diff --git a/frontend/src/config/apps.ts b/frontend/src/config/apps.ts index 69b1cf3..bf5eeff 100644 --- a/frontend/src/config/apps.ts +++ b/frontend/src/config/apps.ts @@ -715,8 +715,9 @@ export function getPhoneAppLabel( export function isPhoneAppRemovable(app: PhoneAppDefinition): boolean { return app.kind === 'external' - ? app.removable - : !NON_REMOVABLE_PHONE_APP_IDS.has(app.id) + ? app.removable && !app.defaultInstalled + : !DEFAULT_INSTALLED_PHONE_APP_IDS.has(app.id) && + !NON_REMOVABLE_PHONE_APP_IDS.has(app.id) } export function isLaunchablePhoneApp( diff --git a/frontend/src/stores/app-store.test.ts b/frontend/src/stores/app-store.test.ts index 9b14196..22186a0 100644 --- a/frontend/src/stores/app-store.test.ts +++ b/frontend/src/stores/app-store.test.ts @@ -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: {}, }) diff --git a/frontend/src/stores/app-store.ts b/frontend/src/stores/app-store.ts index b46cd49..2bf4ece 100644 --- a/frontend/src/stores/app-store.ts +++ b/frontend/src/stores/app-store.ts @@ -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 -} - const pendingInstallations = new WeakMap< object, Map >() -const pendingUpdates = new WeakMap< - object, - Map ->() 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>, homeLayout: createDefaultHomeLayout( getDefaultInstalledIds(), getDefaultGridIds(), @@ -120,7 +109,6 @@ export const useAppStoreStore = defineStore('app-store', { ), hydrated: false, installingApps: {} as Partial>, - updatingApps: {} as Partial>, launchCounts: {} as Partial>, }), 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() - 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 } - : {}), }) }, }, diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts index 8a77b2a..172867b 100644 --- a/frontend/src/stores/phone.ts +++ b/frontend/src/stores/phone.ts @@ -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?', diff --git a/frontend/src/views/apps/AppStoreAction.contract.test.ts b/frontend/src/views/apps/AppStoreAction.contract.test.ts index 295e9ef..47233e1 100644 --- a/frontend/src/views/apps/AppStoreAction.contract.test.ts +++ b/frontend/src/views/apps/AppStoreAction.contract.test.ts @@ -9,10 +9,13 @@ const source = readFileSync( ) describe('AppStoreAction contract', () => { - it('uses an Apple-style cloud download icon instead of GET text', () => { - expect(source).toContain("import { CloudDownload } from 'lucide-vue-next'") + it('uses localized text for the download button instead of an icon', () => { expect(source).toContain('v-if="action === \'get\'"') - expect(source).not.toContain("phone.t('Apps.appStore.get')") + expect(source).toContain("phone.t('Apps.appStore.get')") + expect(source).not.toContain('CloudDownload') + expect(source).toContain( + ":class=\"{ 'app-store-action--icon': action === 'installing' }\"", + ) }) it('shows a timed circular installation progress with a center stop mark', () => { diff --git a/frontend/src/views/apps/AppStoreAction.vue b/frontend/src/views/apps/AppStoreAction.vue index 98ae513..943b5df 100644 --- a/frontend/src/views/apps/AppStoreAction.vue +++ b/frontend/src/views/apps/AppStoreAction.vue @@ -1,6 +1,4 @@