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:
parent
073794aed0
commit
6241bdddb6
5 changed files with 391 additions and 0 deletions
29
pkg/lifecycle/messages.go
Normal file
29
pkg/lifecycle/messages.go
Normal 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
25
pkg/lifecycle/platform.go
Normal 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
15
pkg/lifecycle/register.go
Normal 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
63
pkg/lifecycle/service.go
Normal 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
|
||||
}
|
||||
259
pkg/lifecycle/service_test.go
Normal file
259
pkg/lifecycle/service_test.go
Normal 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)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue