ENH - refine App Store experience

This commit is contained in:
smx.pusha
2026-08-16 19:22:56 +02:00
parent 77dd50a900
commit 42e8180ebe
11 changed files with 652 additions and 424 deletions
+31 -65
View File
@@ -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: {},
})
-85
View File
@@ -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 }
: {}),
})
},
},
+43 -4
View File
@@ -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?',