diff --git a/core.go b/core.go index dd9bcb4..8978426 100644 --- a/core.go +++ b/core.go @@ -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 } @@ -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. @@ -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 } diff --git a/core_extra_test.go b/core_extra_test.go new file mode 100644 index 0000000..38da57f --- /dev/null +++ b/core_extra_test.go @@ -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()) +} diff --git a/core_lifecycle_test.go b/core_lifecycle_test.go new file mode 100644 index 0000000..afef054 --- /dev/null +++ b/core_lifecycle_test.go @@ -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") +} diff --git a/interfaces.go b/interfaces.go index 8c13ad1..1d4f9ac 100644 --- a/interfaces.go +++ b/interfaces.go @@ -1,6 +1,7 @@ package core import ( + "context" "embed" "io" "sync" @@ -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 @@ -59,6 +70,8 @@ type Core struct { serviceMu sync.RWMutex services map[string]any servicesLocked bool + startables []Startable + stoppables []Stoppable } var instance *Core diff --git a/runtime_pkg_extra_test.go b/runtime_pkg_extra_test.go new file mode 100644 index 0000000..c63a4a1 --- /dev/null +++ b/runtime_pkg_extra_test.go @@ -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") +}