From 6241bdddb69abf6d9f387cf784e4906e65107b3c Mon Sep 17 00:00:00 2001 From: Snider Date: Fri, 13 Mar 2026 14:41:42 +0000 Subject: [PATCH] feat(lifecycle): add application lifecycle core.Service with Platform interface and IPC Co-Authored-By: Claude Opus 4.6 --- pkg/lifecycle/messages.go | 29 ++++ pkg/lifecycle/platform.go | 25 ++++ pkg/lifecycle/register.go | 15 ++ pkg/lifecycle/service.go | 63 +++++++++ pkg/lifecycle/service_test.go | 259 ++++++++++++++++++++++++++++++++++ 5 files changed, 391 insertions(+) create mode 100644 pkg/lifecycle/messages.go create mode 100644 pkg/lifecycle/platform.go create mode 100644 pkg/lifecycle/register.go create mode 100644 pkg/lifecycle/service.go create mode 100644 pkg/lifecycle/service_test.go diff --git a/pkg/lifecycle/messages.go b/pkg/lifecycle/messages.go new file mode 100644 index 0000000..cd98e66 --- /dev/null +++ b/pkg/lifecycle/messages.go @@ -0,0 +1,29 @@ +// pkg/lifecycle/messages.go +package lifecycle + +// All lifecycle events are broadcasts (Actions). There are no Queries or Tasks. + +// ActionApplicationStarted fires when the platform application starts. +// Distinct from core.ActionServiceStartup — this is platform-level readiness. +type ActionApplicationStarted struct{} + +// ActionOpenedWithFile fires when the application is opened with a file argument. +type ActionOpenedWithFile struct{ Path string } + +// ActionWillTerminate fires when the application is about to terminate (macOS only). +type ActionWillTerminate struct{} + +// ActionDidBecomeActive fires when the application becomes the active app (macOS only). +type ActionDidBecomeActive struct{} + +// ActionDidResignActive fires when the application resigns active status (macOS only). +type ActionDidResignActive struct{} + +// ActionPowerStatusChanged fires on power status changes (Windows only: APMPowerStatusChange). +type ActionPowerStatusChanged struct{} + +// ActionSystemSuspend fires when the system is about to suspend (Windows only: APMSuspend). +type ActionSystemSuspend struct{} + +// ActionSystemResume fires when the system resumes from suspend (Windows only: APMResume). +type ActionSystemResume struct{} diff --git a/pkg/lifecycle/platform.go b/pkg/lifecycle/platform.go new file mode 100644 index 0000000..a273bdc --- /dev/null +++ b/pkg/lifecycle/platform.go @@ -0,0 +1,25 @@ +// pkg/lifecycle/platform.go +package lifecycle + +// EventType identifies application and system lifecycle events. +type EventType int + +const ( + EventApplicationStarted EventType = iota + EventWillTerminate // macOS only + EventDidBecomeActive // macOS only + EventDidResignActive // macOS only + EventPowerStatusChanged // Windows only (APMPowerStatusChange) + EventSystemSuspend // Windows only (APMSuspend) + EventSystemResume // Windows only (APMResume) +) + +// Platform abstracts the application lifecycle backend (Wails v3). +// OnApplicationEvent registers a handler for a fire-and-forget event type. +// OnOpenedWithFile registers a handler for file-open events (carries path data). +// Both return a cancel function that deregisters the handler. +// Platform-specific events no-op silently on unsupported OS (adapter registers nothing). +type Platform interface { + OnApplicationEvent(eventType EventType, handler func()) func() + OnOpenedWithFile(handler func(path string)) func() +} diff --git a/pkg/lifecycle/register.go b/pkg/lifecycle/register.go new file mode 100644 index 0000000..90e5d40 --- /dev/null +++ b/pkg/lifecycle/register.go @@ -0,0 +1,15 @@ +// pkg/lifecycle/register.go +package lifecycle + +import "forge.lthn.ai/core/go/pkg/core" + +// Register creates a factory closure that captures the Platform adapter. +// The returned function has the signature WithService requires: func(*Core) (any, error). +func Register(p Platform) func(*core.Core) (any, error) { + return func(c *core.Core) (any, error) { + return &Service{ + ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}), + platform: p, + }, nil + } +} diff --git a/pkg/lifecycle/service.go b/pkg/lifecycle/service.go new file mode 100644 index 0000000..41e7ca8 --- /dev/null +++ b/pkg/lifecycle/service.go @@ -0,0 +1,63 @@ +// pkg/lifecycle/service.go +package lifecycle + +import ( + "context" + + "forge.lthn.ai/core/go/pkg/core" +) + +// Options holds configuration for the lifecycle service. +type Options struct{} + +// Service is a core.Service that registers platform lifecycle callbacks +// and broadcasts corresponding IPC Actions. It implements both Startable +// and Stoppable: OnStartup registers all callbacks, OnShutdown cancels them. +type Service struct { + *core.ServiceRuntime[Options] + platform Platform + cancels []func() +} + +// OnStartup registers a platform callback for each EventType and for file-open. +// Each callback broadcasts the corresponding Action via s.Core().ACTION(). +func (s *Service) OnStartup(ctx context.Context) error { + // Register fire-and-forget event callbacks + eventActions := map[EventType]func(){ + EventApplicationStarted: func() { _ = s.Core().ACTION(ActionApplicationStarted{}) }, + EventWillTerminate: func() { _ = s.Core().ACTION(ActionWillTerminate{}) }, + EventDidBecomeActive: func() { _ = s.Core().ACTION(ActionDidBecomeActive{}) }, + EventDidResignActive: func() { _ = s.Core().ACTION(ActionDidResignActive{}) }, + EventPowerStatusChanged: func() { _ = s.Core().ACTION(ActionPowerStatusChanged{}) }, + EventSystemSuspend: func() { _ = s.Core().ACTION(ActionSystemSuspend{}) }, + EventSystemResume: func() { _ = s.Core().ACTION(ActionSystemResume{}) }, + } + + for eventType, handler := range eventActions { + cancel := s.platform.OnApplicationEvent(eventType, handler) + s.cancels = append(s.cancels, cancel) + } + + // Register file-open callback (carries data) + cancel := s.platform.OnOpenedWithFile(func(path string) { + _ = s.Core().ACTION(ActionOpenedWithFile{Path: path}) + }) + s.cancels = append(s.cancels, cancel) + + return nil +} + +// OnShutdown cancels all registered platform callbacks. +func (s *Service) OnShutdown(ctx context.Context) error { + for _, cancel := range s.cancels { + cancel() + } + s.cancels = nil + return nil +} + +// HandleIPCEvents is auto-discovered and registered by core.WithService. +// Lifecycle events are all outbound (platform -> IPC) so there is nothing to handle here. +func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error { + return nil +} diff --git a/pkg/lifecycle/service_test.go b/pkg/lifecycle/service_test.go new file mode 100644 index 0000000..767aefa --- /dev/null +++ b/pkg/lifecycle/service_test.go @@ -0,0 +1,259 @@ +// pkg/lifecycle/service_test.go +package lifecycle + +import ( + "context" + "sync" + "testing" + + "forge.lthn.ai/core/go/pkg/core" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- Mock Platform --- + +type mockPlatform struct { + mu sync.Mutex + handlers map[EventType][]func() + fileHandlers []func(string) +} + +func newMockPlatform() *mockPlatform { + return &mockPlatform{ + handlers: make(map[EventType][]func()), + } +} + +func (m *mockPlatform) OnApplicationEvent(eventType EventType, handler func()) func() { + m.mu.Lock() + defer m.mu.Unlock() + m.handlers[eventType] = append(m.handlers[eventType], handler) + idx := len(m.handlers[eventType]) - 1 + return func() { + m.mu.Lock() + defer m.mu.Unlock() + if idx < len(m.handlers[eventType]) { + m.handlers[eventType] = append(m.handlers[eventType][:idx], m.handlers[eventType][idx+1:]...) + } + } +} + +func (m *mockPlatform) OnOpenedWithFile(handler func(string)) func() { + m.mu.Lock() + defer m.mu.Unlock() + m.fileHandlers = append(m.fileHandlers, handler) + idx := len(m.fileHandlers) - 1 + return func() { + m.mu.Lock() + defer m.mu.Unlock() + if idx < len(m.fileHandlers) { + m.fileHandlers = append(m.fileHandlers[:idx], m.fileHandlers[idx+1:]...) + } + } +} + +// simulateEvent fires all registered handlers for the given event type. +func (m *mockPlatform) simulateEvent(eventType EventType) { + m.mu.Lock() + handlers := make([]func(), len(m.handlers[eventType])) + copy(handlers, m.handlers[eventType]) + m.mu.Unlock() + for _, h := range handlers { + h() + } +} + +// simulateFileOpen fires all registered file-open handlers. +func (m *mockPlatform) simulateFileOpen(path string) { + m.mu.Lock() + handlers := make([]func(string), len(m.fileHandlers)) + copy(handlers, m.fileHandlers) + m.mu.Unlock() + for _, h := range handlers { + h(path) + } +} + +// handlerCount returns the number of registered handlers for event-based + file-based. +func (m *mockPlatform) handlerCount() int { + m.mu.Lock() + defer m.mu.Unlock() + count := len(m.fileHandlers) + for _, handlers := range m.handlers { + count += len(handlers) + } + return count +} + +// --- Test helpers --- + +func newTestLifecycleService(t *testing.T) (*Service, *core.Core, *mockPlatform) { + t.Helper() + mock := newMockPlatform() + c, err := core.New( + core.WithService(Register(mock)), + core.WithServiceLock(), + ) + require.NoError(t, err) + require.NoError(t, c.ServiceStartup(context.Background(), nil)) + svc := core.MustServiceFor[*Service](c, "lifecycle") + return svc, c, mock +} + +// --- Tests --- + +func TestRegister_Good(t *testing.T) { + svc, _, _ := newTestLifecycleService(t) + assert.NotNil(t, svc) +} + +func TestApplicationStarted_Good(t *testing.T) { + _, c, mock := newTestLifecycleService(t) + + var received bool + c.RegisterAction(func(_ *core.Core, msg core.Message) error { + if _, ok := msg.(ActionApplicationStarted); ok { + received = true + } + return nil + }) + + mock.simulateEvent(EventApplicationStarted) + assert.True(t, received) +} + +func TestDidBecomeActive_Good(t *testing.T) { + _, c, mock := newTestLifecycleService(t) + + var received bool + c.RegisterAction(func(_ *core.Core, msg core.Message) error { + if _, ok := msg.(ActionDidBecomeActive); ok { + received = true + } + return nil + }) + + mock.simulateEvent(EventDidBecomeActive) + assert.True(t, received) +} + +func TestDidResignActive_Good(t *testing.T) { + _, c, mock := newTestLifecycleService(t) + + var received bool + c.RegisterAction(func(_ *core.Core, msg core.Message) error { + if _, ok := msg.(ActionDidResignActive); ok { + received = true + } + return nil + }) + + mock.simulateEvent(EventDidResignActive) + assert.True(t, received) +} + +func TestWillTerminate_Good(t *testing.T) { + _, c, mock := newTestLifecycleService(t) + + var received bool + c.RegisterAction(func(_ *core.Core, msg core.Message) error { + if _, ok := msg.(ActionWillTerminate); ok { + received = true + } + return nil + }) + + mock.simulateEvent(EventWillTerminate) + assert.True(t, received) +} + +func TestPowerStatusChanged_Good(t *testing.T) { + _, c, mock := newTestLifecycleService(t) + + var received bool + c.RegisterAction(func(_ *core.Core, msg core.Message) error { + if _, ok := msg.(ActionPowerStatusChanged); ok { + received = true + } + return nil + }) + + mock.simulateEvent(EventPowerStatusChanged) + assert.True(t, received) +} + +func TestSystemSuspend_Good(t *testing.T) { + _, c, mock := newTestLifecycleService(t) + + var received bool + c.RegisterAction(func(_ *core.Core, msg core.Message) error { + if _, ok := msg.(ActionSystemSuspend); ok { + received = true + } + return nil + }) + + mock.simulateEvent(EventSystemSuspend) + assert.True(t, received) +} + +func TestSystemResume_Good(t *testing.T) { + _, c, mock := newTestLifecycleService(t) + + var received bool + c.RegisterAction(func(_ *core.Core, msg core.Message) error { + if _, ok := msg.(ActionSystemResume); ok { + received = true + } + return nil + }) + + mock.simulateEvent(EventSystemResume) + assert.True(t, received) +} + +func TestOpenedWithFile_Good(t *testing.T) { + _, c, mock := newTestLifecycleService(t) + + var receivedPath string + c.RegisterAction(func(_ *core.Core, msg core.Message) error { + if a, ok := msg.(ActionOpenedWithFile); ok { + receivedPath = a.Path + } + return nil + }) + + mock.simulateFileOpen("/Users/snider/Documents/test.txt") + assert.Equal(t, "/Users/snider/Documents/test.txt", receivedPath) +} + +func TestOnShutdown_CancelsAll_Good(t *testing.T) { + svc, _, mock := newTestLifecycleService(t) + + // Verify handlers were registered during OnStartup + assert.Greater(t, mock.handlerCount(), 0, "handlers should be registered after OnStartup") + + // Shutdown should cancel all registrations + err := svc.OnShutdown(context.Background()) + require.NoError(t, err) + + assert.Equal(t, 0, mock.handlerCount(), "all handlers should be cancelled after OnShutdown") +} + +func TestRegister_Bad(t *testing.T) { + // No lifecycle service registered — actions are not received + c, err := core.New(core.WithServiceLock()) + require.NoError(t, err) + + var received bool + c.RegisterAction(func(_ *core.Core, msg core.Message) error { + if _, ok := msg.(ActionApplicationStarted); ok { + received = true + } + return nil + }) + + // No way to trigger events without the service + assert.False(t, received) +}