import { defineStore } from 'pinia' import { DEFAULT_INSTALLED_PHONE_APP_IDS, getPhoneApp, isExternalPhoneApp, isPhoneAppId, isPhoneAppRemovable, isValidExternalPhoneAppId, NON_REMOVABLE_PHONE_APP_IDS, PHONE_APPS, } from '@/config/apps' import { usePhoneStore } from '@/stores/phone' import type { LaunchablePhoneAppId } from '@/types/apps' import { addHomeAppToFolder, addHomePage, createHomeFolder, createDefaultHomeLayout, deleteHomePage, extractHomeFolderApp, moveHomeFolderApp, moveHomeApp, moveHomeAppToGridPage, parseHomeLayout, renameHomeFolder, removeHomeApp, restoreHomeApp, type HomeArea, } from '@/utils/homeLayout' import { nuiCall } from '@/utils/nui' const INSTALL_DURATION_MS = 3000 const UPDATE_DURATION_MS = 1800 type PendingInstallation = { deviceImei: string timer: ReturnType token: symbol } type PendingUpdate = { deviceImei: string timer: ReturnType } const pendingInstallations = new WeakMap< object, Map >() const pendingUpdates = new WeakMap< object, Map >() function getDefaultGridIds(): LaunchablePhoneAppId[] { return [...PHONE_APPS] .sort((a, b) => a.gridOrder - b.gridOrder) .map((app) => app.id) } function getDefaultDockIds(): LaunchablePhoneAppId[] { return PHONE_APPS.filter((app) => app.dockOrder !== null) .sort((a, b) => (a.dockOrder ?? 0) - (b.dockOrder ?? 0)) .map((app) => app.id) } function getDefaultInstalledIds(): LaunchablePhoneAppId[] { return PHONE_APPS.filter((app) => isExternalPhoneApp(app) ? app.defaultInstalled : DEFAULT_INSTALLED_PHONE_APP_IDS.has(app.id), ).map((app) => app.id) } function isProtectedHomeApp(appId: LaunchablePhoneAppId): boolean { const app = getPhoneApp(appId) return app ? !isPhoneAppRemovable(app) : NON_REMOVABLE_PHONE_APP_IDS.has(appId) } function hasUninstalledBuiltinApp( layout: unknown, installedIds: readonly LaunchablePhoneAppId[], ): boolean { if (!layout || typeof layout !== 'object') return false const installed = new Set(installedIds) const source = layout as { dock?: unknown grid?: unknown hidden?: unknown } const items = [source.dock, source.grid, source.hidden] .filter(Array.isArray) .flat(2) return items.some((item) => { const ids = item && typeof item === 'object' && Array.isArray(item.apps) ? item.apps : [item] return ids.some((id: unknown) => { if (typeof id !== 'string') return false const app = getPhoneApp(id) return !!app && !installed.has(app.id) && !isExternalPhoneApp(app) }) }) } export const useAppStoreStore = defineStore('app-store', { state: () => ({ claimedApps: [] as LaunchablePhoneAppId[], uninstalledApps: [] as LaunchablePhoneAppId[], updatedAppReleases: {} as Partial>, homeLayout: createDefaultHomeLayout( getDefaultInstalledIds(), getDefaultGridIds(), getDefaultDockIds(), ), hydrated: false, installingApps: {} as Partial>, updatingApps: {} as Partial>, launchCounts: {} as Partial>, }), actions: { addHomePage(): boolean { const next = addHomePage(this.homeLayout) if (next === this.homeLayout) return false this.homeLayout = next this.persist() return true }, deleteHomePage(page: number): boolean { const next = deleteHomePage(this.homeLayout, page) if (next === this.homeLayout) return false this.homeLayout = next this.persist() return true }, claimApp(id: LaunchablePhoneAppId): void { this.uninstalledApps = this.uninstalledApps.filter( (appId) => appId !== id, ) if (!this.claimedApps.includes(id)) { this.claimedApps.push(id) this.homeLayout = restoreHomeApp(this.homeLayout, id) this.persist() } }, cancelPendingInstalls(): void { const installations = pendingInstallations.get(this) if (installations) { for (const installation of installations.values()) { globalThis.clearTimeout(installation.timer) } pendingInstallations.delete(this) } this.installingApps = {} const updates = pendingUpdates.get(this) if (updates) { for (const update of updates.values()) { globalThis.clearTimeout(update.timer) } pendingUpdates.delete(this) } this.updatingApps = {} }, installApp(id: LaunchablePhoneAppId): void { const installed = this.isInstalled(id) if ( this.installingApps[id] || (installed && !this.homeLayout.hidden.includes(id)) ) { return } const phone = usePhoneStore() const deviceImei = phone.device?.imei if (!phone.isOpen || !deviceImei) { console.error( `[App store] Installation cancelled because no phone device is open for ${id}.`, ) return } const app = getPhoneApp(id) const reportInstall = !installed && isExternalPhoneApp(app) const token = Symbol(id) const installations = pendingInstallations.get(this) ?? new Map() this.installingApps[id] = true const timer = globalThis.setTimeout(() => { const pending = installations.get(id) if (!pending || pending.token !== token) return installations.delete(id) if (!installations.size) pendingInstallations.delete(this) const activePhone = usePhoneStore() if ( !activePhone.isOpen || activePhone.device?.imei !== pending.deviceImei ) { delete this.installingApps[id] console.error( `[App store] Installation cancelled because the active phone changed for ${id}.`, ) return } if (reportInstall && !isExternalPhoneApp(getPhoneApp(id))) { delete this.installingApps[id] console.error( `[Custom apps] Installation cancelled because ${id} is no longer registered.`, ) return } if (this.isInstalled(id)) { this.restoreHomeApp(id) } else { this.claimApp(id) } delete this.installingApps[id] if (reportInstall) { void nuiCall('custom-app:lifecycle', { appId: id, event: 'install', }).then((response) => { if (!response.success) { console.error( `[Custom apps] Install lifecycle failed for ${id}: ${response.error ?? 'request_failed'}`, ) } }) } }, INSTALL_DURATION_MS) installations.set(id, { deviceImei, timer, token }) pendingInstallations.set(this, installations) }, hydrate(payload: unknown): void { this.cancelPendingInstalls() const data = payload as { claimedApps?: unknown homeLayout?: unknown launchCounts?: unknown uninstalledApps?: unknown updatedAppReleases?: unknown } | null const layoutVersion = data?.homeLayout && typeof data.homeLayout === 'object' ? (data.homeLayout as { version?: unknown }).version : undefined const supportsPersistedExternalApps = layoutVersion === 3 || layoutVersion === 4 || layoutVersion === 5 this.claimedApps = Array.isArray(data?.claimedApps) ? data.claimedApps.filter( (id): id is LaunchablePhoneAppId => typeof id === 'string' && (isPhoneAppId(id) || (supportsPersistedExternalApps && isValidExternalPhoneAppId(id))), ) : [] this.uninstalledApps = Array.isArray(data?.uninstalledApps) ? data.uninstalledApps.filter((id): id is LaunchablePhoneAppId => { if (typeof id !== 'string' || !isPhoneAppId(id)) return false const app = getPhoneApp(id) return !!app && isPhoneAppRemovable(app) }) : [] this.updatedAppReleases = {} if ( data?.updatedAppReleases && typeof data.updatedAppReleases === 'object' ) { for (const [id, release] of Object.entries(data.updatedAppReleases)) { if ( isPhoneAppId(id) && typeof release === 'string' && release.length <= 32 ) { this.updatedAppReleases[id] = release } } } const installedIds = [ ...new Set([...getDefaultInstalledIds(), ...this.claimedApps]), ].filter((id) => !this.uninstalledApps.includes(id)) for (const appId of Object.keys(this.updatedAppReleases)) { if (!isPhoneAppId(appId) || !installedIds.includes(appId)) { delete this.updatedAppReleases[appId as LaunchablePhoneAppId] } } const removedLegacyDefaults = hasUninstalledBuiltinApp( data?.homeLayout, installedIds, ) const defaults = createDefaultHomeLayout( installedIds, getDefaultGridIds(), getDefaultDockIds(), ) this.homeLayout = parseHomeLayout( data?.homeLayout, defaults, installedIds, false, ) const protectedHiddenAppIds = this.homeLayout.hidden.filter(isProtectedHomeApp) for (const appId of protectedHiddenAppIds) { this.homeLayout = restoreHomeApp(this.homeLayout, appId) } this.launchCounts = {} if (data?.launchCounts && typeof data.launchCounts === 'object') { for (const [appId, count] of Object.entries(data.launchCounts)) { if ( (isPhoneAppId(appId) || (supportsPersistedExternalApps && isValidExternalPhoneAppId(appId))) && typeof count === 'number' && Number.isFinite(count) && count > 0 ) { this.launchCounts[appId] = Math.floor(count) } } } this.hydrated = true if ( protectedHiddenAppIds.length || removedLegacyDefaults || layoutVersion === 2 || layoutVersion === 3 || layoutVersion === 4 ) { this.persist() } }, isInstalled(appId: LaunchablePhoneAppId): boolean { if (this.uninstalledApps.includes(appId)) return false if (this.claimedApps.includes(appId)) return true const app = getPhoneApp(appId) if (!app) return false return isExternalPhoneApp(app) ? app.defaultInstalled : DEFAULT_INSTALLED_PHONE_APP_IDS.has(app.id) }, reconcileCatalog(): void { const installedIds = [ ...new Set([...getDefaultInstalledIds(), ...this.claimedApps]), ].filter((id) => !this.uninstalledApps.includes(id)) const defaults = createDefaultHomeLayout( installedIds, getDefaultGridIds(), getDefaultDockIds(), ) const previous = JSON.stringify(this.homeLayout) this.homeLayout = parseHomeLayout( this.homeLayout, defaults, installedIds, false, ) for (const appId of [...this.homeLayout.hidden]) { if (isProtectedHomeApp(appId)) { this.homeLayout = restoreHomeApp(this.homeLayout, appId) } } if (this.hydrated && previous !== JSON.stringify(this.homeLayout)) { this.persist() } }, recordLaunch(appId: LaunchablePhoneAppId): void { this.launchCounts[appId] = (this.launchCounts[appId] ?? 0) + 1 this.persist() }, moveHomeApp( from: HomeArea, sourceIndex: number, to: HomeArea, targetIndex: number, ): boolean { const next = moveHomeApp( this.homeLayout, from, sourceIndex, to, targetIndex, ) if (next === this.homeLayout) return false this.homeLayout = next this.persist() return true }, moveHomeAppToGridPage( from: HomeArea, sourceIndex: number, targetPage: number, targetOffset: number, capacities: readonly number[], ): boolean { const next = moveHomeAppToGridPage( this.homeLayout, from, sourceIndex, targetPage, targetOffset, capacities, ) if (next === this.homeLayout) return false this.homeLayout = next this.persist() return true }, createHomeFolder( from: HomeArea, sourceIndex: number, to: HomeArea, targetIndex: number, name: string, ): string | null { const folderId = `folder-${globalThis.crypto.randomUUID()}` const next = createHomeFolder( this.homeLayout, from, sourceIndex, to, targetIndex, folderId, name, ) if (next === this.homeLayout) return null this.homeLayout = next this.persist() return folderId }, addHomeAppToFolder( from: HomeArea, sourceIndex: number, folderId: string, ): boolean { const next = addHomeAppToFolder( this.homeLayout, from, sourceIndex, folderId, ) if (next === this.homeLayout) return false this.homeLayout = next this.persist() return true }, renameHomeFolder(folderId: string, name: string): void { const next = renameHomeFolder(this.homeLayout, folderId, name) if (next === this.homeLayout) return this.homeLayout = next this.persist() }, moveHomeFolderApp( folderId: string, sourceIndex: number, targetIndex: number, ): void { const next = moveHomeFolderApp( this.homeLayout, folderId, sourceIndex, targetIndex, ) if (next === this.homeLayout) return this.homeLayout = next this.persist() }, extractHomeFolderApp( folderId: string, sourceIndex: number, to: HomeArea, targetIndex: number, ): boolean { const next = extractHomeFolderApp( this.homeLayout, folderId, sourceIndex, to, targetIndex, ) if (next === this.homeLayout) return false this.homeLayout = next this.persist() return true }, removeHomeApp(appId: LaunchablePhoneAppId): void { if (isProtectedHomeApp(appId)) return this.homeLayout = removeHomeApp(this.homeLayout, appId) this.persist() }, restoreHomeApp(appId: LaunchablePhoneAppId): void { this.homeLayout = restoreHomeApp(this.homeLayout, appId) this.persist() }, uninstallApp(appId: LaunchablePhoneAppId): boolean { const app = getPhoneApp(appId) if (!app || !this.isInstalled(appId) || !isPhoneAppRemovable(app)) { return false } this.claimedApps = this.claimedApps.filter((id) => id !== appId) if (!this.uninstalledApps.includes(appId)) { this.uninstalledApps.push(appId) } delete this.updatedAppReleases[appId] this.homeLayout = removeHomeApp(this.homeLayout, appId) this.persist() return true }, updateApp(appId: LaunchablePhoneAppId, release: string): void { if ( !this.isInstalled(appId) || this.updatingApps[appId] || !release.trim() ) { return } const phone = usePhoneStore() const deviceImei = phone.device?.imei if (!phone.isOpen || !deviceImei) { console.error( `[App store] Update cancelled because no phone device is open for ${appId}.`, ) return } const updates = pendingUpdates.get(this) ?? new Map() this.updatingApps[appId] = true const timer = globalThis.setTimeout(() => { updates.delete(appId) if (!updates.size) pendingUpdates.delete(this) delete this.updatingApps[appId] const activePhone = usePhoneStore() if (!activePhone.isOpen || activePhone.device?.imei !== deviceImei) { console.error( `[App store] Update cancelled because the active phone changed for ${appId}.`, ) return } if (!this.isInstalled(appId)) return this.updatedAppReleases[appId] = release this.persist() }, UPDATE_DURATION_MS) updates.set(appId, { deviceImei, timer }) pendingUpdates.set(this, updates) }, persist(): void { usePhoneStore().saveDeviceNamespace('apps', { claimedApps: this.claimedApps, homeLayout: this.homeLayout, launchCounts: this.launchCounts, ...(this.uninstalledApps.length ? { uninstalledApps: this.uninstalledApps } : {}), ...(Object.keys(this.updatedAppReleases).length ? { updatedAppReleases: this.updatedAppReleases } : {}), }) }, }, })