diff --git a/frontend/src/config/apps.ts b/frontend/src/config/apps.ts index b20c276..dd63476 100644 --- a/frontend/src/config/apps.ts +++ b/frontend/src/config/apps.ts @@ -627,6 +627,24 @@ export const BUILTIN_PHONE_APP_IDS: ReadonlySet = new Set( BUILTIN_PHONE_APPS.map((app) => app.id), ) +export const DEFAULT_INSTALLED_PHONE_APP_IDS: ReadonlySet = + new Set([ + 'phone', + 'messages', + 'calculator', + 'camera', + 'clock', + 'weather', + 'mail', + 'notes', + 'memos', + 'photos', + 'app-store', + 'settings', + 'map', + 'calendar', + ]) + export const NON_REMOVABLE_PHONE_APP_IDS: ReadonlySet = new Set([ 'app-store', diff --git a/frontend/src/stores/app-store.test.ts b/frontend/src/stores/app-store.test.ts index 21b1510..5ac2891 100644 --- a/frontend/src/stores/app-store.test.ts +++ b/frontend/src/stores/app-store.test.ts @@ -1,7 +1,10 @@ import { createPinia, setActivePinia } from 'pinia' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { NON_REMOVABLE_PHONE_APP_IDS } from '@/config/apps' +import { + DEFAULT_INSTALLED_PHONE_APP_IDS, + NON_REMOVABLE_PHONE_APP_IDS, +} from '@/config/apps' import { useAppStoreStore } from '@/stores/app-store' import { getHomeFolder, @@ -32,6 +35,53 @@ describe('app store', () => { vi.useRealTimers() }) + it('installs only the fourteen standard built-in apps by default', () => { + const apps = useAppStoreStore() + + apps.hydrate(null) + + expect([...DEFAULT_INSTALLED_PHONE_APP_IDS]).toEqual([ + 'phone', + 'messages', + 'calculator', + 'camera', + 'clock', + 'weather', + 'mail', + 'notes', + 'memos', + 'photos', + 'app-store', + 'settings', + 'map', + 'calendar', + ]) + for (const appId of DEFAULT_INSTALLED_PHONE_APP_IDS) { + expect(apps.isInstalled(appId)).toBe(true) + } + expect(apps.isInstalled('banking')).toBe(false) + expect(apps.isInstalled('feather')).toBe(false) + expect(apps.isInstalled('snake')).toBe(false) + }) + + it('removes old automatic apps unless the player installed them', () => { + const apps = useAppStoreStore() + + apps.hydrate({ + claimedApps: ['feather'], + homeLayout: { + dock: [], + grid: ['banking', 'feather', 'phone'], + hidden: [], + version: 5, + }, + }) + + expect(apps.homeLayout.grid).not.toContain('banking') + expect(apps.homeLayout.grid).toContain('feather') + expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(1) + }) + it('hydrates valid launch counts and persists launches with claimed apps', () => { const apps = useAppStoreStore() @@ -129,6 +179,79 @@ describe('app store', () => { }) }) + it('fully uninstalls removable apps and allows downloading them again', () => { + vi.useFakeTimers() + const apps = useAppStoreStore() + apps.hydrate(null) + 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(mocks.phone.saveDeviceNamespace).toHaveBeenLastCalledWith('apps', { + claimedApps: [], + homeLayout: apps.homeLayout, + launchCounts: {}, + uninstalledApps: ['calculator'], + }) + + apps.installApp('calculator') + vi.advanceTimersByTime(3000) + + expect(apps.isInstalled('calculator')).toBe(true) + expect(apps.uninstalledApps).toEqual([]) + expect(apps.homeLayout.hidden).not.toContain('calculator') + }) + + it('hydrates persisted removals while rejecting protected and invalid ids', () => { + const apps = useAppStoreStore() + + 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.isInstalled('phone')).toBe(true) + expect(apps.updatedAppReleases).toEqual({ phone: '2026-08-14' }) + }) + + it('protects system apps 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) + 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() diff --git a/frontend/src/stores/app-store.ts b/frontend/src/stores/app-store.ts index 5a9a163..b46cd49 100644 --- a/frontend/src/stores/app-store.ts +++ b/frontend/src/stores/app-store.ts @@ -1,6 +1,7 @@ import { defineStore } from 'pinia' import { + DEFAULT_INSTALLED_PHONE_APP_IDS, getPhoneApp, isExternalPhoneApp, isPhoneAppId, @@ -30,6 +31,7 @@ import { import { nuiCall } from '@/utils/nui' const INSTALL_DURATION_MS = 3000 +const UPDATE_DURATION_MS = 1800 type PendingInstallation = { deviceImei: string @@ -37,10 +39,19 @@ type PendingInstallation = { 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] @@ -56,7 +67,9 @@ function getDefaultDockIds(): LaunchablePhoneAppId[] { function getDefaultInstalledIds(): LaunchablePhoneAppId[] { return PHONE_APPS.filter((app) => - isExternalPhoneApp(app) ? app.defaultInstalled : app.category !== 'games', + isExternalPhoneApp(app) + ? app.defaultInstalled + : DEFAULT_INSTALLED_PHONE_APP_IDS.has(app.id), ).map((app) => app.id) } @@ -67,9 +80,39 @@ function isProtectedHomeApp(appId: LaunchablePhoneAppId): boolean { : 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(), @@ -77,6 +120,7 @@ export const useAppStoreStore = defineStore('app-store', { ), hydrated: false, installingApps: {} as Partial>, + updatingApps: {} as Partial>, launchCounts: {} as Partial>, }), actions: { @@ -95,6 +139,9 @@ export const useAppStoreStore = defineStore('app-store', { 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) @@ -110,6 +157,14 @@ 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) @@ -188,6 +243,8 @@ export const useAppStoreStore = defineStore('app-store', { claimedApps?: unknown homeLayout?: unknown launchCounts?: unknown + uninstalledApps?: unknown + updatedAppReleases?: unknown } | null const layoutVersion = data?.homeLayout && typeof data.homeLayout === 'object' @@ -204,9 +261,40 @@ export const useAppStoreStore = defineStore('app-store', { 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(), @@ -216,6 +304,7 @@ export const useAppStoreStore = defineStore('app-store', { data?.homeLayout, defaults, installedIds, + false, ) const protectedHiddenAppIds = this.homeLayout.hidden.filter(isProtectedHomeApp) @@ -240,6 +329,7 @@ export const useAppStoreStore = defineStore('app-store', { this.hydrated = true if ( protectedHiddenAppIds.length || + removedLegacyDefaults || layoutVersion === 2 || layoutVersion === 3 || layoutVersion === 4 @@ -248,24 +338,30 @@ export const useAppStoreStore = defineStore('app-store', { } }, 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 - : app.category !== 'games' + : 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) + this.homeLayout = parseHomeLayout( + this.homeLayout, + defaults, + installedIds, + false, + ) for (const appId of [...this.homeLayout.hidden]) { if (isProtectedHomeApp(appId)) { @@ -406,11 +502,73 @@ export const useAppStoreStore = defineStore('app-store', { 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 } + : {}), }) }, }, diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts index c01e077..a0271d7 100644 --- a/frontend/src/stores/phone.ts +++ b/frontend/src/stores/phone.ts @@ -35,6 +35,7 @@ export type PhoneOpenPayload = { locales?: LocaleTree memos?: DeviceBootstrap['memos'] notes?: DeviceBootstrap['notes'] + player?: DeviceBootstrap['player'] security?: DeviceSecurity token?: string } @@ -1756,15 +1757,116 @@ const defaultLocales: LocaleTree = { get: 'GET', open: 'OPEN', installing: 'Installing', - searchPlaceholder: 'Search apps and games', + player: 'Player', + profileLabel: '{name} profile', + searchPlaceholder: 'Games, apps, stories and more', appsTitle: 'Built-in Apps', gamesTitle: 'Games', selected: 'Apps available for your Sky Phone', tabs: { + today: 'Today', apps: 'Apps', games: 'Games', search: 'Search', }, + today: { + freshDaily: 'A fresh selection every day', + dailyEdition: 'Daily Edition', + featured: 'Featured today', + gameHighlight: 'Game highlight', + appHighlight: 'App highlight', + curatedForYou: 'Curated for you', + editorsChoice: "Editor's Choice", + discoverTitle: 'Discover {app}', + playTitle: 'Play something new with {app}', + description: '{app} is in the spotlight today on Sky Phone.', + categoryDescription: '{category} · Made for Sky Phone', + moreHighlights: "Today's Highlights", + topToday: 'Top Today', + topTodayDescription: 'Popular picks from today’s edition', + oneMoreThing: 'One more thing', + }, + filters: { + all: 'All', + social: 'Social', + utilities: 'Utilities', + shopping: 'Shopping', + productivity: 'Productivity', + arcade: 'Arcade', + puzzle: 'Puzzle', + classic: 'Classics', + }, + browse: { + categories: 'Store categories', + availableNow: 'Available now', + featureTitle: 'Discover {app}', + featuredPages: 'Featured apps', + handPicked: 'Hand-picked for you', + essentialApps: 'Essential Apps', + essentialGames: 'Essential Games', + }, + search: { + recommended: 'Recommended', + discover: 'Discover', + results: 'Search Results', + noResults: 'No matching apps or games found.', + ad: 'Ad', + topApps: 'Top Apps', + topGames: 'Top Games', + bestApps: 'Bestselling Apps', + bestGames: 'Bestselling Games', + productivity: 'Productivity', + photoVideo: 'Photo & Video', + }, + account: { + account: 'Account', + title: 'App Management', + skyAccount: 'Sky Phone Account', + apps: 'Installed Apps', + games: 'Games', + library: 'Your Library', + myApps: 'My Apps', + update: 'UPDATE', + uninstall: 'Uninstall', + uninstallApp: 'Uninstall {app}', + uninstallTitle: 'Uninstall this app?', + uninstallBody: + '{app} will be removed from this phone. You can download it again from the App Store.', + }, + details: { + skyStudios: 'Sky Studios', + share: 'Share app', + openDetails: 'View {app}', + shareReady: 'App information is ready to share.', + shareCopy: 'Take a look at {app} in the App Store.', + ratings: '{count} ratings', + age: 'Age Rating', + years: 'Years', + chart: 'Chart', + whatsNew: "What's New", + version: 'Version {version}', + updatedToday: 'Today', + releaseNotes: + '{app} now includes fresh content, faster loading and several interface improvements.', + preview: 'Preview', + testData: 'Preview data', + previewLive: 'Live Preview', + previousPreview: 'Previous preview', + nextPreview: 'Next preview', + previewOverview: 'Everything important at a glance', + previewCommunity: 'Community', + previewInsights: 'Insights', + today: 'Today', + progress: 'Progress', + communityTitle: 'Together in {app}', + communityBody: 'Discover activity from players around Los Santos.', + featuredMoment: 'Featured moment from today', + weeklyActivity: 'Weekly Activity', + fromLastWeek: 'from last week', + about: 'About this App', + description: + '{app} is a detailed {category} experience made for Sky Phone. Explore its features, connect with the city and keep everything close at hand.', + }, }, phone: { name: 'Phone', @@ -3637,7 +3739,8 @@ const defaultLocales: LocaleTree = { shareCopy: '{count} photos and videos shared from Photos.', delete: 'Delete Selected', deleteTitle: 'Delete Selected Media?', - deleteBody: 'The selected photos and videos will be permanently deleted.', + deleteBody: + 'The selected photos and videos will be permanently deleted.', deleted: '{count} media items deleted.', limit: 'You can select up to 50 media items.', }, @@ -4101,6 +4204,10 @@ export const usePhoneStore = defineStore('phone', { preferences: cloneJsonData(DEFAULT_PHONE_PREFERENCES), persistenceGeneration: 0, persistenceSession: ++nextPersistenceSession, + player: { + firstName: '', + lastName: '', + } as DeviceBootstrap['player'], security: { enabled: false, length: null, @@ -4133,6 +4240,7 @@ export const usePhoneStore = defineStore('phone', { this.lang = payload.lang ?? 'en' this.locales = payload.locales ?? defaultLocales if (payload.device) this.hydrateDevice(payload.device) + if (payload.player) this.player = payload.player this.security = payload.security ?? { enabled: false, length: null, @@ -4142,6 +4250,7 @@ export const usePhoneStore = defineStore('phone', { }, endDeviceSession(): void { this.close() + this.player = { firstName: '', lastName: '' } if (this.deviceSessionToken !== null) { this.deviceSessionToken = null this.persistenceGeneration += 1 diff --git a/frontend/src/types/device.ts b/frontend/src/types/device.ts index 4a4dd32..34ee1b4 100644 --- a/frontend/src/types/device.ts +++ b/frontend/src/types/device.ts @@ -14,6 +14,11 @@ export type PhoneDevice = { sim: PhoneSim | null } +export type PhonePlayerIdentity = { + firstName: string + lastName: string +} + export type PhoneNotificationDevicePayload = { imei: string name: string @@ -45,6 +50,7 @@ export type DeviceBootstrap = { device: PhoneDevice memos: MemoDto[] notes: Note[] + player: PhonePlayerIdentity security: DeviceSecurity token: string } diff --git a/frontend/src/ui/controls.css b/frontend/src/ui/controls.css index 7165875..2c4770f 100644 --- a/frontend/src/ui/controls.css +++ b/frontend/src/ui/controls.css @@ -1203,6 +1203,15 @@ label.sky-list-item__row { opacity: 0.55; } +.sky-searchbar__suffix { + width: var(--sky-touch-target, 44px); + height: var(--sky-touch-target, 44px); + display: grid; + flex: none; + place-items: center; + color: var(--sky-muted, rgba(0, 0, 0, 0.55)); +} + .sky-searchbar__clear { width: var(--sky-touch-target, 44px); height: var(--sky-touch-target, 44px); diff --git a/frontend/src/ui/controls/SkySearchbar.vue b/frontend/src/ui/controls/SkySearchbar.vue index c4acfe9..2da2ea8 100644 --- a/frontend/src/ui/controls/SkySearchbar.vue +++ b/frontend/src/ui/controls/SkySearchbar.vue @@ -160,6 +160,9 @@ function disable(event: MouseEvent): void { @focus="handleFocus" @input="handleInput" /> + + +