From b406fa94430ce188f9817bcbd380e364aff70901 Mon Sep 17 00:00:00 2001
From: Dominik9906
Date: Tue, 25 Aug 2026 21:26:05 +0200
Subject: [PATCH 1/7] 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")
From 76484cfe05b462b47cbb696a43ab3cd5f45aec2a Mon Sep 17 00:00:00 2001
From: "Leon.Schmidt" <159480018+leonw21342315@users.noreply.github.com>
Date: Tue, 25 Aug 2026 21:26:18 +0200
Subject: [PATCH 2/7] PERF - bound crypto market history queries (#45)
---
sky_phone/source/server/crypto.lua | 108 +++++++++++++++++++----------
1 file changed, 73 insertions(+), 35 deletions(-)
diff --git a/sky_phone/source/server/crypto.lua b/sky_phone/source/server/crypto.lua
index 0fff9a6..df4f85e 100644
--- a/sky_phone/source/server/crypto.lua
+++ b/sky_phone/source/server/crypto.lua
@@ -8,8 +8,10 @@ local market_order = {}
local market_dynamics = {}
local market_state = {}
local market_history = {}
+local market_daily_buckets = {}
local market_cursor = 1
-local market_persistence_interval = 5 * 60 * 1000
+local market_daily_bucket_seconds = 5 * 60
+local market_persistence_interval = market_daily_bucket_seconds * 1000
local global_market_trend = 0
local global_market_cycle = {
direction = 0,
@@ -373,6 +375,37 @@ local function initialize_markets()
market_cursor = math.min(market_cursor, math.max(#market_order, 1))
end
+local function add_market_daily_price(buckets, price, timestamp)
+ local bucket_id = math.floor(timestamp / market_daily_bucket_seconds)
+ local bucket = buckets[#buckets]
+ if bucket and bucket.bucket_id == bucket_id then
+ bucket.low = math.min(bucket.low, price)
+ bucket.high = math.max(bucket.high, price)
+ return
+ end
+ buckets[#buckets + 1] = {
+ bucket_id = bucket_id,
+ low = price,
+ high = price,
+ }
+end
+
+local function market_daily_range(market_id, price, timestamp)
+ local buckets = market_daily_buckets[market_id]
+ local cutoff_bucket = math.floor((timestamp - 24 * 60 * 60) / market_daily_bucket_seconds)
+ while buckets[1] and buckets[1].bucket_id < cutoff_bucket do
+ table.remove(buckets, 1)
+ end
+ local low = price
+ local high = price
+ for index = 1, #buckets do
+ local bucket = buckets[index]
+ low = math.min(low, bucket.low)
+ high = math.max(high, bucket.high)
+ end
+ return low, high
+end
+
local function load_market_cache()
local rows = Bridge.Database.Query([[
SELECT `id`,`price`,`version`,`status`, UNIX_TIMESTAMP(`updated_at`) AS `updated_at`
@@ -380,13 +413,16 @@ local function load_market_cache()
]], {})
local next_market_state = {}
local next_market_history = {}
+ local next_market_daily_buckets = {}
+ local history_limit = math.min(Config.Crypto.HistoryRetentionTicks, Config.Crypto.SparklinePoints)
+ local timestamp = os.time()
for _, row in ipairs(rows) do
if markets[row.id] then
next_market_state[row.id] = {
price = tonumber(row.price) or markets[row.id].InitialPrice,
version = tonumber(row.version) or 1,
status = row.status,
- updated_at = tonumber(row.updated_at) or os.time(),
+ updated_at = tonumber(row.updated_at) or timestamp,
dirty = false,
}
end
@@ -397,30 +433,40 @@ local function load_market_cache()
error(("[sky_phone] Crypto market state is missing after initialization: %s"):format(market_id))
end
local rows_for_market = Bridge.Database.Query([[
- SELECT `price`,`version`, UNIX_TIMESTAMP(`created_at`) AS `created_at`
+ SELECT `price`
FROM `sky_phone_crypto_market_ticks`
- WHERE `market_id` = ? ORDER BY `id` DESC LIMIT ?
- ]], { market_id, Config.Crypto.HistoryRetentionTicks })
+ WHERE `market_id` = ? ORDER BY `created_at` DESC, `id` DESC LIMIT ?
+ ]], { market_id, history_limit })
local history = {}
for index = #rows_for_market, 1, -1 do
- local tick = rows_for_market[index]
- history[#history + 1] = {
- price = tonumber(tick.price) or state.price,
- version = tonumber(tick.version) or state.version,
- created_at = tonumber(tick.created_at) or state.updated_at,
- }
+ history[#history + 1] = tonumber(rows_for_market[index].price) or state.price
end
if #history == 0 then
- history[1] = {
- price = state.price,
- version = state.version,
- created_at = state.updated_at,
- }
+ history[1] = state.price
end
next_market_history[market_id] = history
+ local daily_rows = Bridge.Database.Query([[
+ SELECT FLOOR(UNIX_TIMESTAMP(`created_at`) / ?) AS `bucket_id`,
+ MIN(`price`) AS `low_price`, MAX(`price`) AS `high_price`
+ FROM `sky_phone_crypto_market_ticks`
+ WHERE `market_id` = ?
+ AND `created_at` >= DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 24 HOUR)
+ GROUP BY `bucket_id` ORDER BY `bucket_id`
+ ]], { market_daily_bucket_seconds, market_id })
+ local daily_buckets = {}
+ for _, daily_row in ipairs(daily_rows) do
+ daily_buckets[#daily_buckets + 1] = {
+ bucket_id = tonumber(daily_row.bucket_id),
+ low = tonumber(daily_row.low_price) or state.price,
+ high = tonumber(daily_row.high_price) or state.price,
+ }
+ end
+ add_market_daily_price(daily_buckets, state.price, timestamp)
+ next_market_daily_buckets[market_id] = daily_buckets
end
market_state = next_market_state
market_history = next_market_history
+ market_daily_buckets = next_market_daily_buckets
end
local function persist_market_cache()
@@ -557,6 +603,7 @@ local function market_dtos(selected_market_ids)
end
end
local result = {}
+ local timestamp = os.time()
for _, market_id in ipairs(market_order) do
if not selected or selected[market_id] then
local config = markets[market_id]
@@ -565,7 +612,7 @@ local function market_dtos(selected_market_ids)
local prices = {}
local first_history_index = math.max(1, #history - Config.Crypto.SparklinePoints + 1)
for index = first_history_index, #history do
- prices[#prices + 1] = history[index].price
+ prices[#prices + 1] = history[index]
end
if #prices == 0 then
prices[1] = tonumber(row.price)
@@ -581,17 +628,7 @@ local function market_dtos(selected_market_ids)
end
local first = prices[1]
local price = tonumber(row.price) or config.InitialPrice
- local daily_low = price
- local daily_high = price
- local daily_cutoff = os.time() - 24 * 60 * 60
- for index = #history, 1, -1 do
- local tick = history[index]
- if tick.created_at < daily_cutoff then
- break
- end
- daily_low = math.min(daily_low, tick.price)
- daily_high = math.max(daily_high, tick.price)
- end
+ local daily_low, daily_high = market_daily_range(market_id, price, timestamp)
result[#result + 1] = {
id = market_id,
symbol = config.Symbol,
@@ -607,7 +644,7 @@ local function market_dtos(selected_market_ids)
treasuryAvailable = decimal_string(balance("treasury", market_id), Config.Crypto.AssetScale),
priceHistory = price_history,
sparkline = sparkline,
- updatedAt = (tonumber(row.updated_at) or os.time()) * 1000,
+ updatedAt = (tonumber(row.updated_at) or timestamp) * 1000,
}
end
end
@@ -1627,6 +1664,10 @@ local function start_crypto_schedulers()
)
advance_global_market_cycle()
local changed_markets = {}
+ local history_limit = math.min(
+ Config.Crypto.HistoryRetentionTicks,
+ Config.Crypto.SparklinePoints
+ )
market_count = math.min(market_count, #market_order)
for offset = 0, market_count - 1 do
@@ -1709,14 +1750,11 @@ local function start_crypto_schedulers()
row.updated_at = updated_at
row.dirty = true
local history = market_history[market_id]
- history[#history + 1] = {
- price = next_price,
- version = next_version,
- created_at = updated_at,
- }
- if #history > Config.Crypto.HistoryRetentionTicks then
+ history[#history + 1] = next_price
+ if #history > history_limit then
table.remove(history, 1)
end
+ add_market_daily_price(market_daily_buckets[market_id], next_price, updated_at)
changed_markets[#changed_markets + 1] = market_id
end
end
From b0b8cfc550f16d08b2652411d4d8bef3b2de63f3 Mon Sep 17 00:00:00 2001
From: Alec Schitzkat
Date: Tue, 25 Aug 2026 21:27:14 +0200
Subject: [PATCH 3/7] BLD - Bump sky_phone version to 0.3.2
---
sky_phone/fxmanifest.lua | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/sky_phone/fxmanifest.lua b/sky_phone/fxmanifest.lua
index f0c755d..a754be1 100644
--- a/sky_phone/fxmanifest.lua
+++ b/sky_phone/fxmanifest.lua
@@ -6,7 +6,7 @@ use_experimental_fxv2_oal 'yes'
author 'Sky-Systems'
description 'Sky Phone'
-version '0.3.1'
+version '0.3.2'
provide 'lb-phone'
provide '17mov_Phone'
From a597e161ea6b8a770931a44a0244f912768f9996 Mon Sep 17 00:00:00 2001
From: "Leon.Schmidt" <159480018+leonw21342315@users.noreply.github.com>
Date: Wed, 26 Aug 2026 18:46:39 +0200
Subject: [PATCH 4/7] ADD - integrate TGIANN House (#47)
Authorize owner properties from the documented TGIANN schema and enrich entrance waypoints through the published client export. Keep unsupported key, lock, CCTV, and garage capabilities disabled.
---
README.md | 4 +-
.../src/housingProviders.contract.test.ts | 63 +++++++++
sky_phone/config/config.lua | 4 +-
.../source/bridge/client/housing/tgiann.lua | 73 +++++++++++
.../source/bridge/server/housing/tgiann.lua | 124 ++++++++++++++++++
sky_phone/source/shared/config_default.lua | 4 +-
6 files changed, 266 insertions(+), 6 deletions(-)
create mode 100644 frontend/src/housingProviders.contract.test.ts
create mode 100644 sky_phone/source/bridge/client/housing/tgiann.lua
create mode 100644 sky_phone/source/bridge/server/housing/tgiann.lua
diff --git a/README.md b/README.md
index 7606ef3..902ee91 100644
--- a/README.md
+++ b/README.md
@@ -89,7 +89,7 @@ Sky Phone is built to be the **free FiveM phone you can choose without accepting
| **Inventories** | ak47_inventory, codem-inventory, core_inventory, jaksam_inventory, jpr-inventory, lj-inventory, mf-inventory, one_inventory, origen_inventory, ox_inventory, ps-inventory, qb-inventory, qs-inventory, smx-inventory, tgiann-inventory, hex_4_inventory, and native ESX inventory |
| **Calls** | YACA, PMA Voice, SaltyChat |
| **Radio** | YACA, PMA Voice, SaltyChat |
-| **Housing** | RTX Housing, Quasar Housing, VMS Housing, RX Housing, NoLag Properties, SN Properties, ESX Property, qbx_properties |
+| **Housing** | RTX Housing, Quasar Housing, TGIANN House, VMS Housing, RX Housing, NoLag Properties, SN Properties, ESX Property, qbx_properties |
| **Garages** | Built-in/custom data and a broad set of popular garage providers configured through the bridge |
| **Custom app contracts** | Sky Phone, LB Phone, 17Movement, High Phone, Quasar Smartphone, YSeries |
| **Languages** | English, German |
@@ -650,7 +650,7 @@ Select the provider under `Config.Garage.System`. Vehicle images use the configu
### Housing
-Select `rtx`, `quasar`, `vms`, `rx`, `nolag`, `sn`, `esx_property`, or `qbx_properties` under `Config.Housing.System`. Automatic mode uses `Config.Housing.AutoPriority` and keeps the existing `esx_property` and `qbx_properties` defaults ahead of newly supported providers. Select a provider explicitly when multiple housing resources are running. Each bridge exposes only the capabilities supported by the documented provider API.
+Select `rtx`, `quasar`, `tgiann`, `vms`, `rx`, `nolag`, `sn`, `esx_property`, or `qbx_properties` under `Config.Housing.System`. Automatic mode uses `Config.Housing.AutoPriority` and keeps the existing `esx_property` and `qbx_properties` defaults ahead of newly supported providers. Select a provider explicitly when multiple housing resources are running. Each bridge exposes only the capabilities supported by the documented provider API. The TGIANN bridge expects `tgiann-core` to start before `tgiann-house`, lists server-authorized owner properties, and supports entrance waypoints; TGIANN does not publish stable external contracts for keyholders, lock controls, CCTV, or live garage status.
### Companies
diff --git a/frontend/src/housingProviders.contract.test.ts b/frontend/src/housingProviders.contract.test.ts
new file mode 100644
index 0000000..172b887
--- /dev/null
+++ b/frontend/src/housingProviders.contract.test.ts
@@ -0,0 +1,63 @@
+import { readFileSync } from 'node:fs'
+
+import { describe, expect, it } from 'vitest'
+
+const readResourceFile = (path: string) =>
+ readFileSync(
+ new URL(`../../sky_phone/${path}`, import.meta.url),
+ 'utf8',
+ ).replace(/\r\n/g, '\n')
+
+describe('housing provider contracts', () => {
+ it('registers TGIANN House with server-authorized owner properties', () => {
+ const adapter = readResourceFile('source/bridge/server/housing/tgiann.lua')
+
+ expect(adapter).toContain('local resource_name = "tgiann-house"')
+ expect(adapter).toContain('FROM `tgiann_house`')
+ expect(adapter).toContain('WHERE `owner` = ?')
+ expect(adapter).toContain('Bridge.Framework.GetIdentifier(source)')
+ expect(adapter).toContain(
+ 'Bridge.Housing.RegisterProvider(provider_name, {',
+ )
+ expect(adapter).toContain('return nil, "property_access_denied"')
+ expect(adapter).not.toContain('`houseKeys`')
+ expect(adapter).not.toContain('tgiann-house:getPlayerHouses')
+ })
+
+ it('uses the published client export for entrance waypoints only', () => {
+ const adapter = readResourceFile('source/bridge/client/housing/tgiann.lua')
+
+ expect(adapter).toContain(
+ 'return exports[resource_name]:getHouseData(house)',
+ )
+ expect(adapter).toContain(
+ 'Bridge.Normalize.Coordinates(house_data.doorCoord)',
+ )
+ expect(adapter).toContain(
+ 'Bridge.Housing.RegisterClientProvider(provider_name, {',
+ )
+ expect(adapter).toContain('SetNewWaypoint(')
+ expect(adapter).not.toContain('enterHouse(')
+ expect(adapter).not.toContain('forceOpenDoorHouse')
+ })
+
+ it('advertises TGIANN in housing configuration and documentation', () => {
+ const config = readResourceFile('config/config.lua')
+ const readme = readFileSync(
+ new URL('../../README.md', import.meta.url),
+ 'utf8',
+ )
+
+ expect(config).toContain(
+ '"rtx", "quasar", "tgiann", "vms", "rx", "nolag", "sn"',
+ )
+ expect(readme).toContain('Quasar Housing, TGIANN House, VMS Housing')
+ expect(readme).toContain('`quasar`, `tgiann`, `vms`')
+ expect(readme).toContain(
+ 'expects `tgiann-core` to start before `tgiann-house`',
+ )
+ expect(readme).toContain(
+ 'lists server-authorized owner properties, and supports entrance waypoints',
+ )
+ })
+})
diff --git a/sky_phone/config/config.lua b/sky_phone/config/config.lua
index 3b1c588..6035ab0 100644
--- a/sky_phone/config/config.lua
+++ b/sky_phone/config/config.lua
@@ -450,8 +450,8 @@ Config.Garage = {
}
Config.Housing = {
- System = "auto", -- auto, rtx, quasar, vms, rx, nolag, sn, esx_property, qbx_properties
- AutoPriority = { "esx_property", "qbx_properties", "rtx", "quasar", "vms", "rx", "nolag", "sn" },
+ System = "auto", -- auto, rtx, quasar, tgiann, vms, rx, nolag, sn, esx_property, qbx_properties
+ AutoPriority = { "esx_property", "qbx_properties", "rtx", "quasar", "tgiann", "vms", "rx", "nolag", "sn" },
MaximumProperties = 50,
OverviewRequestsPerMinute = 30,
ActionsPerMinute = 12,
diff --git a/sky_phone/source/bridge/client/housing/tgiann.lua b/sky_phone/source/bridge/client/housing/tgiann.lua
new file mode 100644
index 0000000..c4e6537
--- /dev/null
+++ b/sky_phone/source/bridge/client/housing/tgiann.lua
@@ -0,0 +1,73 @@
+local provider_name = "tgiann"
+local resource_name = "tgiann-house"
+
+local function house_reference(value)
+ if type(value) ~= "string" then
+ return nil
+ end
+ return value ~= "" and value or nil
+end
+
+local function house_entrance(house)
+ local success, house_data = pcall(function()
+ return exports[resource_name]:getHouseData(house)
+ end)
+ if not success then
+ Bridge.Debug(
+ "error",
+ "[sky_phone] tgiann-house:getHouseData failed for '%s': %s",
+ house,
+ tostring(house_data)
+ )
+ return nil, "provider_error"
+ end
+ local entrance = type(house_data) == "table"
+ and Bridge.Normalize.Coordinates(house_data.doorCoord) or nil
+ if not entrance then
+ return nil, "invalid_coordinates"
+ end
+ return entrance
+end
+
+local function enrich_overview(properties)
+ local result = {}
+ if GetResourceState(resource_name) ~= "started" or type(properties) ~= "table" then
+ return result
+ end
+
+ for _, property in ipairs(properties) do
+ local house = type(property) == "table" and house_reference(property.providerId) or nil
+ if house and property.id == provider_name .. ":" .. house then
+ local entrance = house_entrance(house)
+ if entrance then
+ result[#result + 1] = {
+ id = property.id,
+ entrance = entrance,
+ }
+ end
+ end
+ end
+ return result
+end
+
+Bridge.Housing.RegisterClientProvider(provider_name, {
+ enrich_overview = enrich_overview,
+ execute = function(action, data)
+ if GetResourceState(resource_name) ~= "started" then
+ return false, "provider_unavailable"
+ end
+ if action ~= "set_waypoint" then
+ return false, "capability_unavailable"
+ end
+ local house = type(data) == "table" and house_reference(data.providerId) or nil
+ if not house then
+ return false, "invalid_property"
+ end
+ local entrance, error_code = house_entrance(house)
+ if not entrance then
+ return false, error_code
+ end
+ SetNewWaypoint(entrance.x + 0.0, entrance.y + 0.0)
+ return true
+ end,
+})
diff --git a/sky_phone/source/bridge/server/housing/tgiann.lua b/sky_phone/source/bridge/server/housing/tgiann.lua
new file mode 100644
index 0000000..b0bdb17
--- /dev/null
+++ b/sky_phone/source/bridge/server/housing/tgiann.lua
@@ -0,0 +1,124 @@
+local provider_name = "tgiann"
+local resource_name = "tgiann-house"
+
+local function query_owned_houses(identifier)
+ local success, properties = pcall(function()
+ return MySQL.query.await([[
+ SELECT `name`
+ FROM `tgiann_house`
+ WHERE `owner` = ?
+ ORDER BY `name` ASC
+ ]], { identifier })
+ end)
+ if not success or type(properties) ~= "table" then
+ Bridge.Debug("error", "[sky_phone] tgiann-house overview query failed: %s", tostring(properties))
+ return nil
+ end
+ return properties
+end
+
+local function owns_house(identifier, house)
+ local success, property = pcall(function()
+ return MySQL.single.await([[
+ SELECT `name`
+ FROM `tgiann_house`
+ WHERE `name` = ? AND `owner` = ?
+ LIMIT 1
+ ]], { house, identifier })
+ end)
+ if not success then
+ Bridge.Debug("error", "[sky_phone] tgiann-house ownership query failed: %s", tostring(property))
+ return nil, "provider_error"
+ end
+ return type(property) == "table"
+end
+
+local function house_from_property_id(value)
+ if type(value) ~= "string" then
+ return nil
+ end
+ local house = value:match("^tgiann:(.+)$")
+ return house and house ~= "" and house or nil
+end
+
+local function normalized_property(house)
+ return {
+ id = provider_name .. ":" .. house,
+ providerId = house,
+ name = house,
+ access = "owner",
+ locked = false,
+ capabilities = {
+ lock = false,
+ keys = false,
+ waypoint = true,
+ cctv = false,
+ garageStatus = false,
+ },
+ cctv = { enabled = false },
+ garage = nil,
+ keys = nil,
+ }
+end
+
+Bridge.Housing.RegisterProvider(provider_name, {
+ resource_name = resource_name,
+ is_available = function()
+ return GetResourceState(resource_name) == "started"
+ end,
+ get_overview = function(source)
+ local identifier = Bridge.Framework.GetIdentifier(source)
+ if not identifier then
+ return nil, "housing_unavailable"
+ end
+ local properties = query_owned_houses(identifier)
+ if not properties then
+ return nil, "provider_error"
+ end
+
+ local result = {}
+ local seen = {}
+ local maximum = math.max(0, math.floor(tonumber(Config.Housing.MaximumProperties) or 0))
+ for _, property in ipairs(properties) do
+ if #result >= maximum then
+ break
+ end
+ local house = type(property.name) == "string" and property.name or nil
+ if house and house ~= "" and not seen[house] then
+ seen[house] = true
+ result[#result + 1] = normalized_property(house)
+ end
+ end
+ return result
+ end,
+ prepare = function(source, action, data)
+ if action == "toggle_lock" or action == "grant_key" or action == "revoke_key"
+ or action == "key_candidates"
+ then
+ return nil, "capability_unavailable"
+ end
+ if action == "open_cctv" then
+ return nil, "cctv_unavailable"
+ end
+ if action ~= "set_waypoint" then
+ return nil, "invalid_action"
+ end
+
+ local house = house_from_property_id(data and data.propertyId)
+ if not house then
+ return nil, "invalid_property"
+ end
+ local identifier = Bridge.Framework.GetIdentifier(source)
+ if not identifier then
+ return nil, "housing_unavailable"
+ end
+ local owned, error_code = owns_house(identifier, house)
+ if owned == nil then
+ return nil, error_code
+ end
+ if not owned then
+ return nil, "property_access_denied"
+ end
+ return { providerId = house }
+ end,
+})
diff --git a/sky_phone/source/shared/config_default.lua b/sky_phone/source/shared/config_default.lua
index bfa55c2..7f833ab 100644
--- a/sky_phone/source/shared/config_default.lua
+++ b/sky_phone/source/shared/config_default.lua
@@ -435,8 +435,8 @@ Config.Garage = {
}
Config.Housing = {
- System = "auto", -- auto, rtx, quasar, vms, rx, nolag, sn, esx_property, qbx_properties
- AutoPriority = { "esx_property", "qbx_properties", "rtx", "quasar", "vms", "rx", "nolag", "sn" },
+ System = "auto", -- auto, rtx, quasar, tgiann, vms, rx, nolag, sn, esx_property, qbx_properties
+ AutoPriority = { "esx_property", "qbx_properties", "rtx", "quasar", "tgiann", "vms", "rx", "nolag", "sn" },
MaximumProperties = 50,
OverviewRequestsPerMinute = 30,
ActionsPerMinute = 12,
From 693c9bd6f9fb8e8d911c9beaa1985859755591b4 Mon Sep 17 00:00:00 2001
From: "Leon.Schmidt" <159480018+leonw21342315@users.noreply.github.com>
Date: Wed, 26 Aug 2026 18:46:51 +0200
Subject: [PATCH 5/7] FIX - allow configurable radio channel jobs (#46)
Mark locked-channel job maps mutable in the configurator schema and initialize new structured list rows from their schema template so required frequency ranges remain valid.
---
.../src/components/AdminConfigValueEditor.vue | 8 ++---
.../components/AdminPanel.contract.test.ts | 3 ++
.../utils/adminConfiguratorDefaults.test.ts | 34 ++++++++++++++++++-
frontend/testserver/configurator-fixture.cjs | 19 +++++++++++
.../testserver/configurator-fixture.test.ts | 23 +++++++++++++
.../source/server/phone_configurator.lua | 15 ++++++++
6 files changed, 97 insertions(+), 5 deletions(-)
diff --git a/frontend/src/components/AdminConfigValueEditor.vue b/frontend/src/components/AdminConfigValueEditor.vue
index 5af001a..4c5e2a0 100644
--- a/frontend/src/components/AdminConfigValueEditor.vue
+++ b/frontend/src/components/AdminConfigValueEditor.vue
@@ -380,10 +380,10 @@ function toggleOptionalString(event: Event): void {
function addListRow(): void {
const rows = Array.isArray(props.modelValue) ? [...props.modelValue] : []
const index = rows.length
- const value = rows.length
- ? blankLike(rows[0])
- : listTemplate.value
- ? blankFromConfiguratorStructure(listTemplate.value)
+ const value = listTemplate.value
+ ? blankFromConfiguratorStructure(listTemplate.value)
+ : rows.length
+ ? blankLike(rows[0])
: blankValue(newArrayKind.value)
rows.push(value)
emit('update:modelValue', rows)
diff --git a/frontend/src/components/AdminPanel.contract.test.ts b/frontend/src/components/AdminPanel.contract.test.ts
index 4d3bbcf..07f4463 100644
--- a/frontend/src/components/AdminPanel.contract.test.ts
+++ b/frontend/src/components/AdminPanel.contract.test.ts
@@ -440,6 +440,9 @@ describe('standalone admin panel contracts', () => {
expect(source).toContain("selectConfiguratorScope('media')")
expect(source).not.toContain('class="admin-panel-config-meta"')
expect(configuratorValueEditor).toContain('function addListRow()')
+ expect(configuratorValueEditor).toMatch(
+ /const value = listTemplate\.value\s+\? blankFromConfiguratorStructure\(listTemplate\.value\)\s+: rows\.length\s+\? blankLike\(rows\[0\]\)/,
+ )
expect(configuratorValueEditor).toContain('function addTableField()')
expect(configuratorValueEditor).toContain(
'const canExtendTable = computed(',
diff --git a/frontend/src/utils/adminConfiguratorDefaults.test.ts b/frontend/src/utils/adminConfiguratorDefaults.test.ts
index fc3e2c9..c2824be 100644
--- a/frontend/src/utils/adminConfiguratorDefaults.test.ts
+++ b/frontend/src/utils/adminConfiguratorDefaults.test.ts
@@ -1,7 +1,10 @@
import { describe, expect, it } from 'vitest'
import type { AdminConfiguratorStructure } from '@/types/admin'
-import { createMutableTableEntry } from '@/utils/adminConfiguratorDefaults'
+import {
+ blankFromConfiguratorStructure,
+ createMutableTableEntry,
+} from '@/utils/adminConfiguratorDefaults'
describe('admin configurator defaults', () => {
it('creates a usable company draft from its key and the server defaults', () => {
@@ -111,4 +114,33 @@ describe('admin configurator defaults', () => {
false,
)
})
+
+ it('preserves required nested list fields in schema-derived rows', () => {
+ const structure: AdminConfiguratorStructure = {
+ fields: {
+ jobs: {
+ fields: {
+ police: { kind: 'value', valueType: 'boolean' },
+ },
+ kind: 'table',
+ mutableKeys: true,
+ template: { kind: 'value', valueType: 'boolean' },
+ },
+ range: {
+ items: [
+ { kind: 'value', valueType: 'number' },
+ { kind: 'value', valueType: 'number' },
+ ],
+ kind: 'list',
+ template: { kind: 'value', valueType: 'number' },
+ },
+ },
+ kind: 'table',
+ }
+
+ expect(blankFromConfiguratorStructure(structure)).toEqual({
+ jobs: { police: false },
+ range: [0, 0],
+ })
+ })
})
diff --git a/frontend/testserver/configurator-fixture.cjs b/frontend/testserver/configurator-fixture.cjs
index 6d02ee5..3971201 100644
--- a/frontend/testserver/configurator-fixture.cjs
+++ b/frontend/testserver/configurator-fixture.cjs
@@ -540,6 +540,25 @@ function buildStructure(value, scope, path) {
if (scope === 'config' && path === 'Phone.Keybind') {
return { kind: 'optionalString' }
}
+ if (
+ scope === 'config' &&
+ /^Radio\.LockedChannels\.\d+\.jobs$/.test(path) &&
+ value !== null &&
+ typeof value === 'object' &&
+ !Array.isArray(value)
+ ) {
+ return {
+ fields: Object.fromEntries(
+ Object.entries(value).map(([key, child]) => [
+ key,
+ buildStructure(child, scope, `${path}.${key}`),
+ ]),
+ ),
+ kind: 'table',
+ mutableKeys: true,
+ template: { kind: 'value', valueType: 'boolean' },
+ }
+ }
if (
scope === 'config' &&
path === 'Companies.Definitions' &&
diff --git a/frontend/testserver/configurator-fixture.test.ts b/frontend/testserver/configurator-fixture.test.ts
index e5deea8..142e228 100644
--- a/frontend/testserver/configurator-fixture.test.ts
+++ b/frontend/testserver/configurator-fixture.test.ts
@@ -193,6 +193,29 @@ describe('admin configurator fixture', () => {
})
})
+ it('allows custom jobs in locked radio channel entries', () => {
+ const radio = loadConfiguratorSections()
+ .flatMap((section) => section.fields)
+ .find((field) => field.path === 'Radio')
+ const lockedChannels = radio?.structure?.fields?.LockedChannels
+ const jobs = lockedChannels?.items?.[0]?.fields?.jobs
+
+ expect(jobs).toMatchObject({
+ fields: {
+ ambulance: { kind: 'value', valueType: 'boolean' },
+ police: { kind: 'value', valueType: 'boolean' },
+ },
+ kind: 'table',
+ mutableKeys: true,
+ template: { kind: 'value', valueType: 'boolean' },
+ })
+ expect(lockedChannels?.template?.fields?.jobs).toMatchObject({
+ kind: 'table',
+ mutableKeys: true,
+ template: { kind: 'value', valueType: 'boolean' },
+ })
+ })
+
it('publishes fixed schemas for every empty configurable collection', () => {
const fields = loadConfiguratorSections().flatMap(
(section) => section.fields,
diff --git a/sky_phone/source/server/phone_configurator.lua b/sky_phone/source/server/phone_configurator.lua
index 8b009d9..e6f86d7 100644
--- a/sky_phone/source/server/phone_configurator.lua
+++ b/sky_phone/source/server/phone_configurator.lua
@@ -637,6 +637,21 @@ local function build_structure(value, scope, path)
if scope == "config" and path == "Phone.Keybind" then
return { kind = "optionalString" }
end
+ if scope == "config"
+ and path:match("^Radio%.LockedChannels%.%d+%.jobs$")
+ and value_type == "table"
+ then
+ local fields = {}
+ for key, child in pairs(value) do
+ fields[key] = build_structure(child, scope, path .. "." .. tostring(key))
+ end
+ return {
+ fields = fields,
+ kind = "table",
+ mutableKeys = true,
+ template = { kind = "value", valueType = "boolean" },
+ }
+ end
if scope == "config" and path == "Companies.Definitions" and value_type == "table" then
local keys = {}
for key in pairs(value) do
From efcc67334f8344cfd2e26098f2911b128b919f52 Mon Sep 17 00:00:00 2001
From: Alec Schitzkat
Date: Wed, 26 Aug 2026 18:47:33 +0200
Subject: [PATCH 6/7] BLD - Bump sky_phone version to 0.3.3
---
sky_phone/fxmanifest.lua | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/sky_phone/fxmanifest.lua b/sky_phone/fxmanifest.lua
index a754be1..c6c9587 100644
--- a/sky_phone/fxmanifest.lua
+++ b/sky_phone/fxmanifest.lua
@@ -6,7 +6,7 @@ use_experimental_fxv2_oal 'yes'
author 'Sky-Systems'
description 'Sky Phone'
-version '0.3.2'
+version '0.3.3'
provide 'lb-phone'
provide '17mov_Phone'
From 8efaa9f03b1038a91f10c22c7cd0fe416b1b8692 Mon Sep 17 00:00:00 2001
From: Dominik9906
Date: Mon, 31 Aug 2026 15:43:18 +0200
Subject: [PATCH 7/7] Feat/custom phone tones yaca (#49)
* ADD - manage custom phone tones
* ADD - provide phone database uninstall script
* FIX - support legacy YACA availability
* BLD - sync generated phone defaults
---
frontend/src/App.vue | 4 +
.../src/components/AdminCustomToneManager.vue | 809 ++++++++++++++++++
.../components/AdminPanel.contract.test.ts | 1 +
frontend/src/components/AdminPanel.vue | 133 ++-
frontend/src/customTones.contract.test.ts | 117 +++
frontend/src/permissions.contract.test.ts | 8 +-
frontend/src/stores/admin.ts | 101 +++
frontend/src/stores/calls.ts | 29 +-
frontend/src/stores/notifications.ts | 30 +-
frontend/src/stores/phone.ts | 85 ++
frontend/src/types/admin.ts | 20 +
frontend/src/utils/customAppBridge.ts | 10 +-
frontend/src/utils/customTones.test.ts | 156 ++++
frontend/src/utils/customTones.ts | 240 ++++++
frontend/src/utils/phoneAudio.ts | 6 +
frontend/src/utils/preferences.test.ts | 32 +
frontend/src/utils/preferences.ts | 43 +-
frontend/src/utils/tones.test.ts | 56 +-
frontend/src/utils/tones.ts | 51 +-
frontend/src/views/apps/SettingsApp.vue | 47 +-
frontend/src/voiceProviders.contract.test.ts | 15 +
frontend/testserver/configurator-fixture.cjs | 1 +
.../testserver/configurator-fixture.test.ts | 3 +-
frontend/testserver/index.cjs | 91 ++
sky_phone/config/config.lua | 27 +
sky_phone/config/custom_tones/README.md | 7 +
sky_phone/config/locales/de.lua | 47 +-
sky_phone/config/locales/en.lua | 47 +-
sky_phone/config/locales/es.lua | 47 +-
sky_phone/fxmanifest.lua | 2 +
sky_phone/source/bridge/server/voice.lua | 37 +-
sky_phone/source/client/custom_tones.lua | 76 ++
sky_phone/source/client/main.lua | 18 +
sky_phone/source/client/nui_server_bridge.lua | 2 +
sky_phone/source/server/admin.lua | 110 +++
sky_phone/source/server/custom_tones.lua | 698 +++++++++++++++
sky_phone/source/server/db_migrate.lua | 23 +
.../source/server/phone_configurator.lua | 11 +-
sky_phone/source/shared/config_default.lua | 1 +
sky_phone/sql/install.sql | 16 +
sky_phone/sql/uninstall.sql | 145 ++++
41 files changed, 3342 insertions(+), 60 deletions(-)
create mode 100644 frontend/src/components/AdminCustomToneManager.vue
create mode 100644 frontend/src/customTones.contract.test.ts
create mode 100644 frontend/src/utils/customTones.test.ts
create mode 100644 frontend/src/utils/customTones.ts
create mode 100644 sky_phone/config/custom_tones/README.md
create mode 100644 sky_phone/source/client/custom_tones.lua
create mode 100644 sky_phone/source/server/custom_tones.lua
create mode 100644 sky_phone/sql/uninstall.sql
diff --git a/frontend/src/App.vue b/frontend/src/App.vue
index 73f4c88..f9f619e 100644
--- a/frontend/src/App.vue
+++ b/frontend/src/App.vue
@@ -87,6 +87,7 @@ import { getHairlinePixelStyle } from '@/utils/rendering'
import { isTextInputElement } from '@/utils/textInputFocus'
import { configurePhoneNumberFormat } from '@/utils/phone'
import { consumeEscape } from '@/utils/keyboard'
+import type { CustomPhoneToneCatalog } from '@/utils/customTones'
import { isTrustedRootMessageSource } from '@/utils/windowMessages'
import SpringboardView from '@/views/SpringboardView.vue'
@@ -118,6 +119,7 @@ type AppMessage = {
| CustomAppEventData
| NavigationEventData
| AdminPanelOpenPayload
+ | CustomPhoneToneCatalog
}
type AdminPanelOpenPayload = Required<
@@ -750,6 +752,8 @@ function onMessage(event: MessageEvent): void {
adminPanelOpen.value = true
} else if (event.data?.type === 'admin:close') {
adminPanelOpen.value = false
+ } else if (event.data?.type === 'phone:tones') {
+ phone.setCustomTones(event.data.data)
} else if (event.data?.type === 'custom-apps:catalog') {
appCatalog.replaceCatalog(event.data.data)
const catalogPayload = event.data.data as
diff --git a/frontend/src/components/AdminCustomToneManager.vue b/frontend/src/components/AdminCustomToneManager.vue
new file mode 100644
index 0000000..2cc9eb4
--- /dev/null
+++ b/frontend/src/components/AdminCustomToneManager.vue
@@ -0,0 +1,809 @@
+
+
+
+
+
+
+
+
{{ t('configTitle') }}
+
{{ t('configBody') }}
+
Config.CustomTones · config/custom_tones/
+
+
+
+
+
+
+
+ {{ t('loading') }}
+
+
+
+
+ {{ group.label }}
+ {{ group.tones.length }}/32
+
+
+ {{ t('empty') }}
+
+
+
+
+ {{ tone.label }}
+
+ {{ formatDuration(tone.durationMs) }} ·
+ {{ formatBytes(tone.byteSize) }} ·
+ {{
+ tone.source === 'config' ? t('configSource') : tone.createdBy
+ }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/AdminPanel.contract.test.ts b/frontend/src/components/AdminPanel.contract.test.ts
index 07f4463..e48f36d 100644
--- a/frontend/src/components/AdminPanel.contract.test.ts
+++ b/frontend/src/components/AdminPanel.contract.test.ts
@@ -144,6 +144,7 @@ describe('standalone admin panel contracts', () => {
'messages',
'calls',
'moderation',
+ 'tones',
'audit',
'configurator',
]) {
diff --git a/frontend/src/components/AdminPanel.vue b/frontend/src/components/AdminPanel.vue
index 8f1e65d..df1d70f 100644
--- a/frontend/src/components/AdminPanel.vue
+++ b/frontend/src/components/AdminPanel.vue
@@ -2,6 +2,7 @@
import {
ChartNoAxesCombined,
BadgeDollarSign,
+ BellRing,
BriefcaseBusiness,
Check,
ChevronRight,
@@ -65,6 +66,7 @@ import { nuiCall } from '@/utils/nui'
import AdminConfigValueEditor, {
type AdminConfigEditorLabels,
} from './AdminConfigValueEditor.vue'
+import AdminCustomToneManager from './AdminCustomToneManager.vue'
type AdminTab =
| 'overview'
@@ -75,6 +77,7 @@ type AdminTab =
| 'messages'
| 'calls'
| 'moderation'
+ | 'tones'
| 'audit'
| 'configurator'
type ConfiguratorScope = 'config' | 'media'
@@ -538,8 +541,15 @@ function selectTab(nextTab: AdminTab): void {
if (nextTab === 'overview' && admin.initialized && !admin.loading) {
void admin.load()
}
- if (nextTab === 'configurator' && !admin.configurator) {
- void admin.loadConfigurator().then((loaded) => {
+ if (nextTab === 'configurator') {
+ void (
+ admin.configurator ? Promise.resolve(true) : admin.loadConfigurator()
+ ).then((loaded) => {
+ if (!loaded) showToast(errorText(), 'error')
+ })
+ }
+ if (nextTab === 'tones') {
+ void admin.loadCustomTones().then((loaded) => {
if (!loaded) showToast(errorText(), 'error')
})
}
@@ -863,7 +873,9 @@ onBeforeUnmount(() => {
{{
tab === 'configurator'
? t('configurator.context')
- : admin.selectedPlayer?.name || t('editor.noSelection')
+ : tab === 'tones'
+ ? t('configurator.customTones.context')
+ : admin.selectedPlayer?.name || t('editor.noSelection')
}}
@@ -969,6 +981,15 @@ onBeforeUnmount(() => {
>
+
+
+
+
+
+
+
+
+ {{ t('configurator.customTones.ringtones') }}
+ {{
+ admin.customTones.filter(
+ (tone) => tone.toneType === 'ringtone',
+ ).length
+ }}/32
+
+
+
+
+
+ {{
+ t('configurator.customTones.notifications')
+ }}
+ {{
+ admin.customTones.filter(
+ (tone) => tone.toneType === 'notification',
+ ).length
+ }}/32
+
+
+
+
+