diff --git a/frontend/src/app/composition/createAppServices.ts b/frontend/src/app/composition/createAppServices.ts new file mode 100644 index 0000000..1e4b20f --- /dev/null +++ b/frontend/src/app/composition/createAppServices.ts @@ -0,0 +1,62 @@ +import { AppRuntime } from '../orchestration/AppRuntime'; +import type { + ReminderApplicationDependencies, + ReminderApplicationPort, +} from '../../features/reminder/application/interfaces'; +import { + MockLocalScheduleReader, + MockReminderApplication, + MockReminderDispositionSync, + MockReminderStateStore, +} from '../../features/reminder/data/local'; +import { MockAudioPlayback } from '../../infrastructure/audio'; +import { MockLocationMonitor } from '../../infrastructure/location'; +import { + MockAlarmScheduler, + MockDeviceCapability, + MockPopup, + MockReminderRecovery, + MockReminderDelivery, + MockSystemNotification, + MockVibration, +} from '../../infrastructure/notifications'; +import { MockTimeListener } from '../../shared/time'; +import { MockReminderPresenter } from '../../features/reminder/presentation'; + +export type AppServices = { + runtime: AppRuntime; + reminder: ReminderApplicationPort; + reminderPorts: ReminderApplicationDependencies; +}; + +/** 提醒端口的组合根;当前所有具体适配器都是确定性的模拟实现。 */ +export function createAppServices(): AppServices { + const reminderPorts: ReminderApplicationDependencies = { + schedules: new MockLocalScheduleReader(), + time: new MockTimeListener(), + location: new MockLocationMonitor(), + alarms: new MockAlarmScheduler(), + delivery: new MockReminderDelivery(), + audio: new MockAudioPlayback(), + device: new MockDeviceCapability(), + presenter: new MockReminderPresenter(), + systemNotification: new MockSystemNotification(), + popup: new MockPopup(), + vibration: new MockVibration(), + recovery: new MockReminderRecovery(), + state: new MockReminderStateStore(), + dispositionSync: new MockReminderDispositionSync(), + }; + const reminder = new MockReminderApplication(reminderPorts); + + return { + runtime: new AppRuntime([ + { + start: () => reminder.start(), + stop: () => reminder.stop(), + }, + ]), + reminder, + reminderPorts, + }; +} diff --git a/frontend/src/app/orchestration/AppRuntime.ts b/frontend/src/app/orchestration/AppRuntime.ts new file mode 100644 index 0000000..86978b8 --- /dev/null +++ b/frontend/src/app/orchestration/AppRuntime.ts @@ -0,0 +1,99 @@ +export type RuntimeModule = { + start(): Promise | void; + stop(): Promise | void; +}; + +/** 协调应用生命周期模块,不在应用层放置业务规则。 */ +export class AppRuntime { + private started = false; + /** 串行化 start/stop,避免并发重入与半开状态。 */ + private lifecycle: Promise = Promise.resolve(); + + constructor(private readonly modules: readonly RuntimeModule[] = []) {} + + async start(): Promise { + const run = this.lifecycle.then(() => this.startInternal()); + this.lifecycle = run.then( + () => undefined, + () => undefined, + ); + return run; + } + + async stop(): Promise { + const run = this.lifecycle.then(() => this.stopInternal()); + this.lifecycle = run.then( + () => undefined, + () => undefined, + ); + return run; + } + + private async startInternal(): Promise { + if (this.started) return; + + const startedModules: RuntimeModule[] = []; + try { + for (const module of this.modules) { + await module.start(); + startedModules.push(module); + } + this.started = true; + } catch (error) { + const cleanupErrors = await stopAllBestEffort([...startedModules].reverse()); + throw withCleanupErrors(error, cleanupErrors, 'AppRuntime start failed'); + } + } + + private async stopInternal(): Promise { + if (!this.started) return; + + const stopErrors = await stopAllBestEffort([...this.modules].reverse()); + // 无论个别 stop 是否失败,都视为已退出 started,避免半关闭后重试重复 stop。 + this.started = false; + if (stopErrors.length > 0) { + throw withCleanupErrors(undefined, stopErrors, 'AppRuntime stop failed'); + } + } +} + +async function stopAllBestEffort(modules: readonly RuntimeModule[]): Promise { + const errors: unknown[] = []; + for (const module of modules) { + try { + await module.stop(); + } catch (error) { + errors.push(error); + } + } + return errors; +} + +function withCleanupErrors( + primary: unknown | undefined, + cleanupErrors: readonly unknown[], + message: string, +): Error { + if (cleanupErrors.length === 0) { + return primary instanceof Error ? primary : new Error(String(primary)); + } + + const parts: unknown[] = []; + if (primary !== undefined) { + parts.push(primary); + } + parts.push(...cleanupErrors); + + if (typeof AggregateError === 'function') { + return new AggregateError(parts, message); + } + + const detail = parts + .map((error) => (error instanceof Error ? error.message : String(error))) + .join('; '); + const fallback = new Error(`${message}: ${detail}`); + if (primary instanceof Error) { + fallback.cause = primary; + } + return fallback; +}