Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 41 additions & 8 deletions core.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,7 @@ func New(opts ...Option) (*Core, error) {
return nil, err
}
}
c.once.Do(func() {
c.initErr = nil
})
if c.initErr != nil {
return nil, c.initErr
}

if c.serviceLock {
c.servicesLocked = true
}
Expand Down Expand Up @@ -138,13 +133,43 @@ func WithServiceLock() Option {
// ServiceStartup is the entry point for the Core service's startup lifecycle.
// It is called by Wails when the application starts.
func (c *Core) ServiceStartup(ctx context.Context, options application.ServiceOptions) error {
return c.ACTION(ActionServiceStartup{})
c.serviceMu.RLock()
startables := append([]Startable(nil), c.startables...)
c.serviceMu.RUnlock()

var agg error
for _, s := range startables {
if err := s.OnStartup(ctx); err != nil {
agg = errors.Join(agg, err)
}
}

if err := c.ACTION(ActionServiceStartup{}); err != nil {
agg = errors.Join(agg, err)
}

return agg
}

// ServiceShutdown is the entry point for the Core service's shutdown lifecycle.
// It is called by Wails when the application shuts down.
func (c *Core) ServiceShutdown(ctx context.Context) error {
return c.ACTION(ActionServiceShutdown{})
var agg error
if err := c.ACTION(ActionServiceShutdown{}); err != nil {
agg = errors.Join(agg, err)
}

c.serviceMu.RLock()
stoppables := append([]Stoppable(nil), c.stoppables...)
c.serviceMu.RUnlock()

for i := len(stoppables) - 1; i >= 0; i-- {
if err := stoppables[i].OnShutdown(ctx); err != nil {
agg = errors.Join(agg, err)
}
}

return agg
}

// ACTION dispatches a message to all registered IPC handlers.
Expand Down Expand Up @@ -191,6 +216,14 @@ func (c *Core) RegisterService(name string, api any) error {
return fmt.Errorf("core: service %q already registered", name)
}
c.services[name] = api

if s, ok := api.(Startable); ok {
c.startables = append(c.startables, s)
}
if s, ok := api.(Stoppable); ok {
c.stoppables = append(c.stoppables, s)
}

return nil
}

Expand Down
43 changes: 43 additions & 0 deletions core_extra_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package core

import (
"testing"

"github.com/stretchr/testify/assert"
)

type MockServiceWithIPC struct {
MockService
handled bool
}

func (m *MockServiceWithIPC) HandleIPCEvents(c *Core, msg Message) error {
m.handled = true
return nil
}

func TestCore_WithService_IPC(t *testing.T) {
svc := &MockServiceWithIPC{MockService: MockService{Name: "ipc-service"}}
factory := func(c *Core) (any, error) {
return svc, nil
}
c, err := New(WithService(factory))
assert.NoError(t, err)

// Trigger ACTION to verify handler was registered
err = c.ACTION(nil)
assert.NoError(t, err)
assert.True(t, svc.handled)
}

func TestCore_ACTION_Bad(t *testing.T) {
c, err := New()
assert.NoError(t, err)
errHandler := func(c *Core, msg Message) error {
return assert.AnError
}
c.RegisterAction(errHandler)
err = c.ACTION(nil)
assert.Error(t, err)
assert.Contains(t, err.Error(), assert.AnError.Error())
}
164 changes: 164 additions & 0 deletions core_lifecycle_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
package core

import (
"context"
"errors"
"testing"

"github.com/stretchr/testify/assert"
"github.com/wailsapp/wails/v3/pkg/application"
)

type MockStartable struct {
started bool
err error
}

func (m *MockStartable) OnStartup(ctx context.Context) error {
m.started = true
return m.err
}

type MockStoppable struct {
stopped bool
err error
}

func (m *MockStoppable) OnShutdown(ctx context.Context) error {
m.stopped = true
return m.err
}

type MockLifecycle struct {
MockStartable
MockStoppable
}

func TestCore_LifecycleInterfaces(t *testing.T) {
c, err := New()
assert.NoError(t, err)

startable := &MockStartable{}
stoppable := &MockStoppable{}
lifecycle := &MockLifecycle{}

// Register services
err = c.RegisterService("startable", startable)
assert.NoError(t, err)
err = c.RegisterService("stoppable", stoppable)
assert.NoError(t, err)
err = c.RegisterService("lifecycle", lifecycle)
assert.NoError(t, err)

// Startup
err = c.ServiceStartup(context.Background(), application.ServiceOptions{})
assert.NoError(t, err)
assert.True(t, startable.started)
assert.True(t, lifecycle.started)
assert.False(t, stoppable.stopped)

// Shutdown
err = c.ServiceShutdown(context.Background())
assert.NoError(t, err)
assert.True(t, stoppable.stopped)
assert.True(t, lifecycle.stopped)
}

type MockLifecycleWithLog struct {
id string
log *[]string
}

func (m *MockLifecycleWithLog) OnStartup(ctx context.Context) error {
*m.log = append(*m.log, "start-"+m.id)
return nil
}

func (m *MockLifecycleWithLog) OnShutdown(ctx context.Context) error {
*m.log = append(*m.log, "stop-"+m.id)
return nil
}

func TestCore_LifecycleOrder(t *testing.T) {
c, err := New()
assert.NoError(t, err)

var callOrder []string

s1 := &MockLifecycleWithLog{id: "1", log: &callOrder}
s2 := &MockLifecycleWithLog{id: "2", log: &callOrder}

err = c.RegisterService("s1", s1)
assert.NoError(t, err)
err = c.RegisterService("s2", s2)
assert.NoError(t, err)

// Startup
err = c.ServiceStartup(context.Background(), application.ServiceOptions{})
assert.NoError(t, err)
assert.Equal(t, []string{"start-1", "start-2"}, callOrder)

// Reset log
callOrder = nil

// Shutdown
err = c.ServiceShutdown(context.Background())
assert.NoError(t, err)
assert.Equal(t, []string{"stop-2", "stop-1"}, callOrder)
}

func TestCore_LifecycleErrors(t *testing.T) {
c, err := New()
assert.NoError(t, err)

s1 := &MockStartable{err: assert.AnError}
s2 := &MockStoppable{err: assert.AnError}

c.RegisterService("s1", s1)
c.RegisterService("s2", s2)

err = c.ServiceStartup(context.Background(), application.ServiceOptions{})
assert.Error(t, err)
assert.ErrorIs(t, err, assert.AnError)

err = c.ServiceShutdown(context.Background())
assert.Error(t, err)
assert.ErrorIs(t, err, assert.AnError)
}

func TestCore_LifecycleErrors_Aggregated(t *testing.T) {
c, err := New()
assert.NoError(t, err)

// Register action that fails
c.RegisterAction(func(c *Core, msg Message) error {
if _, ok := msg.(ActionServiceStartup); ok {
return errors.New("startup action error")
}
if _, ok := msg.(ActionServiceShutdown); ok {
return errors.New("shutdown action error")
}
return nil
})

// Register service that fails
s1 := &MockStartable{err: errors.New("startup service error")}
s2 := &MockStoppable{err: errors.New("shutdown service error")}

err = c.RegisterService("s1", s1)
assert.NoError(t, err)
err = c.RegisterService("s2", s2)
assert.NoError(t, err)

// Startup
err = c.ServiceStartup(context.Background(), application.ServiceOptions{})
assert.Error(t, err)
assert.Contains(t, err.Error(), "startup action error")
assert.Contains(t, err.Error(), "startup service error")

// Shutdown
err = c.ServiceShutdown(context.Background())
assert.Error(t, err)
assert.Contains(t, err.Error(), "shutdown action error")
assert.Contains(t, err.Error(), "shutdown service error")
}
13 changes: 13 additions & 0 deletions interfaces.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package core

import (
"context"
"embed"
"io"
"sync"
Expand Down Expand Up @@ -46,6 +47,16 @@ type Option func(*Core) error
// Any struct can be a message, allowing for structured data to be passed between services.
type Message interface{}

// Startable is an interface for services that need to perform initialization.
type Startable interface {
OnStartup(ctx context.Context) error
}

// Stoppable is an interface for services that need to perform cleanup.
type Stoppable interface {
OnShutdown(ctx context.Context) error
}

// Core is the central application object that manages services, assets, and communication.
type Core struct {
once sync.Once
Expand All @@ -59,6 +70,8 @@ type Core struct {
serviceMu sync.RWMutex
services map[string]any
servicesLocked bool
startables []Startable
stoppables []Stoppable
}

var instance *Core
Expand Down
18 changes: 18 additions & 0 deletions runtime_pkg_extra_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package core

import (
"testing"

"github.com/stretchr/testify/assert"
)

func TestNewWithFactories_EmptyName(t *testing.T) {
factories := map[string]ServiceFactory{
"": func() (any, error) {
return &MockService{Name: "test"}, nil
},
}
_, err := NewWithFactories(nil, factories)
assert.Error(t, err)
assert.Contains(t, err.Error(), "service name cannot be empty")
}
Loading