Files
sky_phone/frontend/src/stores/app-store.ts
T
2026-08-14 12:53:39 +02:00

576 lines
17 KiB
TypeScript

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<typeof globalThis.setTimeout>
token: symbol
}
type PendingUpdate = {
deviceImei: string
timer: ReturnType<typeof globalThis.setTimeout>
}
const pendingInstallations = new WeakMap<
object,
Map<LaunchablePhoneAppId, PendingInstallation>
>()
const pendingUpdates = new WeakMap<
object,
Map<LaunchablePhoneAppId, PendingUpdate>
>()
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<Record<LaunchablePhoneAppId, string>>,
homeLayout: createDefaultHomeLayout(
getDefaultInstalledIds(),
getDefaultGridIds(),
getDefaultDockIds(),
),
hydrated: false,
installingApps: {} as Partial<Record<LaunchablePhoneAppId, boolean>>,
updatingApps: {} as Partial<Record<LaunchablePhoneAppId, boolean>>,
launchCounts: {} as Partial<Record<LaunchablePhoneAppId, number>>,
}),
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<LaunchablePhoneAppId, PendingInstallation>()
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<LaunchablePhoneAppId, PendingUpdate>()
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 }
: {}),
})
},
},
})