diff --git a/.github/workflows/firebase-hosting-pull-request.yml b/.github/workflows/firebase-hosting-pull-request.yml index 595e1ad..ecc9120 100644 --- a/.github/workflows/firebase-hosting-pull-request.yml +++ b/.github/workflows/firebase-hosting-pull-request.yml @@ -16,6 +16,10 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm - run: npm ci - uses: FirebaseExtended/action-hosting-deploy@v0 with: diff --git a/.nvmrc b/.nvmrc index 2bd5a0a..209e3ef 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -22 +20 diff --git a/docs/TODOS/TECH_DEBT.md b/docs/TODOS/TECH_DEBT.md index afd3238..c95035a 100644 --- a/docs/TODOS/TECH_DEBT.md +++ b/docs/TODOS/TECH_DEBT.md @@ -40,10 +40,10 @@ Status legend: ## Medium Refactors -- [~] Refactor `SettingsService` for typed models and safe subscription lifecycle. +- [x] Refactor `SettingsService` for typed models and safe subscription lifecycle. - Impact: High - Effort: M - - Validation: lint/test/build + settings UI regression check. + - Validation: typed internal stores, guarded keyed-setting operations, explicit persistence subscriptions, `tsc --noEmit`, focused eslint pass. - [x] Optimize `ScrollClassToggleDirective` scroll handling by batching with `requestAnimationFrame` and caching class lists. - Impact: Medium @@ -64,16 +64,25 @@ Status legend: - Impact: High - Effort: M - Validation: app launch/focus/close regression tests. - -- [~] Stabilize `TypewriterService` timer and callback semantics. + - Progress: extracted localStorage open-app persistence into `ApplicationStatePersistenceService`. + - Progress: extracted shared app IDs/types/window constraints into `application-manager.models.ts` and updated consumers. + - Progress: extracted static app registration definitions into `application-catalog.ts`. + - Progress: extracted registry storage and app lookup/query behavior into `ApplicationRegistryService`. + - Progress: extracted open/close/focus/memory/persistence lifecycle state into `ApplicationLifecycleService`. + - Progress: normalized saved instance IDs during restore and forced deterministic re-open for repeated entries. + - Progress: switched persisted open-app payloads to base app IDs for safer multi-instance restoration. + - Progress: added focused unit specs for lifecycle restore/instance behavior (`application-lifecycle.service.spec.ts`, `application-manager.service.spec.ts`). + +- [x] Stabilize `TypewriterService` timer and callback semantics. - Impact: Medium - Effort: M - - Validation: CLI typing flow checks and queue behavior tests. + - Validation: CLI typing flow checks, queue behavior unit tests (`typewriter.service.spec.ts`), `tsc --noEmit`. -- [~] Reduce startup randomness/cost in `FileSystemService`. +- [x] Reduce startup randomness/cost in `FileSystemService`. - Impact: Medium - Effort: M - - Validation: finder behavior and startup responsiveness. + - Validation: finder behavior and startup responsiveness, deterministic startup unit tests (`file-system.service.spec.ts`), `tsc --noEmit`. + - Progress: replaced random deep favorite-folder generation at startup with deterministic lightweight seeded folder content. ## Larger Changes (Riskier, Stage Later) @@ -87,10 +96,10 @@ Status legend: - Effort: L - Validation: XSS regression tests + UI snapshot/manual checks. -- [~] Enforce supported Node LTS through `.nvmrc`/`engines` and CI checks. +- [x] Enforce supported Node LTS through `.nvmrc`/`engines` and CI checks. - Impact: Medium - Effort: S - - Validation: consistent local/CI build success. + - Validation: consistent local/CI build success, `.nvmrc` present, and workflow Node setup parity. ## Suggested Execution Order diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 3da126a..a4fd68d 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -1,10 +1,7 @@ import {Routes} from '@angular/router'; import {redirectGuard} from './guards/redirect.guard'; -import {AuthGuard, redirectUnauthorizedTo, redirectLoggedInTo} from '@angular/fire/auth-guard'; - -const redirectUnauthorizedToLogin = () => redirectUnauthorizedTo([PATH_NAMES.OS_LOGIN]); -const redirectLoggedInToHome = () => redirectLoggedInTo([PATH_NAMES.OS_MAIN]); +import {AuthGuard} from './guards/auth.guard'; export const PATH_NAMES = { OS_MAIN: 'os', @@ -26,19 +23,19 @@ export const routes: Routes = [ path: PATH_NAMES.OS_MAIN, pathMatch: 'full', canActivate: [AuthGuard], - data: {authGuardPipe: redirectUnauthorizedToLogin, animation: 'DesktopWindow'}, + data: {animation: 'DesktopWindow'}, loadComponent: () => import('./components/game/desktop/desktop.component').then(m => m.DesktopComponent) }, { path: `${PATH_NAMES.OS_MAIN}/:app`, pathMatch: 'full', canActivate: [AuthGuard], - data: {authGuardPipe: redirectUnauthorizedToLogin, animation: 'DesktopWindow'}, + data: {animation: 'DesktopWindow'}, loadComponent: () => import('./components/game/desktop/desktop.component').then(m => m.DesktopComponent), }, { path: PATH_NAMES.OS_LOGIN, - data: {authGuardPipe: redirectLoggedInToHome, animation: 'LoginWindow'}, + data: {animation: 'LoginWindow'}, loadComponent: () => import('./components/game/system/login-screen/login-screen.component').then(m => m.LoginScreenComponent), }, { diff --git a/src/app/components/game/apps/markdown-reader/markdown-reader.component.ts b/src/app/components/game/apps/markdown-reader/markdown-reader.component.ts index 3a801c0..72265b6 100644 --- a/src/app/components/game/apps/markdown-reader/markdown-reader.component.ts +++ b/src/app/components/game/apps/markdown-reader/markdown-reader.component.ts @@ -26,7 +26,7 @@ export class MarkdownReaderComponent { private _filename: string = 'gameplay.doc.md'; - @Input() params: any; + @Input() params: unknown; @Input() set filename(value: string) { @@ -42,8 +42,10 @@ export class MarkdownReaderComponent { const currentApp = this.appManager.getCurrentApp(); - - this.filename = currentApp?.params?.file; + const params = currentApp?.params; + if (params && typeof params === 'object' && 'file' in params && typeof params.file === 'string') { + this.filename = params.file; + } console.warn('FILE', this.filename); this.document = this.docsPath + this.filename; } diff --git a/src/app/components/game/desktop/desktop.component.ts b/src/app/components/game/desktop/desktop.component.ts index 70771b3..55e0312 100644 --- a/src/app/components/game/desktop/desktop.component.ts +++ b/src/app/components/game/desktop/desktop.component.ts @@ -7,12 +7,15 @@ import {TypewriterService} from '../services/typewriter.service'; import {SoundService} from '../services/sound.service'; import {UserService} from '../services/user.service'; import {OverlayService} from '../services/overlay.service'; +import { + ApplicationManagerService +} from '../services/application-manager.service'; import { APP_ID, - ApplicationManagerService, WINDOW_HEIGHT_MIN, + WINDOW_HEIGHT_MIN, WINDOW_WIDTH_MAX, WINDOW_WIDTH_MIN -} from '../services/application-manager.service'; +} from '../services/application-manager.models'; import {SystemTrayComponent} from '../system/system-tray/system-tray.component'; import {NotificationService} from '../services/notification.service'; import {MediaItem} from '../services/media.service'; @@ -75,7 +78,7 @@ export class DesktopComponent implements OnInit, AfterViewInit { this.onBeginInvestigation(); } - openApp(id: string, params?: any) { + openApp(id: string, params?: unknown) { this.appManager.openApplication(id, params); } diff --git a/src/app/components/game/factories/application-factory.ts b/src/app/components/game/factories/application-factory.ts index a5d162c..3c4196d 100644 --- a/src/app/components/game/factories/application-factory.ts +++ b/src/app/components/game/factories/application-factory.ts @@ -1,5 +1,5 @@ import {Injectable} from '@angular/core'; -import {AppEntry, ApplicationInstance} from '../services/application-manager.service'; +import {AppEntry, ApplicationInstance} from '../services/application-manager.models'; @Injectable({providedIn: 'root'}) export class ApplicationFactory { @@ -7,7 +7,8 @@ export class ApplicationFactory { id: string, app: AppEntry, offsetX: number, - offsetY: number + offsetY: number, + params?: unknown ): ApplicationInstance { // Dynamic defaults or more sophisticated rules can go here const memory = app.memory || 64; // Default memory @@ -29,7 +30,7 @@ export class ApplicationFactory { installed: app.installed, instanceIndex: app.instanceIndex, focused: app.focused ?? false, - params: app.params, + params: params ?? app.params, }; } } diff --git a/src/app/components/game/services/application-catalog.ts b/src/app/components/game/services/application-catalog.ts new file mode 100644 index 0000000..398cf5b --- /dev/null +++ b/src/app/components/game/services/application-catalog.ts @@ -0,0 +1,331 @@ +import {faCss} from '@fortawesome/free-brands-svg-icons'; +import {faFaceGrin} from '@fortawesome/free-regular-svg-icons'; +import { + faChartSimple, + faCircleInfo, + faCloudSunRain, + faCogs, + faComputer, + faHexagonNodesBolt, + faIcons, + faKeyboard, + faMessage, + faMusic, + faNoteSticky, + faPerson, + faRocket +} from '@fortawesome/free-solid-svg-icons'; +import {ChatBotComponent} from '../../../modules/chat/chat.component'; +import {AboutAppComponent} from '../apps/about-app/about-app.component'; +import {ActivityMonitorComponent} from '../apps/activity-monitor/activity-monitor.component'; +import {CliGameComponent} from '../apps/cli-game/cli-game.component'; +import {IconPlaygroundComponent} from '../apps/icon-playground/icon-playground.component'; +import {MarkdownReaderComponent} from '../apps/markdown-reader/markdown-reader.component'; +import {MessagesComponent} from '../apps/messages/messages.component'; +import {PatchEditorComponent} from '../apps/music-apps/patch-editor/patch-editor.component'; +import {PianoComponent} from '../apps/music-apps/piano/piano.component'; +import {MusicPlayerComponent} from '../apps/music-player/music-player.component'; +import {PlayerConfiguratorComponent} from '../apps/player-configurator/player-configurator.component'; +import {SpaceXComponent} from '../apps/space-x/space-x.component'; +import {TailwindPreviewComponent} from '../apps/tailwind-preview/tailwind-preview.component'; +import {TaskAppComponent} from '../apps/task-app/task-app.component'; +import {TooltipExamplesComponent} from '../apps/tooltip-examples/tooltip-examples.component'; +import {WeatherComponent} from '../apps/weather/weather.component'; +import {FinderAppComponent} from '../system/finder-app/finder-app.component'; +import {SettingsPanelComponent} from '../system/settings-panel/settings-panel.component'; +import {APP_ID, AppEntry, AppType} from './application-manager.models'; + +function createDevelopmentMetadata(): NonNullable { + return { + version: '0.0.1', + author: '', + license: 'MIT', + website: 'https://github.com/colinmichaels' + }; +} + +export function getDefaultApplicationCatalog(): AppEntry[] { + return [ + { + id: APP_ID.player_config, + title: 'Player Config', + component: PlayerConfiguratorComponent, + installed: true, + icon: { + class: 'text-[22px] gradient--bg-green py-0.5 px-2 rounded-lg shadow-lg border-2 border-blue-800 text-black', + svgPath: faPerson + }, + memory: 1024, + maxInstances: 1, + type: AppType.app, + instanceIndex: 0 + }, + { + id: APP_ID.tooltip_example, + title: 'Tooltip Example', + component: TooltipExamplesComponent, + installed: true, + icon: { + class: 'text-teal-500/80 text-[20px] p-0.5 rounded-lg inner-shadow border-2 border-zinc-700', + svgPath: faCogs + }, + memory: 512, + maxInstances: 1, + type: AppType.app, + instanceIndex: 0 + }, + { + id: APP_ID.tasks_app, + title: 'Tasks', + component: TaskAppComponent, + installed: true, + autofit: true, + icon: { + class: 'text-white/80 text-[20px] p-0.5 rounded-lg inner-shadow border-2 border-zinc-700', + svgPath: faNoteSticky + }, + memory: 512, + maxInstances: 1, + type: AppType.app, + instanceIndex: 0 + }, + { + id: APP_ID.music_player, + title: 'Music', + component: MusicPlayerComponent, + installed: true, + windowSize: {height: 400, width: 200}, + autofit: true, + icon: { + class: 'text-white bg-red-600 text-[18px] p-1 rounded-lg inner-shadow border-2 border-zinc-700', + svgPath: faMusic + }, + memory: 512, + maxInstances: 1, + type: AppType.app, + instanceIndex: 0, + status: 'development', + metadata: createDevelopmentMetadata() + }, + { + id: APP_ID.weather_app, + title: 'Weather', + component: WeatherComponent, + installed: true, + windowSize: {height: 600, width: 800}, + autofit: true, + icon: { + class: 'text-blue-900 bg-blue-400 text-[18px] p-1 rounded-lg inner-shadow border-2 border-zinc-700', + svgPath: faCloudSunRain + }, + memory: 512, + maxInstances: 1, + type: AppType.app, + instanceIndex: 0, + status: 'development', + metadata: createDevelopmentMetadata() + }, + { + id: APP_ID.music_piano, + title: 'Piano', + component: PianoComponent, + installed: true, + windowSize: {height: 400, width: 1000}, + autofit: true, + icon: { + class: 'text-white bg-red-600 text-[18px] p-1 rounded-lg inner-shadow border-2 border-zinc-700', + svgPath: faKeyboard + }, + memory: 512, + maxInstances: 1, + type: AppType.app, + instanceIndex: 0, + status: 'development', + metadata: createDevelopmentMetadata() + }, + { + id: APP_ID.music_patch_editor, + title: 'Patch Editor', + component: PatchEditorComponent, + installed: true, + windowSize: {height: 600, width: 600}, + autofit: false, + icon: { + class: 'text-black bg-yellow-600 text-[18px] p-1 rounded-lg inner-shadow border-2 border-zinc-700', + svgPath: faHexagonNodesBolt + }, + memory: 512, + maxInstances: 1, + type: AppType.app, + instanceIndex: 0, + status: 'development', + metadata: createDevelopmentMetadata() + }, + { + id: APP_ID.space_x_app, + title: 'Space X Launches', + component: SpaceXComponent, + installed: true, + windowSize: {height: 800, width: 600}, + autofit: false, + icon: { + class: 'text-white p-1 rounded-lg border-2 border-zinc-700', + svgPath: faRocket + }, + memory: 512, + maxInstances: 1, + type: AppType.app, + instanceIndex: 0, + status: 'development', + metadata: createDevelopmentMetadata() + }, + { + id: APP_ID.messages_app, + title: 'Messages', + component: MessagesComponent, + installed: true, + windowSize: {height: 800, width: 600}, + autofit: false, + icon: { + class: 'text-white p-1 rounded-lg border-2 border-zinc-700', + svgPath: faMessage + }, + memory: 512, + maxInstances: 1, + type: AppType.app, + instanceIndex: 0 + }, + { + id: APP_ID.chat_bot, + title: 'Chat', + component: ChatBotComponent, + installed: true, + windowSize: {height: 800, width: 600}, + autofit: false, + icon: { + class: 'text-white p-1 rounded-lg border-2 border-zinc-700', + svgPath: faMessage + }, + memory: 512, + maxInstances: 1, + type: AppType.app, + instanceIndex: 0 + }, + { + id: APP_ID.markdown_reader, + title: '', + component: MarkdownReaderComponent, + installed: true, + icon: { + class: '', + svgPath: faCogs + }, + memory: 512, + maxInstances: 10, + type: AppType.system, + params: {file: 'colinos-demo.doc.md'}, + instanceIndex: 0 + }, + { + id: APP_ID.tailwind_preview, + title: 'Tailwind Playground', + component: TailwindPreviewComponent, + installed: true, + icon: { + class: 'text-white/80 text-[20px] py-1 px-1.5 rounded-lg border-2 border-zinc-700', + svgPath: faCss + }, + memory: 512, + maxInstances: 1, + type: AppType.app, + instanceIndex: 0, + status: 'development', + metadata: createDevelopmentMetadata() + }, + { + id: APP_ID.icon_playground, + title: 'Icon Playground', + component: IconPlaygroundComponent, + installed: true, + icon: { + class: 'bg-purple-500 text-black/80 p-1 text-[18px] rounded-lg shadow-lg border-2 border-purple-700', + svgPath: faIcons + }, + memory: 512, + maxInstances: 1, + type: AppType.app, + instanceIndex: 0 + }, + { + id: APP_ID.activity_monitor, + title: 'Activity Monitor', + component: ActivityMonitorComponent, + installed: true, + icon: { + class: 'bg-zinc-900 text-sm p-2 rounded-sm shadow-sm border-2 border-zinc-700 text-green-500', + svgPath: faChartSimple + }, + memory: 512, + maxInstances: 1, + type: AppType.system, + instanceIndex: 0 + }, + { + id: APP_ID.cli, + title: 'cli Console', + component: CliGameComponent, + installed: true, + icon: { + class: 'bg-zinc-900 text-green-500 rounded p-1 shadow-lg border-2 border-zinc-500 text-base', + svgPath: faComputer + }, + memory: 1024, + maxInstances: 5, + type: AppType.system, + instanceIndex: 0 + }, + { + id: APP_ID.finder, + title: 'Finder', + component: FinderAppComponent, + installed: true, + icon: { + class: 'text-[20px] gradient--bg-blue p-1 rounded shadow-lg border-2 border-zinc-600 text-black', + svgPath: faFaceGrin + }, + memory: 512, + maxInstances: 5, + type: AppType.system, + instanceIndex: 0 + }, + { + id: APP_ID.system_settings, + title: 'System Settings', + component: SettingsPanelComponent, + installed: true, + icon: { + class: 'text-white/80 text-[20px] p-0.5 rounded-lg inner-shadow border-2 border-zinc-700 text-zinc-800', + svgPath: faCogs + }, + memory: 512, + maxInstances: 1, + type: AppType.system, + instanceIndex: 0 + }, + { + id: APP_ID.about, + title: 'About', + component: AboutAppComponent, + installed: true, + icon: { + class: 'p-2 text-[32px]', + svgPath: faCircleInfo + }, + autofit: true, + memory: 128, + maxInstances: 1, + type: AppType.system, + instanceIndex: 0 + } + ]; +} diff --git a/src/app/components/game/services/application-lifecycle.service.spec.ts b/src/app/components/game/services/application-lifecycle.service.spec.ts new file mode 100644 index 0000000..d2253a7 --- /dev/null +++ b/src/app/components/game/services/application-lifecycle.service.spec.ts @@ -0,0 +1,92 @@ +import {ApplicationFactory} from '../factories/application-factory'; +import {AppEntry, AppType} from './application-manager.models'; +import {ApplicationLifecycleService} from './application-lifecycle.service'; +import {ApplicationStatePersistenceService} from './application-state-persistence.service'; +import {LogService} from './log.service'; +import {NotificationService} from './notification.service'; + +class TestComponent { +} + +function createAppEntry(overrides: Partial = {}): AppEntry { + return { + id: 'cli', + title: 'CLI', + component: TestComponent, + maxInstances: 5, + instanceIndex: 0, + type: AppType.system, + memory: 256, + installed: true, + ...overrides + }; +} + +describe('ApplicationLifecycleService', () => { + let service: ApplicationLifecycleService; + let persistenceMock: jasmine.SpyObj>; + + beforeEach(() => { + persistenceMock = jasmine.createSpyObj>( + 'ApplicationStatePersistenceService', + ['loadOpenApplicationIds', 'saveOpenApplicationIds'] + ); + persistenceMock.loadOpenApplicationIds.and.returnValue([]); + + const notifyMock = jasmine.createSpyObj>('NotificationService', ['show']); + const loggerMock = jasmine.createSpyObj>('LogService', ['debug']); + + service = new ApplicationLifecycleService( + new ApplicationFactory(), + notifyMock as unknown as NotificationService, + loggerMock as unknown as LogService, + persistenceMock as unknown as ApplicationStatePersistenceService + ); + }); + + it('opens an application and passes params into the created instance', () => { + const args = {path: 'trash'}; + const app = createAppEntry({id: 'finder', title: 'Finder'}); + + const result = service.openApplication(app.id, app, args); + + expect(result).toBeTrue(); + expect(service.openApplications.length).toBe(1); + expect(service.openApplications[0].id).toBe('finder'); + expect(service.openApplications[0].params).toEqual(args); + }); + + it('focuses an existing running app when opening without forceNewInstance', () => { + const app = createAppEntry(); + service.openApplication(app.id, app); + + const result = service.openApplication(app.id, app); + + expect(result).toBeTrue(); + expect(service.openApplications.length).toBe(1); + expect(service.getFocusedAppId()).toBe('cli'); + }); + + it('creates unique IDs when force opening additional instances', () => { + const app = createAppEntry(); + service.openApplication(app.id, app); + service.openApplication(app.id, app, undefined, true); + service.openApplication(app.id, app, undefined, true); + + service.closeApplication('cli-2'); + service.openApplication(app.id, app, undefined, true); + + const ids = service.openApplications.map((application) => application.id); + expect(ids.length).toBe(new Set(ids).size); + }); + + it('persists open applications by base app id for restore safety', () => { + const app = createAppEntry(); + service.openApplication(app.id, app); + service.openApplication(app.id, app, undefined, true); + + const lastSaveArgs = persistenceMock.saveOpenApplicationIds.calls.mostRecent().args; + expect(lastSaveArgs[0]).toBe('applications'); + expect(lastSaveArgs[1]).toEqual(['cli', 'cli']); + }); +}); diff --git a/src/app/components/game/services/application-lifecycle.service.ts b/src/app/components/game/services/application-lifecycle.service.ts new file mode 100644 index 0000000..11ba56c --- /dev/null +++ b/src/app/components/game/services/application-lifecycle.service.ts @@ -0,0 +1,248 @@ +import {Injectable} from '@angular/core'; +import {faExclamationTriangle} from '@fortawesome/free-solid-svg-icons'; +import {BehaviorSubject, Observable} from 'rxjs'; +import {ApplicationFactory} from '../factories/application-factory'; +import { + AppEntry, + ApplicationInstance, + AppType, + DEFAULT_WINDOW_OFFSET_X, + DEFAULT_WINDOW_OFFSET_Y +} from './application-manager.models'; +import {NotificationService} from './notification.service'; +import {IMediaItem} from './media.service'; +import {ApplicationStatePersistenceService} from './application-state-persistence.service'; +import {LogService} from './log.service'; + +const INSTANCE_LIMIT_ERROR_MESSAGE = 'Cannot open application. Maximum number of instances reached.'; +const INSTANCE_LIMIT_ERROR_TITLE = 'System Error'; +const OPEN_APPS_STORAGE_KEY = 'applications'; + +@Injectable({providedIn: 'root'}) +export class ApplicationLifecycleService { + private readonly applications = new BehaviorSubject([]); + private readonly focusedAppId = new BehaviorSubject(null); + private readonly maxMemory = 16 * 1024; // MB + + private readonly notifyTemplate: IMediaItem = { + id: 'error', + title: 'Error', + content: { + type: 'icon', + data: { + type: 'fontawesome', + name: 'fa fa-exclamation-triangle', + svgPath: faExclamationTriangle + } + } + }; + + constructor( + private readonly appFactory: ApplicationFactory, + private readonly notify: NotificationService, + private readonly logger: LogService, + private readonly applicationStatePersistence: ApplicationStatePersistenceService + ) { + } + + get openApplications(): ApplicationInstance[] { + return this.applications.getValue(); + } + + get totalMemory(): number { + return this.maxMemory; + } + + get usedMemory(): number { + return this.openApplications.reduce((sum, app) => sum + app.memory, 16); + } + + getRunningApps(type: AppEntry['type'] = AppType.app): ApplicationInstance[] { + return this.openApplications.filter((app) => app.type === type); + } + + loadSavedApplicationIds(): string[] { + return this.applicationStatePersistence.loadOpenApplicationIds(OPEN_APPS_STORAGE_KEY); + } + + openApplication(appId: string, app?: AppEntry, args?: unknown, forceNewInstance = false): boolean { + this.logger.debug('args', args); + + if (app?.running && !forceNewInstance) { + const existing = this.getMostRecentApplicationInstance(app.id); + if (existing) { + this.setApplicationFocus(existing.id, existing.offsetX, existing.offsetY); + return true; + } + } + + if (!app) { + return false; + } + + if (this.usedMemory + app.memory > this.maxMemory) { + this.showErrorNotification('Not enough memory to open application', INSTANCE_LIMIT_ERROR_TITLE); + return false; + } + + const lastApplication = this.applications.value[this.applications.value.length - 1]; + const newOffsetX = lastApplication?.offsetX !== undefined + ? lastApplication.offsetX + DEFAULT_WINDOW_OFFSET_X + : DEFAULT_WINDOW_OFFSET_X; + const newOffsetY = lastApplication?.offsetY !== undefined + ? lastApplication.offsetY + DEFAULT_WINDOW_OFFSET_Y + : DEFAULT_WINDOW_OFFSET_Y; + + if (this.isInstanceLimitReached(app)) { + return false; + } + + const openInstanceCount = this.getOpenInstanceCount(app.id); + const newAppInstanceId = this.getNextInstanceId(appId); + app.instanceIndex = openInstanceCount + 1; + app.running = true; + + this.applications.next([ + ...this.applications.value, + this.appFactory.createInstance(newAppInstanceId, app, newOffsetX, newOffsetY, args) + ]); + + this.saveOpenApplications(); + + const focusSuccessful = this.setApplicationFocus(newAppInstanceId, newOffsetX, newOffsetY); + if (!focusSuccessful) { + this.showErrorNotification(`Failed to set focus for app with id: ${newAppInstanceId}`, INSTANCE_LIMIT_ERROR_TITLE); + this.notify.show({ + message: `Failed to set focus for terminal with id: ${newAppInstanceId}`, + title: 'Terminal Error', + type: 'error', + media: this.notifyTemplate, + }); + return false; + } + + return true; + } + + closeApplication(id: string): void { + const application = this.getAppByID(id); + if (!application) { + return; + } + + application.running = false; + + const remainingApplications = this.applications.getValue().filter((app) => app.id !== id); + this.applications.next(remainingApplications); + + if (application.parent) { + const remainingInstances = remainingApplications.filter((openApp) => openApp.parent?.id === application.parent?.id); + application.parent.instanceIndex = remainingInstances.length; + application.parent.running = remainingInstances.length > 0; + } + + this.saveOpenApplications(); + } + + closeAllApps(): void { + this.applications.getValue().forEach((app) => this.closeApplication(app.id)); + } + + setApplicationFocus(id: string, offsetX?: number, offsetY?: number): boolean { + const application = this.applications.value.find((app) => app.id === id); + if (id === 'desktop') { + this.focusedAppId.next(id); + return true; + } + if (!application) { + return false; + } + + this.focusedAppId.next(id); + application.focused = true; + application.offsetX = offsetX ?? DEFAULT_WINDOW_OFFSET_X; + application.offsetY = offsetY ?? DEFAULT_WINDOW_OFFSET_Y; + + const index = this.applications.value.findIndex((app) => app.id === id); + if (index !== -1) { + this.applications.next([ + ...this.applications.value.slice(0, index), + ...this.applications.value.slice(index + 1), + application + ]); + } + + return true; + } + + getAppByID(id: string): ApplicationInstance | undefined { + return this.applications.value.find((app) => app.id === id); + } + + getFocus$(): Observable { + return this.focusedAppId.asObservable(); + } + + getFocusedAppId(): string | null { + return this.focusedAppId.getValue(); + } + + getCurrentApp(): ApplicationInstance | undefined { + const focusedAppId = this.getFocusedAppId(); + return this.openApplications.find((app) => app.id === focusedAppId); + } + + private isInstanceLimitReached(app: AppEntry): boolean { + const openInstanceCount = this.getOpenInstanceCount(app.id); + if (openInstanceCount < app.maxInstances) { + return false; + } + + if (app.maxInstances > 1) { + this.showErrorNotification(INSTANCE_LIMIT_ERROR_MESSAGE, INSTANCE_LIMIT_ERROR_TITLE); + } + return true; + } + + private showErrorNotification(message: string, title: string): void { + this.notify.show({ + message, + title, + type: 'error', + media: this.notifyTemplate, + }); + } + + private saveOpenApplications(): void { + const openAppIds = this.applications.value.map((app) => app.parent?.id ?? app.id); + this.applicationStatePersistence.saveOpenApplicationIds(OPEN_APPS_STORAGE_KEY, openAppIds); + } + + private getNextInstanceId(appId: string): string { + const existingIds = new Set( + this.applications.value + .filter((openApp) => openApp.parent?.id === appId) + .map((openApp) => openApp.id) + ); + + if (!existingIds.has(appId)) { + return appId; + } + + let suffix = 2; + while (existingIds.has(`${appId}-${suffix}`)) { + suffix++; + } + + return `${appId}-${suffix}`; + } + + private getOpenInstanceCount(appId: string): number { + return this.applications.value.filter((openApp) => openApp.parent?.id === appId).length; + } + + private getMostRecentApplicationInstance(appId: string): ApplicationInstance | undefined { + const appInstances = this.applications.value.filter((openApp) => openApp.parent?.id === appId); + return appInstances[appInstances.length - 1]; + } +} diff --git a/src/app/components/game/services/application-manager.models.ts b/src/app/components/game/services/application-manager.models.ts new file mode 100644 index 0000000..4bf1ab4 --- /dev/null +++ b/src/app/components/game/services/application-manager.models.ts @@ -0,0 +1,82 @@ +import {Type} from '@angular/core'; +import type {IconProp} from '@fortawesome/fontawesome-svg-core'; + +export interface AppMetadata { + version?: string; + author?: string; + license?: string; + website?: string; +} + +export interface AppWindowSize { + width?: number; + height?: number; +} + +export interface AppIcon { + class?: string; + svgPath: IconProp; +} + +export type ApplicationKind = 'system' | 'other' | 'app'; + +export interface AppEntry { + id: string; + title: string; + description?: string; + component: Type; + maxInstances: number; + instanceIndex: number; + type: ApplicationKind; + icon?: AppIcon; + memory: number; + metadata?: AppMetadata; + status?: 'development' | 'stable' | 'deprecated' | 'obsolete'; + autofit?: boolean; + windowSize?: AppWindowSize; + installed: boolean; + running?: boolean; + focused?: boolean; + params?: unknown; +} + +export interface ApplicationInstance extends AppEntry { + autofit: boolean; + parent: AppEntry | null; + offsetX?: number; + offsetY?: number; +} + +export enum AppType { + system = 'system', + app = 'app', + other = 'other' +} + +export enum APP_ID { + cli = 'cli', + finder = 'finder', + about = 'about', + player_config = 'player-config', + music_piano = 'music-piano', + music_patch_editor = 'music-patch-editor', + activity_monitor = 'activity-monitor', + system_settings = 'system-settings', + markdown_reader = 'markdown-reader', + music_player = 'music-player', + tailwind_preview = 'tailwind-preview', + tasks_app = 'tasks', + tooltip_example = 'tooltip-example', + space_x_app = 'space-x-app', + icon_playground = 'icon-playground', + weather_app = 'weather-app', + messages_app = 'messages-app', + chat_bot = 'chat-bot', +} + +export const WINDOW_WIDTH_MIN = 480; +export const WINDOW_WIDTH_MAX = 1024; +export const WINDOW_HEIGHT_MIN = 480; +export const WINDOW_HEIGHT_MAX = 1024; +export const DEFAULT_WINDOW_OFFSET_Y = 40; +export const DEFAULT_WINDOW_OFFSET_X = 40; diff --git a/src/app/components/game/services/application-manager.service.spec.ts b/src/app/components/game/services/application-manager.service.spec.ts new file mode 100644 index 0000000..c116a0b --- /dev/null +++ b/src/app/components/game/services/application-manager.service.spec.ts @@ -0,0 +1,78 @@ +import {AppEntry, AppType} from './application-manager.models'; +import {ApplicationLifecycleService} from './application-lifecycle.service'; +import {ApplicationManagerService} from './application-manager.service'; +import {ApplicationRegistryService} from './application-registry.service'; + +class TestComponent { +} + +function createAppEntry(overrides: Partial = {}): AppEntry { + return { + id: 'cli', + title: 'CLI', + component: TestComponent, + maxInstances: 5, + instanceIndex: 0, + type: AppType.system, + memory: 256, + installed: true, + ...overrides + }; +} + +describe('ApplicationManagerService', () => { + it('normalizes saved instance ids and restores repeated apps as new instances', () => { + const app = createAppEntry(); + + const registryMock = jasmine.createSpyObj>( + 'ApplicationRegistryService', + ['getInstalledAppById'] + ); + registryMock.getInstalledAppById.and.callFake((id: string) => id === 'cli' ? app : undefined); + + const lifecycleMock = jasmine.createSpyObj>( + 'ApplicationLifecycleService', + ['loadSavedApplicationIds', 'openApplication'] + ); + lifecycleMock.loadSavedApplicationIds.and.returnValue(['cli', 'cli-2', 'unknown-3']); + lifecycleMock.openApplication.and.returnValue(true); + + new ApplicationManagerService( + registryMock as unknown as ApplicationRegistryService, + lifecycleMock as unknown as ApplicationLifecycleService + ); + + expect(lifecycleMock.openApplication.calls.allArgs()).toEqual([ + ['cli', app, undefined, false], + ['cli', app, undefined, true] + ]); + }); + + it('delegates openApplication to lifecycle using resolved app registry entry', () => { + const app = createAppEntry({id: 'finder', title: 'Finder'}); + + const registryMock = jasmine.createSpyObj>( + 'ApplicationRegistryService', + ['getInstalledAppById'] + ); + registryMock.getInstalledAppById.and.callFake((id: string) => id === 'finder' ? app : undefined); + + const lifecycleMock = jasmine.createSpyObj>( + 'ApplicationLifecycleService', + ['loadSavedApplicationIds', 'openApplication'] + ); + lifecycleMock.loadSavedApplicationIds.and.returnValue([]); + lifecycleMock.openApplication.and.returnValue(true); + + const service = new ApplicationManagerService( + registryMock as unknown as ApplicationRegistryService, + lifecycleMock as unknown as ApplicationLifecycleService + ); + + const args = {path: 'trash'}; + const result = service.openApplication('finder', args); + + expect(result).toBeTrue(); + expect(lifecycleMock.openApplication).toHaveBeenCalledWith('finder', app, args); + }); +}); diff --git a/src/app/components/game/services/application-manager.service.ts b/src/app/components/game/services/application-manager.service.ts index c6758ea..b3d48be 100644 --- a/src/app/components/game/services/application-manager.service.ts +++ b/src/app/components/game/services/application-manager.service.ts @@ -1,745 +1,111 @@ -import {Injectable, Type} from '@angular/core'; -import {NotificationService} from './notification.service'; -import {IMediaItem} from './media.service'; -import { - faChartSimple, - faCircleInfo, faCloudSunRain, faCogs, - faComputer, - faExclamationTriangle, faHexagonNodesBolt, faIcons, faKeyboard, faMessage, faMusic, faNoteSticky, - faPerson, faRocket -} from '@fortawesome/free-solid-svg-icons'; -import {faFaceGrin} from '@fortawesome/free-regular-svg-icons'; - -/** installed apps */ -import {ActivityMonitorComponent} from '../apps/activity-monitor/activity-monitor.component'; -import {SettingsPanelComponent} from '../system/settings-panel/settings-panel.component'; -import {CliGameComponent} from '../apps/cli-game/cli-game.component'; -import {FinderAppComponent} from '../system/finder-app/finder-app.component'; -import {BehaviorSubject, Observable} from 'rxjs'; -import {AboutAppComponent} from '../apps/about-app/about-app.component'; -import {PlayerConfiguratorComponent} from '../apps/player-configurator/player-configurator.component'; -import {TooltipExamplesComponent} from '../apps/tooltip-examples/tooltip-examples.component'; -import {MarkdownReaderComponent} from '../apps/markdown-reader/markdown-reader.component'; -import {ApplicationFactory} from '../factories/application-factory'; -import {TailwindPreviewComponent} from '../apps/tailwind-preview/tailwind-preview.component'; -import {faCss} from '@fortawesome/free-brands-svg-icons'; -import {IconPlaygroundComponent} from '../apps/icon-playground/icon-playground.component'; -import {TaskAppComponent} from '../apps/task-app/task-app.component'; -import {MusicPlayerComponent} from '../apps/music-player/music-player.component'; -import {SpaceXComponent} from '../apps/space-x/space-x.component'; -import {LogService} from './log.service'; -import {PianoComponent} from '../apps/music-apps/piano/piano.component'; -import {PatchEditorComponent} from '../apps/music-apps/patch-editor/patch-editor.component'; -import {WeatherComponent} from '../apps/weather/weather.component'; -import {MessagesComponent} from '../apps/messages/messages.component'; -import {ChatBotComponent} from '../../../modules/chat/chat.component'; - -export interface ApplicationInstance extends AppEntry { - id: string; - title: string; - parent: AppEntry | null; - component: Type; - autofit: boolean; - windowSize?: { - width?: number; - height?: number; - }; - maxInstances: number; - instanceIndex: number; - type: 'system' | 'other' | 'app', - memory: number; // in MB - offsetX?: number; - offsetY?: number; - icon?: { - class?: string; - svgPath?: any; - }, - running?: boolean; - focused?: boolean; - params?: any; - installed: boolean; -} - -export interface AppEntry { - id: string; - title: string; - description?: string; - component: Type; - maxInstances: number; - instanceIndex: number; - type: 'system' | 'other' | 'app', - icon?: { - class?: string; - svgPath?: any; - } - memory: number; - metadata?: { - version?: string; - author?: string; - license?: string; - website?: string; - } - status?: 'development' | 'stable' | 'deprecated' | 'obsolete' - autofit?: boolean; - windowSize?: { - width?: number; - height?: number; - }; - installed: boolean; - running?: boolean; - focused?: boolean; - params?: any; -} - -export enum AppType { - system = 'system', - app = 'app', - other = 'other' -} - -export const WINDOW_WIDTH_MIN = 480; -export const WINDOW_WIDTH_MAX = 1024 -export const WINDOW_HEIGHT_MIN = 480; -export const WINDOW_HEIGHT_MAX = 1024; - -export const DEFAULT_WINDOW_OFFSET_Y = 40; -export const DEFAULT_WINDOW_OFFSET_X = 40; - -const INSTANCE_LIMIT_ERROR_MESSAGE = "Cannot open application. Maximum number of instances reached."; -const INSTANCE_LIMIT_ERROR_TITLE = "System Error"; -const OPEN_APPS_STORAGE_KEY = 'applications'; - - -export enum APP_ID { - cli = 'cli', - finder = 'finder', - about = 'about', - player_config = 'player-config', - music_piano = 'music-piano', - music_patch_editor = 'music-patch-editor', - activity_monitor = 'activity-monitor', - system_settings = 'system-settings', - markdown_reader = 'markdown-reader', - music_player = 'music-player', - tailwind_preview = 'tailwind-preview', - tasks_app = 'tasks', - tooltip_example = 'tooltip-example', - space_x_app = 'space-x-app', - icon_playground = 'icon-playground', - weather_app = 'weather-app', - messages_app = 'messages-app', - chat_bot = 'chat-bot', -} +import {Injectable} from '@angular/core'; +import {Observable} from 'rxjs'; +import {ApplicationLifecycleService} from './application-lifecycle.service'; +import {ApplicationRegistryService} from './application-registry.service'; +import {AppEntry, ApplicationInstance, AppType} from './application-manager.models'; @Injectable({providedIn: 'root'}) export class ApplicationManagerService { - private applications: BehaviorSubject = new BehaviorSubject([]); - private appRegistry: AppEntry[] = []; - private maxMemory = 16 * 1024; // MB - private focusedAppId = new BehaviorSubject(null); - private readonly notifyTemplate: IMediaItem = { - id: 'error', - title: 'Error', - content: { - type: 'icon', - data: { - type: 'fontawesome', - name: 'fa fa-exclamation-triangle', - svgPath: faExclamationTriangle - } - } - } - constructor( - private appFactory: ApplicationFactory, - private notify: NotificationService, - private logger: LogService + private readonly applicationRegistry: ApplicationRegistryService, + private readonly applicationLifecycle: ApplicationLifecycleService ) { - this.registerApps(); - this.registerSystemApps(); this.loadSavedApplications(); } - private registerApps() { - - this.registerApp({ - id: APP_ID.player_config, - title: 'Player Config', - component: PlayerConfiguratorComponent, - installed: true, - icon: { - class: 'text-[22px] gradient--bg-green py-0.5 px-2 rounded-lg shadow-lg border-2 border-blue-800 text-black', - svgPath: faPerson - }, - memory: 1024, - maxInstances: 1, - type: AppType.app, - instanceIndex: 0 - }); - - this.registerApp({ - id: APP_ID.tooltip_example, - title: 'Tooltip Example', - component: TooltipExamplesComponent, - installed: true, - icon: { - class: 'text-teal-500/80 text-[20px] p-0.5 rounded-lg inner-shadow border-2 border-zinc-700', - svgPath: faCogs - }, - memory: 512, - maxInstances: 1, - type: AppType.app, - instanceIndex: 0 - }); - - this.registerApp({ - id: APP_ID.tasks_app, - title: 'Tasks', - component: TaskAppComponent, - installed: true, - autofit: true, - icon: { - class: 'text-white/80 text-[20px] p-0.5 rounded-lg inner-shadow border-2 border-zinc-700', - svgPath: faNoteSticky - }, - memory: 512, - maxInstances: 1, - type: AppType.app, - instanceIndex: 0 - }); - - this.registerApp({ - id: APP_ID.music_player, - title: 'Music', - component: MusicPlayerComponent, - installed: true, - windowSize: {height: 400, width: 200}, - autofit: true, - icon: { - class: 'text-white bg-red-600 text-[18px] p-1 rounded-lg inner-shadow border-2 border-zinc-700', - svgPath: faMusic - }, - memory: 512, - maxInstances: 1, - type: AppType.app, - instanceIndex: 0, - status: 'development', - metadata: { - version: '0.0.1', - author: '', - license: 'MIT', - website: 'https://github.com/colinmichaels' - } - }); - - this.registerApp({ - id: APP_ID.weather_app, - title: 'Weather', - component: WeatherComponent, - installed: true, - windowSize: {height: 600, width: 800}, - autofit: true, - icon: { - class: 'text-blue-900 bg-blue-400 text-[18px] p-1 rounded-lg inner-shadow border-2 border-zinc-700', - svgPath: faCloudSunRain - }, - memory: 512, - maxInstances: 1, - type: AppType.app, - instanceIndex: 0, - status: 'development', - metadata: { - version: '0.0.1', - author: '', - license: 'MIT', - website: 'https://github.com/colinmichaels' - } - }); - - this.registerApp({ - id: APP_ID.music_piano, - title: 'Piano', - component: PianoComponent, - installed: true, - windowSize: {height: 400, width: 1000}, - autofit: true, - icon: { - class: 'text-white bg-red-600 text-[18px] p-1 rounded-lg inner-shadow border-2 border-zinc-700', - svgPath: faKeyboard - }, - memory: 512, - maxInstances: 1, - type: AppType.app, - instanceIndex: 0, - status: 'development', - metadata: { - version: '0.0.1', - author: '', - license: 'MIT', - website: 'https://github.com/colinmichaels' - } - }); - - this.registerApp({ - id: APP_ID.music_patch_editor, - title: 'Patch Editor', - component: PatchEditorComponent, - installed: true, - windowSize: {height: 600, width: 600}, - autofit: false, - icon: { - class: 'text-black bg-yellow-600 text-[18px] p-1 rounded-lg inner-shadow border-2 border-zinc-700', - svgPath: faHexagonNodesBolt - }, - memory: 512, - maxInstances: 1, - type: AppType.app, - instanceIndex: 0, - status: 'development', - metadata: { - version: '0.0.1', - author: '', - license: 'MIT', - website: 'https://github.com/colinmichaels' - } - }); + private loadSavedApplications(): void { + const restoredOpenCounts = new Map(); - - this.registerApp({ - id: APP_ID.space_x_app, - title: 'Space X Launches', - component: SpaceXComponent, - installed: true, - windowSize: {height: 800, width: 600}, - autofit: false, - icon: { - class: 'text-white p-1 rounded-lg border-2 border-zinc-700', - svgPath: faRocket - }, - memory: 512, - maxInstances: 1, - type: AppType.app, - instanceIndex: 0, - status: 'development', - metadata: { - version: '0.0.1', - author: '', - license: 'MIT', - website: 'https://github.com/colinmichaels' + this.applicationLifecycle.loadSavedApplicationIds().forEach((savedAppId) => { + const appId = this.normalizeSavedAppId(savedAppId); + const app = this.applicationRegistry.getInstalledAppById(appId); + if (!app) { + return; } - }); - - this.registerApp({ - id: APP_ID.messages_app, - title: 'Messages', - component: MessagesComponent, - installed: true, - windowSize: {height: 800, width: 600}, - autofit: false, - icon: { - class: 'text-white p-1 rounded-lg border-2 border-zinc-700', - svgPath: faMessage - }, - memory: 512, - maxInstances: 1, - type: AppType.app, - instanceIndex: 0 - }); - - this.registerApp({ - id: APP_ID.chat_bot, - title: 'Chat', - component: ChatBotComponent, - installed: true, - windowSize: {height: 800, width: 600}, - autofit: false, - icon: { - class: 'text-white p-1 rounded-lg border-2 border-zinc-700', - svgPath: faMessage - }, - memory: 512, - maxInstances: 1, - type: AppType.app, - instanceIndex: 0 - }); - - this.registerApp({ - id: APP_ID.markdown_reader, - title: '', - component: MarkdownReaderComponent, - installed: true, - icon: { - class: '', - svgPath: faCogs - }, - memory: 512, - maxInstances: 10, - type: AppType.system, - params: {file: 'colinos-demo.doc.md'}, - instanceIndex: 0 - }); - this.registerApp({ - id: APP_ID.tailwind_preview, - title: 'Tailwind Playground', - component: TailwindPreviewComponent, - installed: true, - icon: { - class: 'text-white/80 text-[20px] py-1 px-1.5 rounded-lg border-2 border-zinc-700', - svgPath: faCss - }, - memory: 512, - maxInstances: 1, - type: AppType.app, - instanceIndex: 0, - status: 'development', - metadata: { - version: '0.0.1', - author: '', - license: 'MIT', - website: 'https://github.com/colinmichaels' + const restoredCount = restoredOpenCounts.get(appId) ?? 0; + const opened = this.applicationLifecycle.openApplication(appId, app, undefined, restoredCount > 0); + if (opened) { + restoredOpenCounts.set(appId, restoredCount + 1); } }); - - this.registerApp({ - id: APP_ID.icon_playground, - title: 'Icon Playground', - component: IconPlaygroundComponent, - installed: true, - icon: { - class: 'bg-purple-500 text-black/80 p-1 text-[18px] rounded-lg shadow-lg border-2 border-purple-700', - svgPath: faIcons - }, - memory: 512, - maxInstances: 1, - type: AppType.app, - instanceIndex: 0 - }); } - private registerSystemApps() { - - this.registerApp({ - id: APP_ID.activity_monitor, - title: 'Activity Monitor', - component: ActivityMonitorComponent, - installed: true, - icon: { - class: 'bg-zinc-900 text-sm p-2 rounded-sm shadow-sm border-2 border-zinc-700 text-green-500', - svgPath: faChartSimple - }, - memory: 512, - maxInstances: 1, - type: AppType.system, - instanceIndex: 0 - }); - - this.registerApp({ - id: APP_ID.cli, - title: 'cli Console', - component: CliGameComponent, - installed: true, - icon: { - class: 'bg-zinc-900 text-green-500 rounded p-1 shadow-lg border-2 border-zinc-500 text-base', - svgPath: faComputer - }, - memory: 1024, - maxInstances: 5, - type: AppType.system, - instanceIndex: 0 - }); - - this.registerApp({ - id: APP_ID.finder, - title: 'Finder', - component: FinderAppComponent, - installed: true, - icon: { - class: 'text-[20px] gradient--bg-blue p-1 rounded shadow-lg border-2 border-zinc-600 text-black', - svgPath: faFaceGrin - }, - memory: 512, - maxInstances: 5, - type: AppType.system, - instanceIndex: 0 - }); - - this.registerApp({ - id: APP_ID.system_settings, - title: 'System Settings', - component: SettingsPanelComponent, - installed: true, - icon: { - class: 'text-white/80 text-[20px] p-0.5 rounded-lg inner-shadow border-2 border-zinc-700 text-zinc-800', - svgPath: faCogs - }, - memory: 512, - maxInstances: 1, - type: AppType.system, - instanceIndex: 0 - }); - - this.registerApp({ - id: APP_ID.about, - title: 'About', - component: AboutAppComponent, - installed: true, - icon: { - class: 'p-2 text-[32px]', - svgPath: faCircleInfo - }, - autofit: true, - memory: 128, - maxInstances: 1, - type: AppType.system, - instanceIndex: 0 - }); - - } - - private loadSavedApplications() { - const appIds = this.getSavedApplicationIds(); - for (const appId of appIds) { - this.openApplication(appId); + private normalizeSavedAppId(savedAppId: string): string { + if (this.applicationRegistry.getInstalledAppById(savedAppId)) { + return savedAppId; } - } - private getSavedApplicationIds(): string[] { - const savedApps = localStorage.getItem(OPEN_APPS_STORAGE_KEY); - if (!savedApps) { - return []; + const normalizedId = savedAppId.replace(/-\d+$/, ''); + if (this.applicationRegistry.getInstalledAppById(normalizedId)) { + return normalizedId; } - try { - const parsed: unknown = JSON.parse(savedApps); - if (!Array.isArray(parsed)) { - return []; - } - - return parsed - .map((entry) => { - if (typeof entry === 'string') { - return entry; - } - if (entry && typeof entry === 'object' && 'id' in entry) { - const maybeId = (entry as { id?: unknown }).id; - return typeof maybeId === 'string' ? maybeId : null; - } - return null; - }) - .filter((id): id is string => Boolean(id)); - } catch (error) { - this.logger.warn('Failed to parse saved applications.', {error}); - return []; - } + return savedAppId; } get openApplications(): ApplicationInstance[] { - return this.applications.getValue(); + return this.applicationLifecycle.openApplications; } - getRunningApps(type = 'app'): ApplicationInstance[] { - return this.openApplications.filter((app) => { - return app.type === type; - }); + getRunningApps(type: AppEntry['type'] = AppType.app): ApplicationInstance[] { + return this.applicationLifecycle.getRunningApps(type); } - getApps(type = 'app'): ApplicationInstance[] { - return (this.registeredApps as ApplicationInstance[]).filter((app) => { - return app.type === type; - }); + getApps(type: AppEntry['type'] = AppType.app): AppEntry[] { + return this.applicationRegistry.getApps(type); } get totalMemory(): number { - return this.maxMemory; + return this.applicationLifecycle.totalMemory; } get usedMemory(): number { - return this.applications.getValue().reduce((sum, t) => sum + t.memory, 16); + return this.applicationLifecycle.usedMemory; } get registeredApps(): AppEntry[] { - return this.appRegistry; + return this.applicationRegistry.registeredApps; } - registerApp(app: AppEntry) { - if (!this.appRegistry.some(a => a.id === app.id)) { - this.appRegistry.push(app); - } + registerApp(app: AppEntry): void { + this.applicationRegistry.registerApp(app); } - unregisterApp(id: string) { - this.appRegistry = this.appRegistry.filter(a => a.id !== id); + unregisterApp(id: string): void { + this.applicationRegistry.unregisterApp(id); } - openApplication(id: string, args?: []): boolean { - const app = this.appRegistry.find(a => a.id === id && a.installed); - - this.logger.debug('args', args); - - const focusId = this.focusedAppId.getValue(); - - if (focusId === id) return true; - - if (app?.running) { - const existing = this.getMostRecentApplicationInstance(id); - if (existing) { - this.setApplicationFocus(existing.id, existing.offsetX, existing.offsetY); - return true; - } - } - - if (!app) return false; - - if (this.usedMemory + app.memory > this.maxMemory) { - this.showErrorNotification('Not enough memory to open application', 'System Error'); - return false; - } - - const lastApplication = this.applications.value[this.applications.value.length - 1]; - - const newOffsetX = lastApplication?.offsetX !== undefined - ? lastApplication.offsetX + DEFAULT_WINDOW_OFFSET_X - : DEFAULT_WINDOW_OFFSET_X; - - const newOffsetY = lastApplication?.offsetY !== undefined - ? lastApplication.offsetY + DEFAULT_WINDOW_OFFSET_Y - : DEFAULT_WINDOW_OFFSET_Y; - - if (this.isInstanceLimitReached(app)) { - return false; - } - - const openInstanceCount = this.getOpenInstanceCount(app.id); - const newAppInstanceId = openInstanceCount > 0 ? `${id}-${openInstanceCount + 1}` : id; - app.instanceIndex = openInstanceCount + 1; - app.running = true; - - this.applications.next([...this.applications.value, this.appFactory - .createInstance(newAppInstanceId, app, newOffsetX, newOffsetY)]); - - - this.saveOpenApplications(); - - const focusSuccessful = this.setApplicationFocus(newAppInstanceId, newOffsetX, newOffsetY); - - if (!focusSuccessful) { - this.showErrorNotification(`Failed to set focus for app with id: ${newAppInstanceId}`, 'System Error'); - this.notify.show({ - message: `Failed to set focus for terminal with id: ${newAppInstanceId}`, - title: "Terminal Error", - type: "error", - media: this.notifyTemplate, - }); - return false; - } - return true; - } - - private isInstanceLimitReached(app: AppEntry): boolean { - const openInstanceCount = this.getOpenInstanceCount(app.id); - if (openInstanceCount < app.maxInstances) { - return false; - } - - if (app.maxInstances === 1) { - return true; - } - - // General case when maxInstances limit is reached - this.showErrorNotification(INSTANCE_LIMIT_ERROR_MESSAGE, INSTANCE_LIMIT_ERROR_TITLE); - return true; - } - - private showErrorNotification(message: string, title: string): void { - this.notify.show({ - message, - title, - type: "error", - media: this.notifyTemplate, - }); - } - - - saveOpenApplications() { - const openAppIds = this.applications.value.map((app) => app.id); - localStorage.setItem(OPEN_APPS_STORAGE_KEY, JSON.stringify(openAppIds)); + openApplication(id: string, args?: unknown): boolean { + const app = this.applicationRegistry.getInstalledAppById(id); + return this.applicationLifecycle.openApplication(id, app, args); } closeApplication(id: string): void { - const application = this.getAppByID(id); - if (!application) return; - // Mark the application as no longer running - application.running = false; - // Decrement the instanceIndex for the parent AppEntry - // Remove the application from the active applications list - const remainingApplications = this.applications.getValue().filter(app => app.id !== id); - this.applications.next(remainingApplications); - - if (application.parent) { - const remainingInstances = remainingApplications.filter((openApp) => openApp.parent?.id === application.parent?.id); - application.parent.instanceIndex = remainingInstances.length; - application.parent.running = remainingInstances.length > 0; - } - - // Save the state of opened applications - this.saveOpenApplications(); - } - - private getOpenInstanceCount(appId: string): number { - return this.applications.value.filter((openApp) => openApp.parent?.id === appId).length; - } - - private getMostRecentApplicationInstance(appId: string): ApplicationInstance | undefined { - const appInstances = this.applications.value.filter((openApp) => openApp.parent?.id === appId); - return appInstances[appInstances.length - 1]; + this.applicationLifecycle.closeApplication(id); } setApplicationFocus(id: string, offsetX?: number, offsetY?: number): boolean { - const application = this.applications.value.find(t => t.id === id); - if (id === 'desktop') { - this.focusedAppId.next(id); - return true; - } - if (!application) { - return false; - } - this.focusedAppId.next(id); - application.focused = true; - application.offsetX = offsetX ?? 40; - application.offsetY = offsetY ?? 40; - - // Move application to top of stack without recreating it - const index = this.applications.value.findIndex(t => t.id === id); - if (index !== -1) { - this.applications.next([ - ...this.applications.value.slice(0, index), - ...this.applications.value.slice(index + 1), - application - ]); - } - return true; + return this.applicationLifecycle.setApplicationFocus(id, offsetX, offsetY); } getAppByID(id: string): ApplicationInstance | undefined { - return this.applications.value.find(t => t.id === id); + return this.applicationLifecycle.getAppByID(id); } getFocus$(): Observable { - return this.focusedAppId.asObservable(); + return this.applicationLifecycle.getFocus$(); } - getFocusedAppId() { - return this.focusedAppId.getValue(); + getFocusedAppId(): string | null { + return this.applicationLifecycle.getFocusedAppId(); } - closeAllApps() { - this.applications.getValue().forEach(app => this.closeApplication(app.id)); + closeAllApps(): void { + this.applicationLifecycle.closeAllApps(); } - getCurrentApp() { - // Get current focused app - const focusedAppId = this.getFocusedAppId(); - return this.openApplications.find(app => app.id === focusedAppId); + getCurrentApp(): ApplicationInstance | undefined { + return this.applicationLifecycle.getCurrentApp(); } } diff --git a/src/app/components/game/services/application-registry.service.ts b/src/app/components/game/services/application-registry.service.ts new file mode 100644 index 0000000..50c0968 --- /dev/null +++ b/src/app/components/game/services/application-registry.service.ts @@ -0,0 +1,38 @@ +import {Injectable} from '@angular/core'; +import {getDefaultApplicationCatalog} from './application-catalog'; +import {AppEntry, AppType} from './application-manager.models'; + +@Injectable({providedIn: 'root'}) +export class ApplicationRegistryService { + private appRegistry: AppEntry[] = []; + + constructor() { + this.registerDefaultApps(); + } + + get registeredApps(): AppEntry[] { + return this.appRegistry; + } + + getApps(type: AppEntry['type'] = AppType.app): AppEntry[] { + return this.appRegistry.filter((app) => app.type === type); + } + + registerApp(app: AppEntry): void { + if (!this.appRegistry.some((registeredApp) => registeredApp.id === app.id)) { + this.appRegistry.push(app); + } + } + + unregisterApp(id: string): void { + this.appRegistry = this.appRegistry.filter((app) => app.id !== id); + } + + getInstalledAppById(id: string): AppEntry | undefined { + return this.appRegistry.find((app) => app.id === id && app.installed); + } + + private registerDefaultApps(): void { + getDefaultApplicationCatalog().forEach((app) => this.registerApp(app)); + } +} diff --git a/src/app/components/game/services/application-state-persistence.service.ts b/src/app/components/game/services/application-state-persistence.service.ts new file mode 100644 index 0000000..6c24305 --- /dev/null +++ b/src/app/components/game/services/application-state-persistence.service.ts @@ -0,0 +1,46 @@ +import {Injectable} from '@angular/core'; +import {LogService} from './log.service'; + +@Injectable({providedIn: 'root'}) +export class ApplicationStatePersistenceService { + constructor(private readonly logger: LogService) { + } + + loadOpenApplicationIds(storageKey: string): string[] { + const savedApps = localStorage.getItem(storageKey); + if (!savedApps) { + return []; + } + + try { + const parsed: unknown = JSON.parse(savedApps); + if (!Array.isArray(parsed)) { + return []; + } + + return parsed + .map((entry) => { + if (typeof entry === 'string') { + return entry; + } + if (entry && typeof entry === 'object' && 'id' in entry) { + const maybeId = (entry as { id?: unknown }).id; + return typeof maybeId === 'string' ? maybeId : null; + } + return null; + }) + .filter((id): id is string => Boolean(id)); + } catch (error) { + this.logger.warn('Failed to parse saved applications.', {error}); + return []; + } + } + + saveOpenApplicationIds(storageKey: string, appIds: string[]): void { + try { + localStorage.setItem(storageKey, JSON.stringify(appIds)); + } catch (error) { + this.logger.error('Failed to persist open applications.', {error, storageKey}); + } + } +} diff --git a/src/app/components/game/services/file-system.service.spec.ts b/src/app/components/game/services/file-system.service.spec.ts new file mode 100644 index 0000000..09ba43f --- /dev/null +++ b/src/app/components/game/services/file-system.service.spec.ts @@ -0,0 +1,75 @@ +import {HttpClient} from '@angular/common/http'; +import {of} from 'rxjs'; +import {FileEntry, FileSystemService} from './file-system.service'; + +function createBaseTree(): FileEntry { + return { + name: '/', + path: '/', + type: 'folder', + isDir: true, + created: '2026-01-01T00:00:00.000Z', + modified: '2026-01-01T00:00:00.000Z', + children: [ + { + name: 'Photos', + path: '/Photos', + type: 'folder', + isDir: true, + created: '2026-01-01T00:00:00.000Z', + modified: '2026-01-01T00:00:00.000Z', + children: [] + }, + { + name: 'resume.pdf', + path: '/resume.pdf', + type: 'document', + isDir: false, + created: '2026-01-01T00:00:00.000Z', + modified: '2026-01-01T00:00:00.000Z' + } + ] + }; +} + +function createService(): FileSystemService { + const httpMock = jasmine.createSpyObj>('HttpClient', ['get']); + httpMock.get.and.returnValue(of(createBaseTree())); + return new FileSystemService(httpMock as unknown as HttpClient); +} + +describe('FileSystemService', () => { + it('adds favorite directories without adding duplicate root entries', () => { + const service = createService(); + const root = service.getCurrentDirectory(); + const rootChildren = root.children ?? []; + + const rootPathCount = rootChildren.filter((entry) => entry.path === '/').length; + expect(rootPathCount).toBe(0); + + const desktopEntry = rootChildren.find((entry) => entry.path === '/Desktop'); + expect(desktopEntry).toBeDefined(); + expect(desktopEntry?.isDir).toBeTrue(); + }); + + it('generates deterministic favorite folder content across service instances', () => { + const firstService = createService(); + const secondService = createService(); + + const firstDesktop = firstService.getCurrentDirectory().children?.find((entry) => entry.path === '/Desktop'); + const secondDesktop = secondService.getCurrentDirectory().children?.find((entry) => entry.path === '/Desktop'); + + expect(firstDesktop?.children?.map((entry) => entry.name)).toEqual( + secondDesktop?.children?.map((entry) => entry.name) + ); + }); + + it('navigates to generated favorite folders successfully', () => { + const service = createService(); + + expect(service.navigateTo('/Desktop')).toBeTrue(); + const current = service.getCurrentDirectory(); + expect(current.path).toBe('/Desktop'); + expect((current.children ?? []).length).toBeGreaterThan(0); + }); +}); diff --git a/src/app/components/game/services/file-system.service.ts b/src/app/components/game/services/file-system.service.ts index ca944a4..9e15a30 100644 --- a/src/app/components/game/services/file-system.service.ts +++ b/src/app/components/game/services/file-system.service.ts @@ -86,6 +86,16 @@ export enum VIEW_MODES { list= 'list', columns = 'columns', grid ='grid' } +const GENERATED_FILE_EXTENSIONS: string[] = [ + FileExtensions.txt, + FileExtensions.htm, + FileExtensions.pdf, + FileExtensions.jpg, + FileExtensions.mp3, + FileExtensions.css, + FileExtensions.zip +]; + @Injectable({providedIn: 'root'}) export class FileSystemService { private root: FileEntry = { @@ -225,10 +235,11 @@ export class FileSystemService { this.assignTypesRecursively(tree); const favorites = (this.favoriteDirs ?? []) - .map(dir => this.createFolder(dir.name, dir.path)!) + .filter((dir) => dir.path !== '/') + .map(dir => this.createFolder(dir.name, dir.path, false)) .filter((folder): folder is FileEntry => folder !== undefined) .map(folder => { - folder.children = this.createNestedFolders(folder.name, folder.path, faker.number.int({min: 1, max: 5}), true).children; + folder.children = this.createFavoriteFolderChildren(folder.path); return folder; }); @@ -246,23 +257,53 @@ export class FileSystemService { return [...uniqueNewFolders, ...existing]; } + private createFavoriteFolderChildren(path: string): FileEntry[] { + const normalizedPath = path.replace(/\/+$/, '') || '/'; + const baseName = normalizedPath.split('/').filter(Boolean).pop()?.toLowerCase() ?? 'home'; + const hash = this.hashPath(normalizedPath); + const fileCount = (hash % 3) + 2; + const subfolderCount = (hash % 2) + 1; + + const files: FileEntry[] = []; + for (let i = 0; i < fileCount; i++) { + const extension = GENERATED_FILE_EXTENSIONS[(hash + i) % GENERATED_FILE_EXTENSIONS.length]; + const fileName = `${baseName}-${i + 1}.${extension}`; + files.push(this.createFile(fileName, `${normalizedPath}/${fileName}`)); + } + + const folders: FileEntry[] = []; + for (let i = 0; i < subfolderCount; i++) { + const folderName = `${baseName}-folder-${i + 1}`; + folders.push(this.createFolder(folderName, `${normalizedPath}/${folderName}`, false)); + } + + return [...folders, ...files]; + } + + private hashPath(value: string): number { + let hash = 0; + for (let i = 0; i < value.length; i++) { + hash = ((hash << 5) - hash) + value.charCodeAt(i); + hash |= 0; + } + return Math.abs(hash); + } + createMockFilesForFolder(folder: FileEntry, numFiles = 1) { const files: FileEntry[] = Array.isArray(folder.children) ? folder.children : []; for (let i = 0; i < numFiles; i++) { const file = this.createFile(faker.system.fileName(), folder.path + '/' + faker.system.fileName() + '.' + faker.system.commonFileType()); - if (file) { - files.push(file); - } + files.push(file); } return files; } - createFile(name: string, path: string): FileEntry | undefined { + createFile(name: string, path: string): FileEntry { return { name, - path: `${path}`, + path: `${path}`.replace(/\/+/g, '/'), created: Date().toString(), modified: Date().toString(), type: 'document', diff --git a/src/app/components/game/services/settings.service.ts b/src/app/components/game/services/settings.service.ts index bfb2e83..2e8cb4e 100644 --- a/src/app/components/game/services/settings.service.ts +++ b/src/app/components/game/services/settings.service.ts @@ -1,229 +1,160 @@ -import {Injectable} from '@angular/core'; -import {BehaviorSubject} from 'rxjs'; +import {Injectable, OnDestroy} from '@angular/core'; +import {BehaviorSubject, Subscription, take} from 'rxjs'; import {StorageService} from './storage.service'; import {NotificationService} from './notification.service'; import {FormControl, FormGroup} from '@angular/forms'; -import {Subscription} from 'rxjs'; - -export interface Setting { - id: string; // Unique identifier for the setting - value: any; // Value of the setting -} - -// Interface for the settings set, consisting of an array of settings -export interface SettingsSet { - [key: string]: Setting[]; // Each settings set is indexed by a unique `setId` +export interface Setting { + id: string; + value: T; } -/** - * Service for managing standalone settings and groups of settings (setting sets). - * Provides functionality to register, retrieve, update, add, and remove settings. - * - * USAGE EXAMPLES - * - * // Register a new setting set - * this.settingsService.registerSettingSet('userPreferences', ['optionA', 'optionB']); - * - * // Add a value to the set - * this.settingsService.addSettingToSet('userPreferences', 'optionC'); - * - * // Remove a value from the set - * this.settingsService.removeSettingFromSet('userPreferences', 'optionA'); - * - * // Get the setting set values as an observable - * this.settingsService.getSettingSet('userPreferences')?.subscribe((preferences) => { - * console.log(`User preferences updated: ${preferences}`); - * }); - * - * // Replace the entire set of values - * this.settingsService.updateSettingSet('userPreferences', ['optionX', 'optionY']); - * - * this.settingsService.registerSetting('theme', 'light'); - * this.settingsService.setSetting('theme', 'dark'); - * this.settingsService.getSetting('theme')?.subscribe((theme) => { - * console.log(`Current theme: ${theme}`); - * }); - */ -@Injectable({ providedIn: 'root' }) -export class SettingsService { - private settings = new Map>(); - private settingSets = new Map>(); - private settingValueSubjects = new Map>(); - - constructor(private storageService: StorageService, private notify: NotificationService) { - this.loadPersistedSettings(); +@Injectable({providedIn: 'root'}) +export class SettingsService implements OnDestroy { + private readonly settings = new Map>(); + private readonly settingSets = new Map>(); + private readonly settingValueSubjects = new Map>(); + private readonly settingValueSubscriptions = new Map(); + + constructor( + private readonly storageService: StorageService, + private readonly notify: NotificationService + ) { + this.loadPersistedSettingSets(); } - // Register a new standalone setting registerSetting(id: string, defaultValue: T): void { - if (!this.settings.has(id)) { - const subject = new BehaviorSubject(defaultValue); - - this.storageService.getItem(id).subscribe({ - next: (storedValue) => { - if (storedValue !== null) { - subject.next(storedValue); - } - }, - error: (error) => { - console.error(`Failed to load setting ${id}:`, error); - // Keep using defaultValue in case of error - } - }); - - this.settings.set(id, subject); + if (this.settings.has(id)) { + return; } - } - - private loadPersistedSettings(): void { - // Load setting sets - this.storageService.getAllKeys().subscribe(keys => { - keys.forEach(key => { - this.storageService.getItems(key).subscribe(values => { - if (values !== null) { - const subject = new BehaviorSubject(values); - this.settingSets.set(key, subject); - } - }); - }); - }); - } - + const subject = new BehaviorSubject(defaultValue); + this.settings.set(id, subject as BehaviorSubject); - private showNotify(message = '', title = 'Setting') { - this.notify.show({ title: title, message: message, type: 'error'}); + this.storageService.getItem(id).pipe(take(1)).subscribe({ + next: (storedValue) => { + if (storedValue !== null) { + subject.next(storedValue); + } + }, + error: (error) => { + console.error(`Failed to load setting ${id}:`, error); + } + }); } - // Get observable for a standalone setting getSetting(id: string): BehaviorSubject | null { - return this.settings.get(id) as BehaviorSubject | null; + return (this.settings.get(id) as BehaviorSubject) ?? null; } - // Set a value for a specific standalone setting setSetting(id: string, value: T): void { const setting = this.settings.get(id); - if (setting) { - setting.next(value); // Emit the new value - this.storageService.setItem(id, value); // Persist the updated value - } else { + if (!setting) { this.showNotify(`Setting with id "${id}" is not registered.`); + return; } + + setting.next(value); + this.persistSetting(id, value); } - // Register a new setting set (array of objects/values) registerSettingSet(id: string, defaultValues: T[]): void { - if (!this.settingSets.has(id)) { - const subject = new BehaviorSubject(defaultValues); - this.settingSets.set(id, subject); - - // Get stored values asynchronously - this.storageService.getItems(id).subscribe({ - next: (storedValues) => { - if (storedValues !== null) { - subject.next(storedValues); - } else { - // If no stored values, store the defaults - this.storageService.setItems(id, defaultValues); - } - }, - error: (error) => { - console.error(`Failed to load setting set ${id}:`, error); - // Keep using defaultValues in case of error - } - }); + if (this.settingSets.has(id)) { + return; } - } + const subject = new BehaviorSubject([...defaultValues]); + this.settingSets.set(id, subject); + + this.storageService.getItems(id).pipe(take(1)).subscribe({ + next: (storedValues) => { + if (storedValues !== null) { + subject.next(storedValues); + return; + } + + this.persistSettingSet(id, defaultValues); + }, + error: (error) => { + console.error(`Failed to load setting set ${id}:`, error); + } + }); + } - // Get observable for a setting set getSettingSet(id: string): BehaviorSubject | null { - return this.settingSets.get(id) as BehaviorSubject | null; + return (this.settingSets.get(id) as BehaviorSubject) ?? null; } - // Update the entire setting set (replace all values) updateSettingSet(id: string, values: T[]): void { const settingSet = this.settingSets.get(id); - if (settingSet) { - settingSet.next(values); // Emit the updated list of values - this.storageService.setItems(id, values); // Persist the updated list - } else { + if (!settingSet) { this.showNotify(`Setting set with id "${id}" is not registered.`); + return; } + + settingSet.next(values); + this.persistSettingSet(id, values); } -// Add or update a single value in a setting set - updateSettingSetWithSingleValue(setId: string, settingId: string, value: any): void { - // Retrieve setting set from the BehaviorSubject + updateSettingSetWithSingleValue(setId: string, settingId: string, value: T): void { const settingSet = this.settingSets.get(setId); + if (!settingSet) { + this.showNotify(`Setting set with id "${setId}" not registered.`); + return; + } - if (settingSet) { - // Get current settings set - const currentSet = settingSet.value; - - // Look for the specific setting within the set by `settingId` - const settingIndex = currentSet.findIndex((setting) => setting.id === settingId); - - if (settingIndex >= 0) { - // If found, update the value - currentSet[settingIndex].value = value; - } else { - // Otherwise, add a new setting to the set - currentSet.push({ id: settingId, value }); - } + const currentSet = settingSet.value; + if (!this.isSettingArray(currentSet)) { + this.showNotify(`Setting set with id "${setId}" does not support keyed updates.`); + return; + } - // Emit the updated settings set - settingSet.next([...currentSet]); + const settingIndex = currentSet.findIndex((setting) => setting.id === settingId); + const updatedSet = [...currentSet]; - // Persist the updated settings set in local storage - this.storageService.setItem(setId, currentSet); + if (settingIndex >= 0) { + updatedSet[settingIndex] = {...updatedSet[settingIndex], value}; } else { - this.showNotify(`Setting set with id "${setId}" not registered.`); + updatedSet.push({id: settingId, value}); } - } + settingSet.next(updatedSet); + this.persistSettingSet(setId, updatedSet); + } - // Add an item to a setting set addSettingToSet(id: string, value: T): void { const settingSet = this.settingSets.get(id); - if (settingSet) { - const currentValues = settingSet.value; - const updatedValues = [...currentValues, value]; - settingSet.next(updatedValues); - this.storageService.setItems(id, updatedValues); - } else { + if (!settingSet) { this.showNotify(`Setting set with id "${id}" is not registered.`); + return; } + + const updatedValues = [...settingSet.value, value]; + settingSet.next(updatedValues); + this.persistSettingSet(id, updatedValues); } - // Remove an item from a setting set removeSettingFromSet(id: string, value: T): void { const settingSet = this.settingSets.get(id); - if (settingSet) { - const currentValues = settingSet.value; - const updatedValues = currentValues.filter((item) => item !== value); - settingSet.next(updatedValues); - this.storageService.setItems(id, updatedValues); - } else { + if (!settingSet) { this.showNotify(`Setting set with id "${id}" is not registered.`); + return; } + + const updatedValues = settingSet.value.filter((item) => item !== value); + settingSet.next(updatedValues); + this.persistSettingSet(id, updatedValues); } findSettingValueInSet(setId: string, settingId: string): T | null { - // Retrieve the settings set BehaviorSubject - const settingSet = this.getSettingSet(setId); - - if (settingSet) { - // Locate the setting object by its `id` - const setting = settingSet.value.find((item) => item.id === settingId); - - // If the setting is found, return its value; otherwise, return null - return setting ? (setting.value as T) : null; + const settingSet = this.getSettingSet(setId); + if (!settingSet || !this.isSettingArray(settingSet.value)) { + console.warn(`Setting set with id "${setId}" not found or not a keyed setting set.`); + return null; } - console.warn(`Setting set with id "${setId}" not found.`); - return null; + const setting = settingSet.value.find((item) => item.id === settingId); + return setting ? (setting.value as T) : null; } getSettingValue$(setId: string, settingId?: string): BehaviorSubject { @@ -234,65 +165,134 @@ export class SettingsService { } if (!settingId) { - // Return observable for a single standalone setting - const subject = this.getSetting(setId); - const fallback = subject ? (subject as BehaviorSubject) : new BehaviorSubject(null); - this.settingValueSubjects.set(cacheKey, fallback as BehaviorSubject); + const standalone = this.getSetting(setId); + const fallback = standalone ?? new BehaviorSubject(null); + this.settingValueSubjects.set(cacheKey, fallback as BehaviorSubject); return fallback; } - // Create an on-the-fly observable to watch settingSet changes - const settingSet$ = this.getSettingSet(setId); const subject = new BehaviorSubject(null); + this.settingValueSubjects.set(cacheKey, subject as BehaviorSubject); - if (settingSet$) { - const initialValue = this.findSettingValueInSet(setId, settingId); - subject.next(initialValue); - settingSet$.subscribe((set) => { - const found = set.find((setting) => setting.id === settingId); - subject.next(found ? (found.value as T) : null); - }); + const settingSet$ = this.getSettingSet(setId); + if (!settingSet$) { + return subject; } - this.settingValueSubjects.set(cacheKey, subject as BehaviorSubject); + + const subscription = settingSet$.subscribe((settings) => { + if (!this.isSettingArray(settings)) { + subject.next(null); + return; + } + + const found = settings.find((setting) => setting.id === settingId); + subject.next(found ? (found.value as T) : null); + }); + + this.settingValueSubscriptions.set(cacheKey, subscription); return subject; } createFormGroupForSettings(setId: string): FormGroup | null { - const settingSet = this.getSettingSet(setId); + const settingSet = this.getSettingSet(setId); if (!settingSet) { this.showNotify(`No settings set found with ID: "${setId}".`); return null; } - return new FormGroup( - settingSet.value.reduce((group, setting) => { - group[setting.id] = new FormControl(setting.value); // Map each setting to a FormControl - return group; - }, {} as { [key: string]: FormControl }) - ); + if (!this.isSettingArray(settingSet.value)) { + this.showNotify(`Settings set "${setId}" is not a keyed setting list.`); + return null; + } + + const controls = settingSet.value.reduce>>((group, setting) => { + group[setting.id] = new FormControl(setting.value); + return group; + }, {}); + + return new FormGroup(controls); } syncFormGroupWithSettingSet(formGroup: FormGroup, setId: string): Subscription { return formGroup.valueChanges.subscribe((newValues) => { - const settingSet = this.getSettingSet(setId); - console.warn('settingSet', settingSet?.value); + const settingSet = this.getSettingSet(setId); if (!settingSet) { console.warn(`No settings set found with ID: "${setId}".`); return; } - // Update each setting in the set with the corresponding value from the form + if (!this.isSettingArray(settingSet.value)) { + console.warn(`Settings set "${setId}" is not a keyed setting list.`); + return; + } + + const valueMap = newValues as Record; const updatedSet = settingSet.value.map((setting) => ({ ...setting, - value: newValues[setting.id], // Sync updated value from the form + value: valueMap[setting.id], })); - // Emit the update and persist to storage settingSet.next(updatedSet); - this.storageService.setItem(setId, updatedSet); + this.persistSettingSet(setId, updatedSet); + }); + } + + ngOnDestroy(): void { + this.settingValueSubscriptions.forEach((subscription) => subscription.unsubscribe()); + this.settingValueSubscriptions.clear(); + + this.settingValueSubjects.forEach((subject) => subject.complete()); + this.settingValueSubjects.clear(); + } + + private loadPersistedSettingSets(): void { + this.storageService.getAllKeys().pipe(take(1)).subscribe({ + next: (keys) => { + keys.forEach((key) => { + if (this.settingSets.has(key)) { + return; + } + + this.storageService.getItems(key).pipe(take(1)).subscribe({ + next: (values) => { + if (Array.isArray(values)) { + this.settingSets.set(key, new BehaviorSubject(values)); + } + }, + error: (error) => { + console.error(`Failed to load setting set ${key}:`, error); + } + }); + }); + }, + error: (error) => { + console.error('Failed to load persisted settings:', error); + } }); } + private showNotify(message = '', title = 'Setting'): void { + this.notify.show({title, message, type: 'error'}); + } + + private persistSetting(id: string, value: T): void { + this.storageService.setItem(id, value).pipe(take(1)).subscribe(); + } + + private persistSettingSet(id: string, values: T[]): void { + this.storageService.setItems(id, values).pipe(take(1)).subscribe(); + } + private isSettingArray(value: unknown[]): value is Setting[] { + return value.every((item) => this.isSetting(item)); + } + private isSetting(value: unknown): value is Setting { + if (typeof value !== 'object' || value === null) { + return false; + } + + const candidate = value as { id?: unknown; value?: unknown }; + return typeof candidate.id === 'string' && 'value' in candidate; + } } diff --git a/src/app/components/game/services/typewriter.service.spec.ts b/src/app/components/game/services/typewriter.service.spec.ts new file mode 100644 index 0000000..c577f7d --- /dev/null +++ b/src/app/components/game/services/typewriter.service.spec.ts @@ -0,0 +1,97 @@ +import {fakeAsync, tick} from '@angular/core/testing'; +import {SoundService} from './sound.service'; +import {TypewriterService} from './typewriter.service'; +import {UserService} from './user.service'; + +describe('TypewriterService', () => { + let service: TypewriterService; + let soundServiceMock: jasmine.SpyObj>; + let userServiceMock: Pick; + + beforeEach(() => { + soundServiceMock = jasmine.createSpyObj>('SoundService', ['playVariant']); + userServiceMock = { + user: { + name: 'Colin', + mode: 'default', + score: 0, + level: 1, + sections: 0 + } + }; + + service = new TypewriterService( + soundServiceMock as unknown as SoundService, + userServiceMock as UserService + ); + }); + + afterEach(() => { + service.clear(); + }); + + it('types lines in queue order and emits completion events for each line', fakeAsync(() => { + const completedLines: string[] = []; + const completedAgents: Array<'user' | 'system'> = []; + + service.lineCompleted$.subscribe((event) => { + completedLines.push(event.text); + completedAgents.push(event.agent); + }); + + service.enqueueLine({text: 'first', agent: 'system', speed: 1, pauseAfter: 0}); + service.enqueueLine({text: 'second', agent: 'user', speed: 1, pauseAfter: 0}); + + tick(50); + + expect(service.typedText$.getValue()).toBe('first\nsecond\n'); + expect(completedLines).toEqual(['first', 'second']); + expect(completedAgents).toEqual(['system', 'user']); + })); + + it('uses per-line speed overrides when typing', fakeAsync(() => { + service.enqueueLine({text: 'ab', agent: 'system', speed: 200, pauseAfter: 0}); + + tick(199); + expect(service.typedText$.getValue()).toBe(''); + + tick(1); + expect(service.typedText$.getValue()).toBe('a'); + + tick(200); + expect(service.typedText$.getValue()).toBe('ab\n'); + })); + + it('cancels pending completion timeout when cleared', fakeAsync(() => { + const completed = jasmine.createSpy('completed'); + service.lineCompleted$.subscribe(completed); + + service.enqueueLine({text: 'x', agent: 'system', speed: 1, pauseAfter: 500}); + tick(10); // line typed and completion timeout scheduled + + service.clear(); + tick(600); + + expect(service.typedText$.getValue()).toBe(''); + expect(completed).not.toHaveBeenCalled(); + })); + + it('invokes line lifecycle callbacks once per line', fakeAsync(() => { + const onBegin = jasmine.createSpy('onBegin'); + const onComplete = jasmine.createSpy('onComplete'); + + service.enqueueLine({ + text: 'ok', + agent: 'system', + speed: 1, + pauseAfter: 0, + onBegin, + onComplete + }); + + tick(20); + + expect(onBegin).toHaveBeenCalledTimes(1); + expect(onComplete).toHaveBeenCalledTimes(1); + })); +}); diff --git a/src/app/components/game/services/typewriter.service.ts b/src/app/components/game/services/typewriter.service.ts index f255004..3c3155b 100644 --- a/src/app/components/game/services/typewriter.service.ts +++ b/src/app/components/game/services/typewriter.service.ts @@ -1,11 +1,11 @@ -import { Injectable } from '@angular/core'; +import {Injectable} from '@angular/core'; import {BehaviorSubject, Subject} from 'rxjs'; import {SoundService} from './sound.service'; import {UserService} from './user.service'; export type TypingMode = 'default' | 'glitch' | 'system' | 'dramatic'; -interface TypewriterLine { +export interface TypewriterLine { text: string; speed?: number; mode?: TypingMode; @@ -17,18 +17,19 @@ interface TypewriterLine { onComplete?: () => void; } -interface CompletedLineEvent { +export interface CompletedLineEvent { text: string; agent: 'user' | 'system'; } -@Injectable({ providedIn: 'root' }) +@Injectable({providedIn: 'root'}) export class TypewriterService { public typedText$ = new BehaviorSubject(''); public lineCompleted$ = new Subject(); private queue: TypewriterLine[] = []; private currentIndex = 0; private typingInterval: ReturnType | null = null; + private lineCompletionTimeout: ReturnType | null = null; private lineBuffer = ''; public activeMode$ = new BehaviorSubject('default'); @@ -37,9 +38,6 @@ export class TypewriterService { public volume = new BehaviorSubject(0.2); constructor(private soundService: SoundService, private userService: UserService) { - if(this.queue.length > 0) { - this.processNextLine(); - } } // Public method to enable/disable sounds @@ -53,14 +51,19 @@ export class TypewriterService { } - enqueueLine(line: TypewriterLine) { + enqueueLine(line: TypewriterLine): void { this.queue.push({ ...line, mode: line.mode ?? 'default' }); if (this.queue.length === 1) this.processNextLine(); } - private processNextLine() { + private processNextLine(): void { const line = this.queue[0]; - if (!line) return; + if (!line) { + this.activeMode$.next('default'); + return; + } + + this.clearTypingTimers(); this.currentIndex = 0; this.lineBuffer = ''; @@ -80,10 +83,11 @@ export class TypewriterService { line.onBegin?.(); const config = this.getTypingConfig(mode); - this.typingInterval = setInterval(() => this.typeNextChar(line), config.speed); + const resolvedSpeed = this.resolveTypingSpeed(line.speed, config.speed); + this.typingInterval = setInterval(() => this.typeNextChar(line), resolvedSpeed); } - private typeNextChar(line: TypewriterLine) { + private typeNextChar(line: TypewriterLine): void { const mode = line.mode ?? 'default'; const config = this.getTypingConfig(mode); @@ -104,19 +108,17 @@ export class TypewriterService { line.onCharTyped?.(char, this.currentIndex, mode); } else { - if (this.typingInterval !== null) { - clearInterval(this.typingInterval); - } - this.typingInterval = null; + this.clearTypingInterval(); line.onComplete?.(); - setTimeout(() => { + this.lineCompletionTimeout = setTimeout(() => { + this.lineCompletionTimeout = null; const finalLine = this.typedText$.getValue() + '\n'; this.typedText$.next(finalLine); this.lineCompleted$.next({ - text: finalLine.trim(), + text: this.lineBuffer, agent: line.agent || 'system' }); // ✨ emit completed line this.queue.shift(); @@ -125,16 +127,35 @@ export class TypewriterService { } } - clear() { - if (this.typingInterval !== null) { - clearInterval(this.typingInterval); - } - this.typingInterval = null; + clear(): void { + this.clearTypingTimers(); this.queue = []; this.currentIndex = 0; + this.lineBuffer = ''; + this.activeMode$.next('default'); this.typedText$.next(''); } + private clearTypingTimers(): void { + this.clearTypingInterval(); + if (this.lineCompletionTimeout !== null) { + clearTimeout(this.lineCompletionTimeout); + this.lineCompletionTimeout = null; + } + } + + private clearTypingInterval(): void { + if (this.typingInterval !== null) { + clearInterval(this.typingInterval); + this.typingInterval = null; + } + } + + private resolveTypingSpeed(lineSpeed: number | undefined, defaultSpeed: number): number { + const speed = lineSpeed ?? defaultSpeed; + return Number.isFinite(speed) && speed > 0 ? speed : defaultSpeed; + } + private getTypingConfig(mode: TypingMode): { speed: number; charSound: (char: string) => string | null } { switch (mode) { case 'glitch': diff --git a/src/app/components/game/system/login-screen/login-screen.component.spec.ts b/src/app/components/game/system/login-screen/login-screen.component.spec.ts index 9ac8278..a8e6cf1 100644 --- a/src/app/components/game/system/login-screen/login-screen.component.spec.ts +++ b/src/app/components/game/system/login-screen/login-screen.component.spec.ts @@ -1,5 +1,5 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import {ActivatedRoute} from '@angular/router'; +import {ActivatedRoute, convertToParamMap} from '@angular/router'; import {of} from 'rxjs'; import {RouterTestingModule} from '@angular/router/testing'; import {AuthService} from '../../../../services/auth.service'; @@ -36,7 +36,13 @@ describe('LoginScreenComponent', () => { {provide: SoundService, useValue: soundServiceMock}, {provide: MusicService, useValue: musicServiceMock}, {provide: LogService, useValue: loggerMock}, - {provide: ActivatedRoute, useValue: {queryParams: of({})}} + { + provide: ActivatedRoute, + useValue: { + queryParams: of({}), + snapshot: {queryParamMap: convertToParamMap({})} + } + } ] }) .compileComponents(); diff --git a/src/app/components/game/system/login-screen/login-screen.component.ts b/src/app/components/game/system/login-screen/login-screen.component.ts index fccb61c..13f9e82 100644 --- a/src/app/components/game/system/login-screen/login-screen.component.ts +++ b/src/app/components/game/system/login-screen/login-screen.component.ts @@ -24,7 +24,7 @@ import {MusicService} from '../../services/music.service'; import {Subject, takeUntil} from 'rxjs'; import {PATH_NAMES} from '../../../../app.routes'; import {LogService} from '../../services/log.service'; -import {updateProfile, User} from '@angular/fire/auth'; +import {updateProfile, User, UserCredential} from '@angular/fire/auth'; import {AuthService} from '../../../../services/auth.service'; import {faGoogle} from '@fortawesome/free-brands-svg-icons'; @@ -55,6 +55,7 @@ import {faGoogle} from '@fortawesome/free-brands-svg-icons'; }) export class LoginScreenComponent implements OnInit, OnDestroy { private redirectUrl: string | null = null; + private readonly isLocalHost = this.detectLocalHost(); loginForm: FormGroup; registerForm: FormGroup; isLoginMode = true; // Toggle between login and register views @@ -102,6 +103,13 @@ export class LoginScreenComponent implements OnInit, OnDestroy { } ngOnInit() { + this.redirectUrl = this.route.snapshot.queryParamMap.get('redirectUrl'); + + if (this.isLocalHost) { + this.navigateToDestination(); + return; + } + this.route.queryParams .pipe(takeUntil(this.destroy$)) .subscribe(params => { @@ -111,7 +119,7 @@ export class LoginScreenComponent implements OnInit, OnDestroy { // Check for redirect results this.authService.handleRedirectResult() .pipe(takeUntil(this.destroy$)) - .subscribe((result: any) => { + .subscribe((result: UserCredential | null) => { if (result) { this.userService.updateUser({ name: result.user.displayName ?? result.user.email?.split('@')[0] ?? 'User' @@ -148,12 +156,20 @@ export class LoginScreenComponent implements OnInit, OnDestroy { } private navigateToDestination() { - const destination = this.redirectUrl ?? `/${PATH_NAMES.OS_MAIN}`; + const destination = this.redirectUrl ?? (this.isLocalHost ? `/${PATH_NAMES.OS_MAIN}/cli` : `/${PATH_NAMES.OS_MAIN}`); this.router.navigateByUrl(destination) .then(success => this.logger.info('Navigation success:', success)) .catch(error => this.logger.error('Navigation failed:', error)); } + private detectLocalHost(): boolean { + if (typeof window === 'undefined') { + return false; + } + const hostname = window.location.hostname; + return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1'; + } + private getErrorMessage(errorCode: string): string { switch (errorCode) { case 'auth/user-not-found': @@ -216,7 +232,7 @@ export class LoginScreenComponent implements OnInit, OnDestroy { this.authService.registerWithEmail(email, password) .pipe(takeUntil(this.destroy$)) .subscribe({ - next: (result: any) => { + next: (result: UserCredential) => { this.logger.info('User registered:', result.user.email); // Use the modern Firebase approach for updating the profile @@ -249,7 +265,7 @@ export class LoginScreenComponent implements OnInit, OnDestroy { this.authService.loginWithGoogle() .pipe(takeUntil(this.destroy$)) .subscribe({ - next: (result: any) => { + next: (result: UserCredential | null) => { this.logger.info('User logged in with Google:', result?.user?.email); // Update user service with Google user info this.userService.updateUser({ diff --git a/src/app/components/game/templates/app-window/app-window.component.ts b/src/app/components/game/templates/app-window/app-window.component.ts index ee52475..b7121d5 100644 --- a/src/app/components/game/templates/app-window/app-window.component.ts +++ b/src/app/components/game/templates/app-window/app-window.component.ts @@ -11,10 +11,14 @@ import { import {CommonModule} from '@angular/common'; import {CliGameComponent} from '../../apps/cli-game/cli-game.component'; import { - ApplicationManagerService, WINDOW_HEIGHT_MAX, WINDOW_HEIGHT_MIN, + ApplicationManagerService +} from '../../services/application-manager.service'; +import { + WINDOW_HEIGHT_MAX, + WINDOW_HEIGHT_MIN, WINDOW_WIDTH_MAX, WINDOW_WIDTH_MIN -} from '../../services/application-manager.service'; +} from '../../services/application-manager.models'; import {FontAwesomeModule} from '@fortawesome/angular-fontawesome'; import {faCircle, faMinus, faTimes, faUpRightAndDownLeftFromCenter} from '@fortawesome/free-solid-svg-icons'; import {takeUntilDestroyed} from '@angular/core/rxjs-interop'; diff --git a/src/app/guards/auth.guard.ts b/src/app/guards/auth.guard.ts index c37ff46..db217e7 100644 --- a/src/app/guards/auth.guard.ts +++ b/src/app/guards/auth.guard.ts @@ -1,7 +1,8 @@ // src/app/guards/auth.guard.ts -import { Injectable } from '@angular/core'; +import {Inject, Injectable, PLATFORM_ID} from '@angular/core'; +import {isPlatformBrowser} from '@angular/common'; import {CanActivate, Router, ActivatedRouteSnapshot, RouterStateSnapshot} from '@angular/router'; -import {Observable, map, take, tap} from 'rxjs'; +import {Observable, map, of, take, tap} from 'rxjs'; import {AuthService} from '../services/auth.service'; import {PATH_NAMES} from '../app.routes'; @@ -11,7 +12,8 @@ import {PATH_NAMES} from '../app.routes'; export class AuthGuard implements CanActivate { constructor( private readonly authService: AuthService, - private readonly router: Router + private readonly router: Router, + @Inject(PLATFORM_ID) private readonly platformId: object ) { } @@ -19,9 +21,13 @@ export class AuthGuard implements CanActivate { route: ActivatedRouteSnapshot, state: RouterStateSnapshot ): Observable { + if (this.isLocalDevelopmentHost()) { + return of(true); + } + return this.authService.user$.pipe( take(1), - map(user => !!user), // Map to boolean + map(user => !!user), tap(isLoggedIn => { if (!isLoggedIn) { console.log('Access denied - Not logged in'); @@ -32,4 +38,13 @@ export class AuthGuard implements CanActivate { }) ); } + + private isLocalDevelopmentHost(): boolean { + if (!isPlatformBrowser(this.platformId)) { + return false; + } + + const hostname = window.location.hostname; + return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1'; + } }