feat(lifecycle): add application lifecycle core.Service with Platform interface and IPC

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Snider 2026-03-13 14:41:42 +00:00
parent 073794aed0
commit 6241bdddb6
5 changed files with 391 additions and 0 deletions

29
pkg/lifecycle/messages.go Normal file
View file

@ -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{}

25
pkg/lifecycle/platform.go Normal file
View file

@ -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()
}

15
pkg/lifecycle/register.go Normal file
View file

@ -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
}
}

63
pkg/lifecycle/service.go Normal file
View file

@ -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
}

View file

@ -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)
}