mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-04 09:13:24 +00:00
ENH - refine App Store experience
This commit is contained in:
@@ -3,7 +3,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
DEFAULT_INSTALLED_PHONE_APP_IDS,
|
||||
NON_REMOVABLE_PHONE_APP_IDS,
|
||||
} from '@/config/apps'
|
||||
import { useAppStoreStore } from '@/stores/app-store'
|
||||
import {
|
||||
@@ -184,26 +183,26 @@ describe('app store', () => {
|
||||
it('fully uninstalls removable apps and allows downloading them again', () => {
|
||||
vi.useFakeTimers()
|
||||
const apps = useAppStoreStore()
|
||||
apps.hydrate(null)
|
||||
apps.hydrate({ claimedApps: ['snake'] })
|
||||
mocks.phone.saveDeviceNamespace.mockClear()
|
||||
|
||||
expect(apps.uninstallApp('calculator')).toBe(true)
|
||||
expect(apps.isInstalled('calculator')).toBe(false)
|
||||
expect(apps.uninstalledApps).toEqual(['calculator'])
|
||||
expect(apps.homeLayout.hidden).toContain('calculator')
|
||||
expect(apps.uninstallApp('snake')).toBe(true)
|
||||
expect(apps.isInstalled('snake')).toBe(false)
|
||||
expect(apps.uninstalledApps).toEqual(['snake'])
|
||||
expect(apps.homeLayout.hidden).toContain('snake')
|
||||
expect(mocks.phone.saveDeviceNamespace).toHaveBeenLastCalledWith('apps', {
|
||||
claimedApps: [],
|
||||
homeLayout: apps.homeLayout,
|
||||
launchCounts: {},
|
||||
uninstalledApps: ['calculator'],
|
||||
uninstalledApps: ['snake'],
|
||||
})
|
||||
|
||||
apps.installApp('calculator')
|
||||
apps.installApp('snake')
|
||||
vi.advanceTimersByTime(3000)
|
||||
|
||||
expect(apps.isInstalled('calculator')).toBe(true)
|
||||
expect(apps.isInstalled('snake')).toBe(true)
|
||||
expect(apps.uninstalledApps).toEqual([])
|
||||
expect(apps.homeLayout.hidden).not.toContain('calculator')
|
||||
expect(apps.homeLayout.hidden).not.toContain('snake')
|
||||
})
|
||||
|
||||
it('hydrates persisted removals while rejecting protected and invalid ids', () => {
|
||||
@@ -211,49 +210,25 @@ describe('app store', () => {
|
||||
|
||||
apps.hydrate({
|
||||
uninstalledApps: ['calculator', 'phone', 'not-an-app'],
|
||||
updatedAppReleases: {
|
||||
calculator: '2026-08-14',
|
||||
phone: '2026-08-14',
|
||||
snake: 'x'.repeat(64),
|
||||
},
|
||||
})
|
||||
|
||||
expect(apps.uninstalledApps).toEqual(['calculator'])
|
||||
expect(apps.isInstalled('calculator')).toBe(false)
|
||||
expect(apps.uninstalledApps).toEqual([])
|
||||
expect(apps.isInstalled('calculator')).toBe(true)
|
||||
expect(apps.isInstalled('phone')).toBe(true)
|
||||
expect(apps.updatedAppReleases).toEqual({ phone: '2026-08-14' })
|
||||
})
|
||||
|
||||
it('protects system apps from full uninstallation', () => {
|
||||
it('protects every default app from full uninstallation', () => {
|
||||
const apps = useAppStoreStore()
|
||||
apps.hydrate(null)
|
||||
mocks.phone.saveDeviceNamespace.mockClear()
|
||||
|
||||
expect(apps.uninstallApp('phone')).toBe(false)
|
||||
expect(apps.isInstalled('phone')).toBe(true)
|
||||
for (const appId of DEFAULT_INSTALLED_PHONE_APP_IDS) {
|
||||
expect(apps.uninstallApp(appId)).toBe(false)
|
||||
expect(apps.isInstalled(appId)).toBe(true)
|
||||
}
|
||||
expect(mocks.phone.saveDeviceNamespace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('updates installed apps and persists the current release', () => {
|
||||
vi.useFakeTimers()
|
||||
const apps = useAppStoreStore()
|
||||
apps.hydrate(null)
|
||||
|
||||
apps.updateApp('phone', '2026-08-14')
|
||||
expect(apps.updatingApps.phone).toBe(true)
|
||||
|
||||
vi.advanceTimersByTime(1800)
|
||||
|
||||
expect(apps.updatingApps.phone).toBeUndefined()
|
||||
expect(apps.updatedAppReleases.phone).toBe('2026-08-14')
|
||||
expect(mocks.phone.saveDeviceNamespace).toHaveBeenLastCalledWith('apps', {
|
||||
claimedApps: [],
|
||||
homeLayout: apps.homeLayout,
|
||||
launchCounts: {},
|
||||
updatedAppReleases: { phone: '2026-08-14' },
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores duplicate installation requests and invalid persisted ids', () => {
|
||||
vi.useFakeTimers()
|
||||
const apps = useAppStoreStore()
|
||||
@@ -267,7 +242,7 @@ describe('app store', () => {
|
||||
expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('reinstalls core and claimed apps removed from the Home Screen', () => {
|
||||
it('reinstalls claimed apps removed from the Home Screen', () => {
|
||||
vi.useFakeTimers()
|
||||
const apps = useAppStoreStore()
|
||||
|
||||
@@ -279,8 +254,8 @@ describe('app store', () => {
|
||||
apps.installApp('notes')
|
||||
apps.installApp('memory')
|
||||
|
||||
expect(apps.installingApps).toEqual({ notes: true, memory: true })
|
||||
expect(apps.homeLayout.hidden).toEqual(['notes', 'memory'])
|
||||
expect(apps.installingApps).toEqual({ memory: true })
|
||||
expect(apps.homeLayout.hidden).toEqual(['memory'])
|
||||
|
||||
vi.advanceTimersByTime(3000)
|
||||
|
||||
@@ -289,25 +264,15 @@ describe('app store', () => {
|
||||
expect(apps.homeLayout.grid).toContain('notes')
|
||||
expect(apps.homeLayout.grid).toContain('memory')
|
||||
expect(apps.claimedApps).toEqual(['memory'])
|
||||
expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(2)
|
||||
expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('prevents protected apps from being removed from the Home Screen', () => {
|
||||
it('prevents every default app from being removed from the Home Screen', () => {
|
||||
const apps = useAppStoreStore()
|
||||
apps.hydrate(null)
|
||||
mocks.phone.saveDeviceNamespace.mockClear()
|
||||
|
||||
expect([...NON_REMOVABLE_PHONE_APP_IDS]).toEqual([
|
||||
'app-store',
|
||||
'settings',
|
||||
'camera',
|
||||
'photos',
|
||||
'phone',
|
||||
'messages',
|
||||
'mail',
|
||||
'health',
|
||||
])
|
||||
for (const appId of NON_REMOVABLE_PHONE_APP_IDS) {
|
||||
for (const appId of DEFAULT_INSTALLED_PHONE_APP_IDS) {
|
||||
apps.removeHomeApp(appId)
|
||||
expect(apps.homeLayout.hidden).not.toContain(appId)
|
||||
}
|
||||
@@ -332,17 +297,18 @@ describe('app store', () => {
|
||||
|
||||
it('persists home reordering and removal independently from installation', () => {
|
||||
const apps = useAppStoreStore()
|
||||
apps.hydrate(null)
|
||||
apps.hydrate({ claimedApps: ['memory'] })
|
||||
mocks.phone.saveDeviceNamespace.mockClear()
|
||||
|
||||
const notesIndex = apps.homeLayout.grid.indexOf('notes')
|
||||
apps.moveHomeApp('grid', notesIndex, 'grid', 0)
|
||||
expect(apps.homeLayout.grid[0]).toBe('notes')
|
||||
const memoryIndex = apps.homeLayout.grid.indexOf('memory')
|
||||
apps.moveHomeApp('grid', memoryIndex, 'grid', 0)
|
||||
expect(apps.homeLayout.grid[0]).toBe('memory')
|
||||
|
||||
apps.removeHomeApp('notes')
|
||||
expect(apps.homeLayout.grid).not.toContain('notes')
|
||||
expect(apps.homeLayout.hidden).toContain('notes')
|
||||
apps.removeHomeApp('memory')
|
||||
expect(apps.homeLayout.grid).not.toContain('memory')
|
||||
expect(apps.homeLayout.hidden).toContain('memory')
|
||||
expect(mocks.phone.saveDeviceNamespace).toHaveBeenLastCalledWith('apps', {
|
||||
claimedApps: [],
|
||||
claimedApps: ['memory'],
|
||||
homeLayout: apps.homeLayout,
|
||||
launchCounts: {},
|
||||
})
|
||||
|
||||
@@ -31,7 +31,6 @@ import {
|
||||
import { nuiCall } from '@/utils/nui'
|
||||
|
||||
const INSTALL_DURATION_MS = 3000
|
||||
const UPDATE_DURATION_MS = 1800
|
||||
|
||||
type PendingInstallation = {
|
||||
deviceImei: string
|
||||
@@ -39,19 +38,10 @@ type PendingInstallation = {
|
||||
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]
|
||||
@@ -112,7 +102,6 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
state: () => ({
|
||||
claimedApps: [] as LaunchablePhoneAppId[],
|
||||
uninstalledApps: [] as LaunchablePhoneAppId[],
|
||||
updatedAppReleases: {} as Partial<Record<LaunchablePhoneAppId, string>>,
|
||||
homeLayout: createDefaultHomeLayout(
|
||||
getDefaultInstalledIds(),
|
||||
getDefaultGridIds(),
|
||||
@@ -120,7 +109,6 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
),
|
||||
hydrated: false,
|
||||
installingApps: {} as Partial<Record<LaunchablePhoneAppId, boolean>>,
|
||||
updatingApps: {} as Partial<Record<LaunchablePhoneAppId, boolean>>,
|
||||
launchCounts: {} as Partial<Record<LaunchablePhoneAppId, number>>,
|
||||
}),
|
||||
actions: {
|
||||
@@ -157,14 +145,6 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
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)
|
||||
@@ -244,7 +224,6 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
homeLayout?: unknown
|
||||
launchCounts?: unknown
|
||||
uninstalledApps?: unknown
|
||||
updatedAppReleases?: unknown
|
||||
} | null
|
||||
const layoutVersion =
|
||||
data?.homeLayout && typeof data.homeLayout === 'object'
|
||||
@@ -268,29 +247,9 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
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,
|
||||
@@ -512,52 +471,11 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
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,
|
||||
@@ -566,9 +484,6 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
...(this.uninstalledApps.length
|
||||
? { uninstalledApps: this.uninstalledApps }
|
||||
: {}),
|
||||
...(Object.keys(this.updatedAppReleases).length
|
||||
? { updatedAppReleases: this.updatedAppReleases }
|
||||
: {}),
|
||||
})
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1861,8 +1861,6 @@ const defaultLocales: LocaleTree = {
|
||||
search: 'Search',
|
||||
},
|
||||
today: {
|
||||
freshDaily: 'A fresh selection every day',
|
||||
dailyEdition: 'Daily Edition',
|
||||
featured: 'Featured today',
|
||||
gameHighlight: 'Game highlight',
|
||||
appHighlight: 'App highlight',
|
||||
@@ -1896,11 +1894,52 @@ const defaultLocales: LocaleTree = {
|
||||
essentialApps: 'Essential Apps',
|
||||
essentialGames: 'Essential Games',
|
||||
},
|
||||
taglines: {
|
||||
health: 'Health, activity and medical information',
|
||||
'weazel-news': 'Local news and city stories',
|
||||
companies: 'Businesses, jobs and services',
|
||||
music: 'Songs, playlists and audio',
|
||||
picstagram: 'Photo sharing and social feed',
|
||||
feather: 'Short posts and city conversations',
|
||||
fliptok: 'Short videos and trends',
|
||||
flare: 'Social posts and live moments',
|
||||
calendar: 'Events, schedules and reminders',
|
||||
radio: 'Live radio and team communication',
|
||||
'local-pages': 'Local businesses and community pages',
|
||||
crewlink: 'Crews, members and coordination',
|
||||
phone: 'Calls, contacts and voicemail',
|
||||
messages: 'Private texts and media',
|
||||
darkchat: 'Private messaging and secure groups',
|
||||
garage: 'Vehicles, parking and valet',
|
||||
house: 'Homes, access and management',
|
||||
map: 'Navigation and nearby places',
|
||||
skyride: 'City rides and driver booking',
|
||||
banking: 'Accounts, transfers and payments',
|
||||
billing: 'Invoices and payment requests',
|
||||
mail: 'Email and attachments',
|
||||
notes: 'Notes, lists and ideas',
|
||||
memos: 'Voice recordings and memos',
|
||||
calculator: 'Everyday and scientific calculations',
|
||||
camera: 'Photos and video capture',
|
||||
clock: 'Time, alarms and timers',
|
||||
weather: 'Forecasts and current conditions',
|
||||
photos: 'Photo and video library',
|
||||
settings: 'Phone preferences and security',
|
||||
snake: 'Classic arcade snake',
|
||||
memory: 'Card matching challenge',
|
||||
'number-merge': 'Number puzzle and strategy',
|
||||
minesweeper: 'Classic logic puzzle',
|
||||
'tower-stack': 'Precision tower-building game',
|
||||
'sky-flappy': 'Fast arcade flying challenge',
|
||||
citymarkt: 'Local marketplace and listings',
|
||||
'neon-drop': 'Neon block-dropping puzzle',
|
||||
},
|
||||
search: {
|
||||
recommended: 'Recommended',
|
||||
discover: 'Discover',
|
||||
results: 'Search Results',
|
||||
noResults: 'No matching apps or games found.',
|
||||
noResults: 'No Results',
|
||||
noResultsBody: 'Try searching for a different app or game.',
|
||||
ad: 'Ad',
|
||||
topApps: 'Top Apps',
|
||||
topGames: 'Top Games',
|
||||
@@ -1917,7 +1956,7 @@ const defaultLocales: LocaleTree = {
|
||||
games: 'Games',
|
||||
library: 'Your Library',
|
||||
myApps: 'My Apps',
|
||||
update: 'UPDATE',
|
||||
downloadedOn: 'Downloaded {date}',
|
||||
uninstall: 'Uninstall',
|
||||
uninstallApp: 'Uninstall {app}',
|
||||
uninstallTitle: 'Uninstall this app?',
|
||||
|
||||
Reference in New Issue
Block a user