From b406fa94430ce188f9817bcbd380e364aff70901 Mon Sep 17 00:00:00 2001
From: Dominik9906
Date: Tue, 25 Aug 2026 21:26:05 +0200
Subject: [PATCH] FIX - UI and Bug Fix (#44)
---
frontend/src/App.vue | 18 ++++-----
.../AppDevelopmentPreview.contract.test.ts | 25 ++++++++++++
frontend/src/assets/main.css | 12 +++++-
.../EasyShareSheet.contract.test.ts | 9 +++++
frontend/src/components/EasyShareSheet.vue | 9 +++--
frontend/src/stores/app-store.test.ts | 17 +++++++++
frontend/src/stores/phone.ts | 1 +
.../views/apps/AppStoreApp.contract.test.ts | 4 ++
frontend/src/views/apps/AppStoreApp.vue | 38 ++++++++++++++++---
sky_phone/config/locales/de.lua | 3 +-
sky_phone/config/locales/en.lua | 1 +
sky_phone/config/locales/es.lua | 1 +
sky_phone/source/client/focus.lua | 2 +-
tests/client_focus.lua | 2 +-
14 files changed, 119 insertions(+), 23 deletions(-)
diff --git a/frontend/src/App.vue b/frontend/src/App.vue
index b5a81b7..73f4c88 100644
--- a/frontend/src/App.vue
+++ b/frontend/src/App.vue
@@ -86,6 +86,7 @@ import { parsePhonePreferences } from '@/utils/preferences'
import { getHairlinePixelStyle } from '@/utils/rendering'
import { isTextInputElement } from '@/utils/textInputFocus'
import { configurePhoneNumberFormat } from '@/utils/phone'
+import { consumeEscape } from '@/utils/keyboard'
import { isTrustedRootMessageSource } from '@/utils/windowMessages'
import SpringboardView from '@/views/SpringboardView.vue'
@@ -1315,20 +1316,17 @@ async function closePhone(): Promise {
function onKeydown(event: KeyboardEvent): void {
if (event.key !== 'Escape') return
if (simPicker.value) {
- event.preventDefault()
+ if (!consumeEscape(event)) return
void closeSimPicker()
return
}
- queueMicrotask(() => {
- if (event.defaultPrevented || !phone.isOpen || activitySuspended.value)
- return
- if (controlCenterOpened.value) {
- controlCenterOpened.value = false
- return
- }
- void closePhone()
- })
+ if (!phone.isOpen || activitySuspended.value || !consumeEscape(event)) return
+ if (controlCenterOpened.value) {
+ controlCenterOpened.value = false
+ return
+ }
+ void closePhone()
}
function onSystemColorSchemeChange(event: MediaQueryListEvent): void {
diff --git a/frontend/src/AppDevelopmentPreview.contract.test.ts b/frontend/src/AppDevelopmentPreview.contract.test.ts
index 4d054f2..26e599a 100644
--- a/frontend/src/AppDevelopmentPreview.contract.test.ts
+++ b/frontend/src/AppDevelopmentPreview.contract.test.ts
@@ -70,6 +70,31 @@ describe('browser development preview contract', () => {
expect(source).toContain(':device-pixel-ratio="browserDevicePixelRatio"')
})
+ it('clips composited app and overlay layers to the curved display', () => {
+ expect(mainCss).toMatch(
+ /\.phone-screen\s*\{[^}]*--phone-screen-radius:\s*40px;[^}]*overflow:\s*hidden;[^}]*border-radius:\s*var\(--phone-screen-radius\);[^}]*clip-path:\s*inset\(0 round var\(--phone-screen-radius\)\);/s,
+ )
+ })
+
+ it('replaces the CEF button focus rectangle around the home indicator', () => {
+ expect(mainCss).toMatch(
+ /\.phone-home-indicator:focus\s*\{[^}]*outline:\s*none;/s,
+ )
+ expect(mainCss).toMatch(
+ /\.phone-home-indicator:focus-visible span\s*\{[^}]*0 0 0 2px #0a84ff,/s,
+ )
+ })
+
+ it('consumes Escape synchronously before FiveM can open the pause menu', () => {
+ expect(source).toContain("import { consumeEscape } from '@/utils/keyboard'")
+ expect(source).toContain(
+ 'if (!phone.isOpen || activitySuspended.value || !consumeEscape(event)) return',
+ )
+ expect(source).not.toMatch(
+ /function onKeydown\(event: KeyboardEvent\): void \{[\s\S]*?queueMicrotask/,
+ )
+ })
+
it('maps the visible device side controls to phone actions', () => {
expect(source).toContain('@click="toggleHardwareAlertMute"')
expect(source).toContain('@click="changeHardwareAlertVolume(10)"')
diff --git a/frontend/src/assets/main.css b/frontend/src/assets/main.css
index 48ff04e..69214a3 100644
--- a/frontend/src/assets/main.css
+++ b/frontend/src/assets/main.css
@@ -460,6 +460,7 @@ button {
}
}
.phone-screen {
+ --phone-screen-radius: 40px;
--phone-screen-portrait-ratio: 2.30951;
position: relative;
container-type: size;
@@ -469,7 +470,8 @@ button {
height: 98%;
overflow: hidden;
background: #08080a;
- border-radius: 40px;
+ border-radius: var(--phone-screen-radius);
+ clip-path: inset(0 round var(--phone-screen-radius));
}
.phone-screen--camera-landscape {
background: transparent;
@@ -856,6 +858,14 @@ button {
border-radius: 10px;
box-shadow: 0 1px 4px #0008;
}
+.phone-home-indicator:focus {
+ outline: none;
+}
+.phone-home-indicator:focus-visible span {
+ box-shadow:
+ 0 0 0 2px #0a84ff,
+ 0 1px 4px #0008;
+}
.phone-home-indicator--interactive {
cursor: pointer;
}
diff --git a/frontend/src/components/EasyShareSheet.contract.test.ts b/frontend/src/components/EasyShareSheet.contract.test.ts
index cb93fa0..0307bdf 100644
--- a/frontend/src/components/EasyShareSheet.contract.test.ts
+++ b/frontend/src/components/EasyShareSheet.contract.test.ts
@@ -24,4 +24,13 @@ describe('EasyShareSheet Sky UI contract', () => {
/\.easyshare-history\s*\{[^}]*overflow:\s*hidden[^}]*background:\s*var\(--easyshare-list-surface\)/s,
)
})
+
+ it('only exposes and opens installed share destinations', () => {
+ expect(source).toContain("if (appStore.isInstalled('flare'))")
+ expect(source).toContain("if (appStore.isInstalled('darkchat'))")
+ expect(source).toContain('.filter((id) => appStore.isInstalled(id))')
+ expect(source).toContain('if (!appStore.isInstalled(kind)) return')
+ expect(source).toContain('if (!appStore.isInstalled(appId)) return')
+ expect(source).not.toContain('appStore.homeLayout.hidden.includes')
+ })
})
diff --git a/frontend/src/components/EasyShareSheet.vue b/frontend/src/components/EasyShareSheet.vue
index 9aafd6f..3b4d8b8 100644
--- a/frontend/src/components/EasyShareSheet.vue
+++ b/frontend/src/components/EasyShareSheet.vue
@@ -67,7 +67,7 @@ const sharePeople = computed(() => {
}> = []
const phoneNumbers = new Set()
- if (!appStore.homeLayout.hidden.includes('flare')) {
+ if (appStore.isInstalled('flare')) {
for (const match of flare.matches) {
people.push({
avatar: match.profile.photoUrls[0],
@@ -102,7 +102,7 @@ const sharePeople = computed(() => {
phoneNumbers.add(contact.phone_number)
}
- if (!appStore.homeLayout.hidden.includes('darkchat')) {
+ if (appStore.isInstalled('darkchat')) {
for (const conversation of darkChat.conversations.slice(0, 8)) {
people.push({
kind: 'darkchat',
@@ -116,7 +116,7 @@ const sharePeople = computed(() => {
})
const shareApps = computed(() =>
(easyShare.payload ? easyShareDestinationAppIds(easyShare.payload) : [])
- .filter((id) => !appStore.homeLayout.hidden.includes(id))
+ .filter((id) => appStore.isInstalled(id))
.flatMap((id) => {
const app = getPhoneApp(id)
return app ? [{ app, id }] : []
@@ -195,18 +195,21 @@ function endDrag(event: PointerEvent): void {
}
function shareToChat(kind: EasyShareChatApp, targetId: string): void {
+ if (!appStore.isInstalled(kind)) return
if (!easyShare.prepareChatDraft(kind, targetId)) return
close()
void router.push(`/apps/${kind}`)
}
function openChatApp(kind: EasyShareChatApp): void {
+ if (!appStore.isInstalled(kind)) return
if (!easyShare.prepareChatDraft(kind)) return
close()
void router.push(`/apps/${kind}`)
}
function openShareApp(appId: EasyShareDestinationApp): void {
+ if (!appStore.isInstalled(appId)) return
if (appId === 'messages' || appId === 'darkchat' || appId === 'flare') {
openChatApp(appId)
return
diff --git a/frontend/src/stores/app-store.test.ts b/frontend/src/stores/app-store.test.ts
index b82a21f..5e37f02 100644
--- a/frontend/src/stores/app-store.test.ts
+++ b/frontend/src/stores/app-store.test.ts
@@ -264,6 +264,23 @@ describe('app store', () => {
expect(apps.homeLayout.hidden).not.toContain('snake')
})
+ it('uninstalls claimed Banking and Picstagram apps from every app state', () => {
+ const apps = useAppStoreStore()
+ apps.hydrate({ claimedApps: ['banking', 'picstagram'] })
+ mocks.phone.saveDeviceNamespace.mockClear()
+
+ for (const appId of ['banking', 'picstagram'] as const) {
+ expect(apps.isInstalled(appId)).toBe(true)
+ expect(apps.uninstallApp(appId)).toBe(true)
+ expect(apps.isInstalled(appId)).toBe(false)
+ expect(apps.claimedApps).not.toContain(appId)
+ expect(apps.uninstalledApps).toContain(appId)
+ expect(apps.homeLayout.hidden).toContain(appId)
+ }
+
+ expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(2)
+ })
+
it('hydrates persisted removals while rejecting protected and invalid ids', () => {
const apps = useAppStoreStore()
diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts
index 67f53f0..7249657 100644
--- a/frontend/src/stores/phone.ts
+++ b/frontend/src/stores/phone.ts
@@ -3027,6 +3027,7 @@ const defaultLocales: LocaleTree = {
uninstallTitle: 'Uninstall this app?',
uninstallBody:
'{app} will be removed from this phone. You can download it again from the App Store.',
+ uninstallFailed: 'The app could not be uninstalled. Please try again.',
},
details: {
skyStudios: 'Sky Studios',
diff --git a/frontend/src/views/apps/AppStoreApp.contract.test.ts b/frontend/src/views/apps/AppStoreApp.contract.test.ts
index f99a1bc..e45fead 100644
--- a/frontend/src/views/apps/AppStoreApp.contract.test.ts
+++ b/frontend/src/views/apps/AppStoreApp.contract.test.ts
@@ -101,6 +101,10 @@ describe('AppStoreApp Sky navigation contract', () => {
expect(source).toContain(
'appStore.uninstallApp(uninstallCandidate.value.id)',
)
+ expect(source).toContain('@click.stop="requestUninstall(app)"')
+ expect(source).toContain('if (!appStore.uninstallApp(')
+ expect(source).toContain('Apps.appStore.account.uninstallFailed')
+ expect(source).toContain('role="alert"')
expect(source).toContain('v-if="isPhoneAppRemovable(app)"')
expect(source).toContain(':opened="Boolean(uninstallCandidate)"')
expect(source).toContain('class="store-account__grabber"')
diff --git a/frontend/src/views/apps/AppStoreApp.vue b/frontend/src/views/apps/AppStoreApp.vue
index 9d7a087..5f27488 100644
--- a/frontend/src/views/apps/AppStoreApp.vue
+++ b/frontend/src/views/apps/AppStoreApp.vue
@@ -63,6 +63,7 @@ const openedAt = new Date()
const featuredSlide = ref(0)
const profileOpened = ref(false)
const uninstallCandidate = ref(null)
+const uninstallError = ref('')
const selectedApp = ref(null)
const storeScroll = ref(null)
const featuredScroller = ref(null)
@@ -288,6 +289,16 @@ function closeProfile(): void {
profileDragOffset.value = 0
}
+function requestUninstall(app: LaunchablePhoneAppDefinition): void {
+ uninstallError.value = ''
+ uninstallCandidate.value = app
+}
+
+function closeUninstallDialog(): void {
+ uninstallError.value = ''
+ uninstallCandidate.value = null
+}
+
function beginProfileDrag(event: PointerEvent): void {
if (!profileOpened.value || event.button !== 0) return
profileDragPointerId = event.pointerId
@@ -318,8 +329,11 @@ function endProfileDrag(event: PointerEvent): void {
function confirmUninstall(): void {
if (!uninstallCandidate.value) return
- appStore.uninstallApp(uninstallCandidate.value.id)
- uninstallCandidate.value = null
+ if (!appStore.uninstallApp(uninstallCandidate.value.id)) {
+ uninstallError.value = phone.t('Apps.appStore.account.uninstallFailed')
+ return
+ }
+ closeUninstallDialog()
}
function highlightStyle(index: number): Record {
@@ -1107,7 +1121,7 @@ watch(
app: getPhoneAppLabel(app, phone.t),
})
"
- @click="uninstallCandidate = app"
+ @click.stop="requestUninstall(app)"
>
@@ -1119,8 +1133,8 @@ watch(
{{ phone.t('Apps.appStore.account.uninstallTitle') }}
@@ -1132,8 +1146,15 @@ watch(
})
}}
+
+ {{ uninstallError }}
+
-
+
{{ phone.t('Common.cancel') }}
@@ -1476,6 +1497,11 @@ watch(
background: var(--sky-danger-soft);
}
+.store-account__uninstall-error {
+ color: var(--sky-danger);
+ font-size: 12px;
+}
+
.store-scroll {
min-height: 0;
flex: 1 1 auto;
diff --git a/sky_phone/config/locales/de.lua b/sky_phone/config/locales/de.lua
index 00779a5..e33b79a 100644
--- a/sky_phone/config/locales/de.lua
+++ b/sky_phone/config/locales/de.lua
@@ -2022,10 +2022,11 @@ Locales["de"] = {
},
account = {
account = "Account", title = "App-Verwaltung", skyAccount = "Sky Phone Konto",
- apps = "Installation von Apps", games = "Spiele", library = "Deine Bibliothek.", myApps = "Meine Apps",
+ apps = "Installierte Apps", games = "Spiele", library = "Deine Bibliothek.", myApps = "Meine Apps",
downloadedOn = "Gespeichert {date}", uninstall = "Deinstallieren", uninstallApp = "Deinstallieren {app}",
uninstallTitle = "Diese App deinstallieren?",
uninstallBody = "{app} wird von diesem Handy entfernt. Du kannst die App erneut aus dem App Store laden.",
+ uninstallFailed = "Die App konnte nicht deinstalliert werden. Bitte versuche es erneut.",
},
details = {
skyStudios = "Sky Studios", share = "App teilen", openDetails = "Ansicht {app}",
diff --git a/sky_phone/config/locales/en.lua b/sky_phone/config/locales/en.lua
index 57e9aa8..d735a95 100644
--- a/sky_phone/config/locales/en.lua
+++ b/sky_phone/config/locales/en.lua
@@ -2026,6 +2026,7 @@ Locales["en"] = {
downloadedOn = "Downloaded {date}", 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.",
+ uninstallFailed = "The app could not be uninstalled. Please try again.",
},
details = {
skyStudios = "Sky Studios", share = "Share app", openDetails = "View {app}",
diff --git a/sky_phone/config/locales/es.lua b/sky_phone/config/locales/es.lua
index 21728e7..bfcd516 100644
--- a/sky_phone/config/locales/es.lua
+++ b/sky_phone/config/locales/es.lua
@@ -2026,6 +2026,7 @@ Locales["es"] = {
downloadedOn = "Descargado {date}", uninstall = "Desinstalar", uninstallApp = "Desinstalar {app}",
uninstallTitle = "¿Desinstalar esta aplicación?",
uninstallBody = "{app} será eliminado de este teléfono. Puedes descargarlo de nuevo de la App Store.",
+ uninstallFailed = "No se ha podido desinstalar la aplicación. Inténtalo de nuevo.",
},
details = {
skyStudios = "Sky Studios", share = "Compartir aplicación", openDetails = "Ver {app}",
diff --git a/sky_phone/source/client/focus.lua b/sky_phone/source/client/focus.lua
index 33ec523..ca7ed9d 100644
--- a/sky_phone/source/client/focus.lua
+++ b/sky_phone/source/client/focus.lua
@@ -1,6 +1,6 @@
SkyPhoneFocus = {}
-local blocked_phone_controls = { 24, 140, 141, 142, 257, 263, 264 }
+local blocked_phone_controls = { 24, 140, 141, 142, 199, 200, 257, 263, 264 }
local blocked_phone_look_controls = { 1, 2, 3, 4, 5, 6 }
local focused_control_groups = { 0, 1, 2 }
local hold_to_look_enabled = false
diff --git a/tests/client_focus.lua b/tests/client_focus.lua
index 8135d9f..3e29e29 100644
--- a/tests/client_focus.lua
+++ b/tests/client_focus.lua
@@ -252,7 +252,7 @@ assert(firing_disabled, "focused phone cursor must block attacks while typing")
all_controls_disabled = {}
firing_disabled = false
SkyPhoneFocus.ApplyGameInputControls(true)
-for _, control in ipairs({ 24, 140, 141, 142, 257, 263, 264 }) do
+for _, control in ipairs({ 24, 140, 141, 142, 199, 200, 257, 263, 264 }) do
assert(disabled_controls[control], ("phone control %d must remain disabled"):format(control))
end
assert(not disabled_controls[19], "Alt must remain available while no phone text input is focused")