mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 01:08:59 +00:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 42999d462b | |||
| 1bf44dd917 | |||
| d199097e43 | |||
| 804da7e984 | |||
| 2388ba9b4e | |||
| c6394fda66 | |||
| 4516469c33 |
+8
-10
@@ -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<void> {
|
||||
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 {
|
||||
|
||||
@@ -70,6 +70,43 @@ 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('replaces rectangular CEF focus outlines on the side hardware controls', () => {
|
||||
expect(mainCss).toMatch(
|
||||
/\.phone-hardware-button:focus\s*\{[^}]*outline:\s*none;/s,
|
||||
)
|
||||
expect(mainCss).toMatch(
|
||||
/\.phone-hardware-button:focus-visible::after\s*\{[^}]*width:\s*3px;[^}]*height:\s*24px;[^}]*border-radius:\s*999px;[^}]*background:\s*#0a84ff;/s,
|
||||
)
|
||||
expect(mainCss).not.toMatch(
|
||||
/\.phone-hardware-button:focus-visible\s*\{[^}]*outline:/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)"')
|
||||
|
||||
@@ -380,9 +380,27 @@ button {
|
||||
.phone-hardware-button:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
.phone-hardware-button:focus-visible {
|
||||
outline: 2px solid #fff;
|
||||
outline-offset: -6px;
|
||||
.phone-hardware-button:focus {
|
||||
outline: none;
|
||||
}
|
||||
.phone-hardware-button:focus-visible::after {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 3px;
|
||||
height: 24px;
|
||||
border-radius: 999px;
|
||||
background: #0a84ff;
|
||||
box-shadow: 0 0 0 1px rgb(255 255 255 / 90%);
|
||||
content: '';
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
.phone-hardware-button--action:focus-visible::after,
|
||||
.phone-hardware-button--volume-up:focus-visible::after,
|
||||
.phone-hardware-button--volume-down:focus-visible::after {
|
||||
right: 7px;
|
||||
}
|
||||
.phone-hardware-button--power:focus-visible::after {
|
||||
left: 7px;
|
||||
}
|
||||
.phone-hardware-button--action {
|
||||
top: 176px;
|
||||
@@ -460,6 +478,7 @@ button {
|
||||
}
|
||||
}
|
||||
.phone-screen {
|
||||
--phone-screen-radius: 40px;
|
||||
--phone-screen-portrait-ratio: 2.30951;
|
||||
position: relative;
|
||||
container-type: size;
|
||||
@@ -469,7 +488,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 +876,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;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import type { AdminConfiguratorDescribe } from '@/utils/adminConfiguratorDescrip
|
||||
|
||||
export type AdminConfigEditorLabels = {
|
||||
addField: string
|
||||
addJob: string
|
||||
addRow: string
|
||||
configuredSecret: string
|
||||
convertToList: string
|
||||
@@ -27,6 +28,7 @@ export type AdminConfigEditorLabels = {
|
||||
emptyTable: string
|
||||
entry: string
|
||||
general: string
|
||||
jobPlaceholder: string
|
||||
keyPlaceholder: string
|
||||
list: string
|
||||
remove: string
|
||||
@@ -149,6 +151,11 @@ const canExtendTable = computed(
|
||||
!vectorType.value &&
|
||||
(!tableStructure.value || tableStructure.value.mutableKeys === true),
|
||||
)
|
||||
const isJobTable = computed(
|
||||
() =>
|
||||
props.path === 'Radio.DisplayName.AllowedJobs' ||
|
||||
/^Radio\.LockedChannels\[\d+\]\.jobs$/.test(props.path),
|
||||
)
|
||||
const usesFixedTableLayout = computed(
|
||||
() =>
|
||||
Boolean(tableStructure.value) && tableStructure.value?.mutableKeys !== true,
|
||||
@@ -371,6 +378,19 @@ function updateOptionalString(event: Event): void {
|
||||
}
|
||||
}
|
||||
|
||||
function updateNewObjectKey(event: Event): void {
|
||||
const target = event.target
|
||||
if (!(target instanceof HTMLInputElement)) return
|
||||
const value = isJobTable.value
|
||||
? target.value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_-]/g, '')
|
||||
.slice(0, 64)
|
||||
: target.value
|
||||
newObjectKey.value = value
|
||||
target.value = value
|
||||
}
|
||||
|
||||
function toggleOptionalString(event: Event): void {
|
||||
const target = event.target
|
||||
if (!(target instanceof HTMLInputElement)) return
|
||||
@@ -1015,11 +1035,15 @@ function mapEntryStructure(
|
||||
@submit.prevent="addTableField"
|
||||
>
|
||||
<input
|
||||
v-model="newObjectKey"
|
||||
:value="newObjectKey"
|
||||
type="text"
|
||||
:disabled="disabled"
|
||||
:placeholder="labels.keyPlaceholder"
|
||||
:aria-label="labels.keyPlaceholder"
|
||||
:placeholder="
|
||||
isJobTable ? labels.jobPlaceholder : labels.keyPlaceholder
|
||||
"
|
||||
:aria-label="isJobTable ? labels.jobPlaceholder : labels.keyPlaceholder"
|
||||
autocomplete="off"
|
||||
@input="updateNewObjectKey"
|
||||
/>
|
||||
<select
|
||||
v-if="!tableStructure"
|
||||
@@ -1039,7 +1063,7 @@ function mapEntryStructure(
|
||||
{{ structureTypeLabel(tableStructure.template) }}
|
||||
</span>
|
||||
<button type="submit" :disabled="disabled || !canAddTableField">
|
||||
<Plus :size="13" />{{ labels.addField }}
|
||||
<Plus :size="13" />{{ isJobTable ? labels.addJob : labels.addField }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -400,6 +400,7 @@ function configuratorDescription(
|
||||
|
||||
const configuratorEditorLabels = computed<AdminConfigEditorLabels>(() => ({
|
||||
addField: t('configurator.table.addField'),
|
||||
addJob: t('configurator.table.addJob'),
|
||||
addRow: t('configurator.table.addRow'),
|
||||
configuredSecret: t('configurator.secretConfigured'),
|
||||
convertToList: t('configurator.table.convertToList'),
|
||||
@@ -409,6 +410,7 @@ const configuratorEditorLabels = computed<AdminConfigEditorLabels>(() => ({
|
||||
emptyTable: t('configurator.table.emptyTable'),
|
||||
entry: t('configurator.table.entry'),
|
||||
general: configuratorLocaleText('configurator.table.general', 'General'),
|
||||
jobPlaceholder: t('configurator.table.jobPlaceholder'),
|
||||
keyPlaceholder: t('configurator.table.keyPlaceholder'),
|
||||
list: t('configurator.table.list'),
|
||||
remove: t('configurator.table.remove'),
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -67,7 +67,7 @@ const sharePeople = computed(() => {
|
||||
}> = []
|
||||
const phoneNumbers = new Set<string>()
|
||||
|
||||
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
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const resourceSource = (path: string): string =>
|
||||
readFileSync(new URL(`../../sky_phone/${path}`, import.meta.url), 'utf8')
|
||||
|
||||
const configSource = resourceSource('config/config.lua')
|
||||
const configuratorSource = resourceSource(
|
||||
'source/server/phone_configurator.lua',
|
||||
)
|
||||
const radioSource = resourceSource('source/server/radio.lua')
|
||||
const editorSource = readFileSync(
|
||||
new URL('./components/AdminConfigValueEditor.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
describe('radio configurator access contract', () => {
|
||||
it('lets the server allow everyone or enforce configured job grades', () => {
|
||||
expect(configSource).toMatch(
|
||||
/DisplayName\s*=\s*\{[\s\S]*?AllowEveryone\s*=\s*false,[\s\S]*?AllowedJobs\s*=\s*\{/,
|
||||
)
|
||||
|
||||
const start = radioSource.indexOf(
|
||||
'local function can_set_display_name(source)',
|
||||
)
|
||||
const end = radioSource.indexOf(
|
||||
'\nlocal function normalize_display_name',
|
||||
start,
|
||||
)
|
||||
const permissionSource = radioSource.slice(start, end)
|
||||
|
||||
expect(permissionSource).toContain('if config.AllowEveryone == true then')
|
||||
expect(permissionSource.indexOf('config.AllowEveryone')).toBeLessThan(
|
||||
permissionSource.indexOf('Bridge.Framework.GetJob(source)'),
|
||||
)
|
||||
expect(permissionSource).toContain('config.AllowedJobs[job.name]')
|
||||
expect(permissionSource).toContain('(tonumber(job.grade) or 0)')
|
||||
})
|
||||
|
||||
it('keeps both radio job tables mutable and validates their value types', () => {
|
||||
expect(configuratorSource).toContain(
|
||||
'path == "Radio.DisplayName.AllowedJobs"',
|
||||
)
|
||||
expect(configuratorSource).toContain(
|
||||
'path:match("^Radio%.LockedChannels%.%d+%.jobs$")',
|
||||
)
|
||||
expect(configuratorSource).toContain(
|
||||
'path == "Companies.Definitions" or radio_job_entry_default(path) ~= nil',
|
||||
)
|
||||
expect(configuratorSource).toMatch(
|
||||
/entryDefault = radio_job_default,[\s\S]*?mutableKeys = true,[\s\S]*?valueType = type\(radio_job_default\)/,
|
||||
)
|
||||
expect(configuratorSource).toContain('not key:match("^[a-z0-9_-]+$")')
|
||||
})
|
||||
|
||||
it('shows a compact job-name input for both radio job tables', () => {
|
||||
expect(editorSource).toContain(
|
||||
"props.path === 'Radio.DisplayName.AllowedJobs'",
|
||||
)
|
||||
expect(editorSource).toContain(
|
||||
String.raw`/^Radio\.LockedChannels\[\d+\]\.jobs$/`,
|
||||
)
|
||||
expect(editorSource).toContain(
|
||||
'isJobTable ? labels.jobPlaceholder : labels.keyPlaceholder',
|
||||
)
|
||||
expect(editorSource).toContain(
|
||||
'isJobTable ? labels.addJob : labels.addField',
|
||||
)
|
||||
expect(editorSource).toContain('function updateNewObjectKey(event: Event)')
|
||||
expect(editorSource).toContain('.toLowerCase()')
|
||||
expect(editorSource).toContain(".replace(/[^a-z0-9_-]/g, '')")
|
||||
expect(editorSource).toContain('.slice(0, 64)')
|
||||
|
||||
for (const locale of ['en', 'de', 'es']) {
|
||||
const localeSource = resourceSource(`config/locales/${locale}.lua`)
|
||||
expect(localeSource).toContain(
|
||||
`Locales["${locale}"].Nui.AdminPanel.configurator.table.addJob`,
|
||||
)
|
||||
expect(localeSource).toContain(
|
||||
`Locales["${locale}"].Nui.AdminPanel.configurator.table.jobPlaceholder`,
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -965,10 +965,12 @@ const adminPanelFallbackLocales = {
|
||||
},
|
||||
addRow: 'Add row',
|
||||
addField: 'Add field',
|
||||
addJob: 'Add job',
|
||||
remove: 'Remove',
|
||||
emptyList: 'No rows yet. Add the first row with plus.',
|
||||
emptyTable: 'No fields yet. Add the first key below.',
|
||||
keyPlaceholder: 'New key',
|
||||
jobPlaceholder: 'Job name',
|
||||
convertToList: 'Use list',
|
||||
convertToMap: 'Use typed key table',
|
||||
convertToTable: 'Use key table',
|
||||
@@ -3027,6 +3029,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',
|
||||
|
||||
@@ -1265,8 +1265,9 @@ label.sky-list-item__row {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.sky-field--floating-label .sky-field__select option {
|
||||
color: var(--sky-text, #000000);
|
||||
.sky-field__select option {
|
||||
background: var(--sky-native-select-option-background, #ffffff);
|
||||
color: var(--sky-native-select-option-text, #000000);
|
||||
}
|
||||
|
||||
.sky-field--floating-label.sky-field--outline:not(
|
||||
|
||||
@@ -164,6 +164,23 @@ describe('SkyField', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps native select options readable on the CEF popup surface', () => {
|
||||
const controls = readFileSync(
|
||||
new URL('../controls.css', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const tokens = readFileSync(
|
||||
new URL('../tokens.css', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
expect(controls).toMatch(
|
||||
/\.sky-field__select option\s*\{[^}]*background:\s*var\(--sky-native-select-option-background, #ffffff\);[^}]*color:\s*var\(--sky-native-select-option-text, #000000\);/s,
|
||||
)
|
||||
expect(tokens).toContain('--sky-native-select-option-background: #ffffff;')
|
||||
expect(tokens).toContain('--sky-native-select-option-text: #000000;')
|
||||
})
|
||||
|
||||
it('raises floating labels only after the field has a value', async () => {
|
||||
const emptyApp = createSSRApp(SkyField, {
|
||||
floatingLabel: true,
|
||||
|
||||
@@ -188,6 +188,8 @@
|
||||
--sky-hairline: rgba(0, 0, 0, 0.2);
|
||||
--sky-field-outline: rgba(0, 0, 0, 0.3);
|
||||
--sky-field-placeholder: rgba(0, 0, 0, 0.3);
|
||||
--sky-native-select-option-background: #ffffff;
|
||||
--sky-native-select-option-text: #000000;
|
||||
--sky-pressed: rgba(0, 0, 0, 0.1);
|
||||
--sky-tabbar-highlight-background: rgba(0, 0, 0, 0.1);
|
||||
--sky-tabbar-thumb-background: rgba(0, 0, 0, 0.05);
|
||||
|
||||
@@ -111,4 +111,36 @@ describe('admin configurator defaults', () => {
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the configured access value when adding radio jobs', () => {
|
||||
const channelJobs: AdminConfiguratorStructure = {
|
||||
entryDefault: true,
|
||||
fields: {},
|
||||
kind: 'table',
|
||||
mutableKeys: true,
|
||||
template: { kind: 'value', valueType: 'boolean' },
|
||||
}
|
||||
const displayNameJobs: AdminConfiguratorStructure = {
|
||||
entryDefault: 0,
|
||||
fields: {},
|
||||
kind: 'table',
|
||||
mutableKeys: true,
|
||||
template: { kind: 'value', valueType: 'number' },
|
||||
}
|
||||
|
||||
expect(
|
||||
createMutableTableEntry(
|
||||
channelJobs,
|
||||
'Radio.LockedChannels[1].jobs',
|
||||
'mechanic',
|
||||
),
|
||||
).toBe(true)
|
||||
expect(
|
||||
createMutableTableEntry(
|
||||
displayNameJobs,
|
||||
'Radio.DisplayName.AllowedJobs',
|
||||
'mechanic',
|
||||
),
|
||||
).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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"')
|
||||
|
||||
@@ -63,6 +63,7 @@ const openedAt = new Date()
|
||||
const featuredSlide = ref(0)
|
||||
const profileOpened = ref(false)
|
||||
const uninstallCandidate = ref<LaunchablePhoneAppDefinition | null>(null)
|
||||
const uninstallError = ref('')
|
||||
const selectedApp = ref<LaunchablePhoneAppDefinition | null>(null)
|
||||
const storeScroll = ref<ComponentPublicInstance | null>(null)
|
||||
const featuredScroller = ref<HTMLElement | null>(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<string, string> {
|
||||
@@ -1107,7 +1121,7 @@ watch(
|
||||
app: getPhoneAppLabel(app, phone.t),
|
||||
})
|
||||
"
|
||||
@click="uninstallCandidate = app"
|
||||
@click.stop="requestUninstall(app)"
|
||||
>
|
||||
<Trash2 :size="17" :stroke-width="2" aria-hidden="true" />
|
||||
</button>
|
||||
@@ -1119,8 +1133,8 @@ watch(
|
||||
<SkyDialog
|
||||
:opened="Boolean(uninstallCandidate)"
|
||||
role="alertdialog"
|
||||
@backdropclick="uninstallCandidate = null"
|
||||
@escape="uninstallCandidate = null"
|
||||
@backdropclick="closeUninstallDialog"
|
||||
@escape="closeUninstallDialog"
|
||||
>
|
||||
<template #title>
|
||||
{{ phone.t('Apps.appStore.account.uninstallTitle') }}
|
||||
@@ -1132,8 +1146,15 @@ watch(
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
<p
|
||||
v-if="uninstallError"
|
||||
class="store-account__uninstall-error"
|
||||
role="alert"
|
||||
>
|
||||
{{ uninstallError }}
|
||||
</p>
|
||||
<template #buttons>
|
||||
<SkyDialogButton @click="uninstallCandidate = null">
|
||||
<SkyDialogButton @click="closeUninstallDialog">
|
||||
{{ phone.t('Common.cancel') }}
|
||||
</SkyDialogButton>
|
||||
<SkyDialogButton strong @click="confirmUninstall">
|
||||
@@ -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;
|
||||
|
||||
@@ -216,8 +216,9 @@ Config.Radio = {
|
||||
AutoRejoin = false,
|
||||
DisplayName = {
|
||||
Enabled = true,
|
||||
AllowEveryone = false, -- true allows every job; false uses AllowedJobs and its minimum grades
|
||||
MaxLength = 32,
|
||||
AllowedJobs = { -- Job name = minimum grade. Unlisted jobs cannot set a radio display name.
|
||||
AllowedJobs = { -- Job name = minimum grade. Used when AllowEveryone is false.
|
||||
police = 0,
|
||||
sheriff = 0,
|
||||
fib = 0,
|
||||
|
||||
@@ -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}",
|
||||
@@ -2140,6 +2141,8 @@ Locales["de"] = {
|
||||
},
|
||||
}
|
||||
|
||||
Locales["de"].Nui.AdminPanel.configurator.table.addJob = "Job hinzufügen"
|
||||
Locales["de"].Nui.AdminPanel.configurator.table.jobPlaceholder = "Jobname"
|
||||
Locales["de"].Nui.AdminPanel.configurator.table.subtabs = {
|
||||
Dictionaries = "Animationsdateien",
|
||||
Clips = "Clips",
|
||||
|
||||
@@ -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}",
|
||||
@@ -2140,6 +2141,8 @@ Locales["en"] = {
|
||||
},
|
||||
}
|
||||
|
||||
Locales["en"].Nui.AdminPanel.configurator.table.addJob = "Add job"
|
||||
Locales["en"].Nui.AdminPanel.configurator.table.jobPlaceholder = "Job name"
|
||||
Locales["en"].Nui.AdminPanel.configurator.table.subtabs = {
|
||||
Dictionaries = "Dictionaries",
|
||||
Clips = "Clips",
|
||||
|
||||
@@ -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}",
|
||||
@@ -2140,6 +2141,8 @@ Locales["es"] = {
|
||||
},
|
||||
}
|
||||
|
||||
Locales["es"].Nui.AdminPanel.configurator.table.addJob = "Añadir trabajo"
|
||||
Locales["es"].Nui.AdminPanel.configurator.table.jobPlaceholder = "Nombre del trabajo"
|
||||
Locales["es"].Nui.AdminPanel.configurator.table.subtabs = {
|
||||
Dictionaries = "Diccionarios",
|
||||
Clips = "Los clips",
|
||||
|
||||
@@ -6,7 +6,7 @@ use_experimental_fxv2_oal 'yes'
|
||||
|
||||
author 'Sky-Systems'
|
||||
description 'Sky Phone'
|
||||
version '0.3.0'
|
||||
version '0.3.1'
|
||||
|
||||
provide 'lb-phone'
|
||||
provide '17mov_Phone'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -6,7 +6,10 @@ local exchange_lock = false
|
||||
local markets = {}
|
||||
local market_order = {}
|
||||
local market_dynamics = {}
|
||||
local market_state = {}
|
||||
local market_history = {}
|
||||
local market_cursor = 1
|
||||
local market_persistence_interval = 5 * 60 * 1000
|
||||
local global_market_trend = 0
|
||||
local global_market_cycle = {
|
||||
direction = 0,
|
||||
@@ -370,6 +373,100 @@ local function initialize_markets()
|
||||
market_cursor = math.min(market_cursor, math.max(#market_order, 1))
|
||||
end
|
||||
|
||||
local function load_market_cache()
|
||||
local rows = Bridge.Database.Query([[
|
||||
SELECT `id`,`price`,`version`,`status`, UNIX_TIMESTAMP(`updated_at`) AS `updated_at`
|
||||
FROM `sky_phone_crypto_markets`
|
||||
]], {})
|
||||
local next_market_state = {}
|
||||
local next_market_history = {}
|
||||
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(),
|
||||
dirty = false,
|
||||
}
|
||||
end
|
||||
end
|
||||
for _, market_id in ipairs(market_order) do
|
||||
local state = next_market_state[market_id]
|
||||
if not state then
|
||||
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`
|
||||
FROM `sky_phone_crypto_market_ticks`
|
||||
WHERE `market_id` = ? ORDER BY `id` DESC LIMIT ?
|
||||
]], { market_id, Config.Crypto.HistoryRetentionTicks })
|
||||
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,
|
||||
}
|
||||
end
|
||||
if #history == 0 then
|
||||
history[1] = {
|
||||
price = state.price,
|
||||
version = state.version,
|
||||
created_at = state.updated_at,
|
||||
}
|
||||
end
|
||||
next_market_history[market_id] = history
|
||||
end
|
||||
market_state = next_market_state
|
||||
market_history = next_market_history
|
||||
end
|
||||
|
||||
local function persist_market_cache()
|
||||
local queries = {}
|
||||
local persisted_markets = {}
|
||||
for _, market_id in ipairs(market_order) do
|
||||
local state = market_state[market_id]
|
||||
if state and state.dirty then
|
||||
queries[#queries + 1] = {
|
||||
query = [[
|
||||
UPDATE `sky_phone_crypto_markets`
|
||||
SET `price` = ?, `version` = ?, `status` = ?, `updated_at` = FROM_UNIXTIME(?)
|
||||
WHERE `id` = ?
|
||||
]],
|
||||
params = { state.price, state.version, state.status, state.updated_at, market_id },
|
||||
}
|
||||
queries[#queries + 1] = {
|
||||
query = [[
|
||||
INSERT INTO `sky_phone_crypto_market_ticks`
|
||||
(`market_id`,`version`,`price`,`created_at`) VALUES (?, ?, ?, FROM_UNIXTIME(?))
|
||||
]],
|
||||
params = { market_id, state.version, state.price, state.updated_at },
|
||||
}
|
||||
persisted_markets[#persisted_markets + 1] = market_id
|
||||
end
|
||||
end
|
||||
if #queries == 0 then
|
||||
return true
|
||||
end
|
||||
queries[#queries + 1] = {
|
||||
query = [[
|
||||
DELETE FROM `sky_phone_crypto_market_ticks`
|
||||
WHERE `created_at` < DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 24 HOUR)
|
||||
]],
|
||||
params = {},
|
||||
}
|
||||
if not Bridge.Database.Transaction(queries) then
|
||||
print("[sky_phone] Failed to persist the crypto market cache.")
|
||||
return false
|
||||
end
|
||||
for _, market_id in ipairs(persisted_markets) do
|
||||
market_state[market_id].dirty = false
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
local function require_phone(source)
|
||||
local phone_session, error_response = SkyPhone.RequireSession(source)
|
||||
if not phone_session then
|
||||
@@ -451,20 +548,7 @@ local function balance(account, asset)
|
||||
row and (tonumber(row.version) or 0) or 0
|
||||
end
|
||||
|
||||
local function market_rows()
|
||||
local rows = Bridge.Database.Query([[
|
||||
SELECT `id`,`price`,`version`,`status`, UNIX_TIMESTAMP(`updated_at`) AS `updated_at`
|
||||
FROM `sky_phone_crypto_markets`
|
||||
]], {})
|
||||
local indexed = {}
|
||||
for _, row in ipairs(rows) do
|
||||
indexed[row.id] = row
|
||||
end
|
||||
return indexed
|
||||
end
|
||||
|
||||
local function market_dtos(selected_market_ids)
|
||||
local current = market_rows()
|
||||
local selected = nil
|
||||
if selected_market_ids then
|
||||
selected = {}
|
||||
@@ -472,28 +556,16 @@ local function market_dtos(selected_market_ids)
|
||||
selected[market_id] = true
|
||||
end
|
||||
end
|
||||
local daily_rows = Bridge.Database.Query([[
|
||||
SELECT `market_id`, MIN(`price`) AS `low`, MAX(`price`) AS `high`
|
||||
FROM `sky_phone_crypto_market_ticks`
|
||||
WHERE `created_at` >= DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 24 HOUR)
|
||||
GROUP BY `market_id`
|
||||
]], {})
|
||||
local daily = {}
|
||||
for _, daily_row in ipairs(daily_rows) do
|
||||
daily[daily_row.market_id] = daily_row
|
||||
end
|
||||
local result = {}
|
||||
for _, market_id in ipairs(market_order) do
|
||||
if not selected or selected[market_id] then
|
||||
local config = markets[market_id]
|
||||
local row = current[market_id]
|
||||
local ticks = Bridge.Database.Query([[
|
||||
SELECT `price` FROM `sky_phone_crypto_market_ticks`
|
||||
WHERE `market_id` = ? ORDER BY `id` DESC LIMIT ?
|
||||
]], { market_id, Config.Crypto.SparklinePoints })
|
||||
local row = market_state[market_id]
|
||||
local history = market_history[market_id]
|
||||
local prices = {}
|
||||
for index = #ticks, 1, -1 do
|
||||
prices[#prices + 1] = tonumber(ticks[index].price) or tonumber(row.price)
|
||||
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
|
||||
end
|
||||
if #prices == 0 then
|
||||
prices[1] = tonumber(row.price)
|
||||
@@ -509,7 +581,17 @@ local function market_dtos(selected_market_ids)
|
||||
end
|
||||
local first = prices[1]
|
||||
local price = tonumber(row.price) or config.InitialPrice
|
||||
local daily_range = daily[market_id]
|
||||
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
|
||||
result[#result + 1] = {
|
||||
id = market_id,
|
||||
symbol = config.Symbol,
|
||||
@@ -519,8 +601,8 @@ local function market_dtos(selected_market_ids)
|
||||
price = decimal_string(price, Config.Crypto.PriceScale),
|
||||
changePercent = first > 0 and ((price - first) / first) * 100 or 0,
|
||||
enabled = row.status == "active",
|
||||
high24h = decimal_string(daily_range and daily_range.high or maximum, Config.Crypto.PriceScale),
|
||||
low24h = decimal_string(daily_range and daily_range.low or minimum, Config.Crypto.PriceScale),
|
||||
high24h = decimal_string(daily_high, Config.Crypto.PriceScale),
|
||||
low24h = decimal_string(daily_low, Config.Crypto.PriceScale),
|
||||
issuedSupply = decimal_string(config.IssuedSupply * Config.Crypto.AssetScale, Config.Crypto.AssetScale),
|
||||
treasuryAvailable = decimal_string(balance("treasury", market_id), Config.Crypto.AssetScale),
|
||||
priceHistory = price_history,
|
||||
@@ -567,13 +649,12 @@ end
|
||||
|
||||
local function bootstrap(profile)
|
||||
local cash = balance(account_id(profile.id), "CASH")
|
||||
local current_markets = market_rows()
|
||||
local holdings = {}
|
||||
local portfolio = cash
|
||||
for _, market_id in ipairs(market_order) do
|
||||
local available = balance(account_id(profile.id), market_id)
|
||||
if available > 0 then
|
||||
local price = tonumber(current_markets[market_id].price) or 0
|
||||
local price = tonumber(market_state[market_id].price) or 0
|
||||
local value = math.floor(available * price / Config.Crypto.AssetScale)
|
||||
local fill = Bridge.Database.Query([[
|
||||
SELECT FLOOR(SUM(fill.`gross`) * ? / NULLIF(SUM(fill.`quantity`), 0)) AS `price`
|
||||
@@ -1044,10 +1125,7 @@ Bridge.Callbacks.Register("sky_phone:crypto:quote", function(source, data)
|
||||
if not config or not side or not quantity then
|
||||
return { success = false, error = "invalid_quantity" }
|
||||
end
|
||||
local market = Bridge.Database.Query(
|
||||
"SELECT `price`,`version`,`status` FROM `sky_phone_crypto_markets` WHERE `id` = ? LIMIT 1",
|
||||
{ config.Id }
|
||||
)[1]
|
||||
local market = market_state[config.Id]
|
||||
if not market or market.status ~= "active" then
|
||||
return { success = false, error = "market_unavailable" }
|
||||
end
|
||||
@@ -1127,9 +1205,8 @@ local function execute_trade(profile, data)
|
||||
or { success = false, error = "duplicate_request" }
|
||||
end
|
||||
local quote = Bridge.Database.Query([[
|
||||
SELECT quote.*, market.`status` AS `market_status`, market.`version` AS `current_version`
|
||||
SELECT quote.*
|
||||
FROM `sky_phone_crypto_quotes` quote
|
||||
JOIN `sky_phone_crypto_markets` market ON market.`id` = quote.`market_id`
|
||||
WHERE quote.`id` = ? AND quote.`profile_id` = ? LIMIT 1
|
||||
]], { data.quoteId, profile.id })[1]
|
||||
if not quote or quote.consumed_operation_id then
|
||||
@@ -1142,7 +1219,10 @@ local function execute_trade(profile, data)
|
||||
if not expiry or tonumber(expiry.expires_at) < os.time() then
|
||||
return { success = false, error = "quote_expired" }
|
||||
end
|
||||
if quote.market_status ~= "active" or tonumber(quote.current_version) ~= tonumber(quote.market_version) then
|
||||
local current_market = market_state[quote.market_id]
|
||||
if not current_market or current_market.status ~= "active"
|
||||
or current_market.version ~= tonumber(quote.market_version)
|
||||
then
|
||||
return { success = false, error = "quote_expired" }
|
||||
end
|
||||
local quantity = tonumber(quote.quantity)
|
||||
@@ -1367,6 +1447,7 @@ end)
|
||||
ensure_schema()
|
||||
migrate_crypto_keys()
|
||||
initialize_markets()
|
||||
load_market_cache()
|
||||
|
||||
local function reconcile_settlements(include_recent)
|
||||
local age_clause = include_recent and "" or " AND settlement.`updated_at` < DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 5 MINUTE)"
|
||||
@@ -1507,6 +1588,16 @@ local function start_crypto_schedulers()
|
||||
local generation = scheduler_generation
|
||||
reconcile_settlements(true)
|
||||
|
||||
CreateThread(function()
|
||||
while scheduler_generation == generation and Config.Crypto.Enabled == true do
|
||||
Wait(market_persistence_interval)
|
||||
if scheduler_generation ~= generation or Config.Crypto.Enabled ~= true then
|
||||
break
|
||||
end
|
||||
with_exchange_lock(persist_market_cache)
|
||||
end
|
||||
end)
|
||||
|
||||
CreateThread(function()
|
||||
while scheduler_generation == generation and Config.Crypto.Enabled == true do
|
||||
Wait(5 * 60 * 1000)
|
||||
@@ -1542,10 +1633,7 @@ local function start_crypto_schedulers()
|
||||
local order_index = ((market_cursor + offset - 1) % #market_order) + 1
|
||||
local market_id = market_order[order_index]
|
||||
local config = markets[market_id]
|
||||
local row = Bridge.Database.Query(
|
||||
"SELECT `price`,`version`,`status` FROM `sky_phone_crypto_markets` WHERE `id` = ? LIMIT 1",
|
||||
{ market_id }
|
||||
)[1]
|
||||
local row = market_state[market_id]
|
||||
if row and row.status == "active" then
|
||||
local price = tonumber(row.price) or config.InitialPrice
|
||||
local impulse = crypto_random_int(
|
||||
@@ -1614,22 +1702,22 @@ local function start_crypto_schedulers()
|
||||
next_price = price + (movement > 0 and 1 or -1)
|
||||
end
|
||||
next_price = math.max(config.MinimumPrice, math.min(config.MaximumPrice, next_price))
|
||||
local next_version = (tonumber(row.version) or 0) + 1
|
||||
if Bridge.Database.Transaction({
|
||||
{ query = [[UPDATE `sky_phone_crypto_markets` SET `price` = ?, `version` = ? WHERE `id` = ? AND `version` = ?]], params = { next_price, next_version, market_id, row.version } },
|
||||
{ query = [[INSERT INTO `sky_phone_crypto_market_ticks` (`market_id`,`version`,`price`) VALUES (?, ?, ?)]], params = { market_id, next_version, next_price } },
|
||||
}) then
|
||||
changed_markets[#changed_markets + 1] = market_id
|
||||
Bridge.Database.Query([[
|
||||
DELETE FROM `sky_phone_crypto_market_ticks`
|
||||
WHERE `market_id` = ? AND `id` NOT IN (
|
||||
SELECT `id` FROM (
|
||||
SELECT `id` FROM `sky_phone_crypto_market_ticks`
|
||||
WHERE `market_id` = ? ORDER BY `id` DESC LIMIT ?
|
||||
) retained
|
||||
)
|
||||
]], { market_id, market_id, Config.Crypto.HistoryRetentionTicks })
|
||||
local next_version = row.version + 1
|
||||
local updated_at = os.time()
|
||||
row.price = next_price
|
||||
row.version = next_version
|
||||
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
|
||||
table.remove(history, 1)
|
||||
end
|
||||
changed_markets[#changed_markets + 1] = market_id
|
||||
end
|
||||
end
|
||||
market_cursor = ((market_cursor + market_count - 1) % #market_order) + 1
|
||||
@@ -1645,11 +1733,26 @@ local function start_crypto_schedulers()
|
||||
end
|
||||
|
||||
local function refresh_crypto_runtime()
|
||||
initialize_markets()
|
||||
start_crypto_schedulers()
|
||||
if exchange_lock then
|
||||
print("[sky_phone] Crypto runtime refresh skipped because the exchange is busy.")
|
||||
return
|
||||
end
|
||||
with_exchange_lock(function()
|
||||
if not persist_market_cache() then
|
||||
return
|
||||
end
|
||||
initialize_markets()
|
||||
load_market_cache()
|
||||
start_crypto_schedulers()
|
||||
end)
|
||||
end
|
||||
|
||||
AddEventHandler("sky_phone:configurator:serverUpdated", refresh_crypto_runtime)
|
||||
AddEventHandler("onResourceStop", function(resource_name)
|
||||
if resource_name == GetCurrentResourceName() then
|
||||
persist_market_cache()
|
||||
end
|
||||
end)
|
||||
start_crypto_schedulers()
|
||||
|
||||
end)
|
||||
|
||||
@@ -274,12 +274,22 @@ local function upgrade_legacy_map(defaults, saved)
|
||||
return { __skyType = "map", entries = entries }
|
||||
end
|
||||
|
||||
local function radio_job_entry_default(path)
|
||||
if path == "Radio.DisplayName.AllowedJobs" then
|
||||
return 0
|
||||
end
|
||||
if type(path) == "string" and path:match("^Radio%.LockedChannels%.%d+%.jobs$") then
|
||||
return true
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function merge_values(defaults, saved, path, excluded_paths)
|
||||
path = path or ""
|
||||
if type(defaults) ~= "table" or type(saved) ~= "table" then
|
||||
return copy_value(saved)
|
||||
end
|
||||
if path == "Companies.Definitions" then
|
||||
if path == "Companies.Definitions" or radio_job_entry_default(path) ~= nil then
|
||||
return copy_value(saved)
|
||||
end
|
||||
if defaults.__skyType == "map" and not saved.__skyType then
|
||||
@@ -492,6 +502,16 @@ local function empty_structure(scope, path)
|
||||
if scope ~= "config" then
|
||||
return nil
|
||||
end
|
||||
local radio_job_default = radio_job_entry_default(path)
|
||||
if radio_job_default ~= nil then
|
||||
return {
|
||||
entryDefault = radio_job_default,
|
||||
fields = {},
|
||||
kind = "table",
|
||||
mutableKeys = true,
|
||||
template = { kind = "value", valueType = type(radio_job_default) },
|
||||
}
|
||||
end
|
||||
if path == "Garage.VehicleImages.ModelNames" then
|
||||
return {
|
||||
entries = {},
|
||||
@@ -712,6 +732,16 @@ local function build_structure(value, scope, path)
|
||||
for key, child in pairs(value) do
|
||||
fields[key] = build_structure(child, scope, path .. "." .. tostring(key))
|
||||
end
|
||||
local radio_job_default = scope == "config" and radio_job_entry_default(path) or nil
|
||||
if radio_job_default ~= nil then
|
||||
return {
|
||||
entryDefault = radio_job_default,
|
||||
fields = fields,
|
||||
kind = "table",
|
||||
mutableKeys = true,
|
||||
template = { kind = "value", valueType = type(radio_job_default) },
|
||||
}
|
||||
end
|
||||
return {
|
||||
fields = fields,
|
||||
kind = "table",
|
||||
|
||||
@@ -50,8 +50,14 @@ local function can_set_display_name(source)
|
||||
if type(config) ~= "table" or not config.Enabled then
|
||||
return false
|
||||
end
|
||||
if config.AllowEveryone == true then
|
||||
return true
|
||||
end
|
||||
|
||||
local job = Bridge.Framework.GetJob(source)
|
||||
if type(job) ~= "table" or type(job.name) ~= "string" then
|
||||
return false
|
||||
end
|
||||
local minimum_grade = type(config.AllowedJobs) == "table" and tonumber(config.AllowedJobs[job.name]) or nil
|
||||
return minimum_grade ~= nil and (tonumber(job.grade) or 0) >= minimum_grade
|
||||
end
|
||||
|
||||
@@ -1836,16 +1836,21 @@ CreateThread(function()
|
||||
while true do
|
||||
local recovery_success, recovery_rows = pcall(
|
||||
Bridge.Database.Query,
|
||||
ride_select .. (([[
|
||||
WHERE r.`refund_status` IN ('pending','completed')
|
||||
AND (
|
||||
r.`status` = 'cancelled'
|
||||
OR (
|
||||
r.`status` = 'payment_pending'
|
||||
AND r.`updated_at` <= DATE_SUB(
|
||||
CURRENT_TIMESTAMP,
|
||||
INTERVAL %d SECOND
|
||||
)
|
||||
(([[
|
||||
SELECT r.`id`, r.`status`, r.`refund_status`, r.`price`,
|
||||
passenger.`owner_identifier` AS `passenger_identifier`
|
||||
FROM `sky_phone_skyride_rides` r
|
||||
INNER JOIN `sky_phone_skyride_profiles` passenger
|
||||
ON passenger.`id` = r.`passenger_profile_id`
|
||||
WHERE (
|
||||
r.`refund_status` = 'pending'
|
||||
AND r.`status` = 'cancelled'
|
||||
) OR (
|
||||
r.`refund_status` IN ('pending','completed')
|
||||
AND r.`status` = 'payment_pending'
|
||||
AND r.`updated_at` <= DATE_SUB(
|
||||
CURRENT_TIMESTAMP,
|
||||
INTERVAL %d SECOND
|
||||
)
|
||||
)
|
||||
ORDER BY r.`updated_at` ASC
|
||||
|
||||
@@ -201,8 +201,9 @@ Config.Radio = {
|
||||
AutoRejoin = false,
|
||||
DisplayName = {
|
||||
Enabled = true,
|
||||
AllowEveryone = false, -- true allows every job; false uses AllowedJobs and its minimum grades
|
||||
MaxLength = 32,
|
||||
AllowedJobs = { -- Job name = minimum grade. Unlisted jobs cannot set a radio display name.
|
||||
AllowedJobs = { -- Job name = minimum grade. Used when AllowEveryone is false.
|
||||
police = 0,
|
||||
sheriff = 0,
|
||||
fib = 0,
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user