mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-28 17:01:18 +00:00
FIX - harden phone stability and app controls (#28)
This commit is contained in:
+10
-1
@@ -530,8 +530,17 @@ function hydratePhone(payload: PhoneOpenPayload): void {
|
||||
clock.hydrate(payload.device?.data.alarms?.payload)
|
||||
games.hydrate(payload.device?.data.games?.payload)
|
||||
media.hydrate(payload.device?.data.media?.payload)
|
||||
appStore.hydrate(payload.device?.data.apps?.payload)
|
||||
appStore.hydrate(payload.device?.data.apps?.payload, payload.disabledApps)
|
||||
widgets.hydrate(payload.device?.data.widgets?.payload)
|
||||
|
||||
const currentAppId = route.params.appId
|
||||
if (
|
||||
typeof currentAppId === 'string' &&
|
||||
isPhoneAppId(currentAppId) &&
|
||||
!appStore.isInstalled(currentAppId)
|
||||
) {
|
||||
void router.push('/')
|
||||
}
|
||||
}
|
||||
|
||||
function getInstalledNavigationAppIds(): string[] {
|
||||
|
||||
@@ -188,6 +188,24 @@ describe('standalone admin panel contracts', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('applies global bundled-app availability to phones and admin management', () => {
|
||||
expect(config).toContain('Config.Apps = {')
|
||||
expect(config).toContain('memory = true,')
|
||||
expect(config).toContain('weather = true,')
|
||||
expect(configDefault).toContain('Config.Apps = {')
|
||||
expect(configuratorServer).toContain('Apps = true,')
|
||||
expect(phoneServer).toContain('function SkyPhone.IsAppEnabled(app_id)')
|
||||
expect(phoneServer).toContain('function SkyPhone.GetDisabledApps()')
|
||||
expect(phoneServer).toContain(
|
||||
'disabledApps = SkyPhone.GetDisabledApps()',
|
||||
)
|
||||
expect(server).toContain('if not SkyPhone.IsAppEnabled(app_id) then')
|
||||
expect(server).toContain('disabledApps = SkyPhone.GetDisabledApps()')
|
||||
expect(store).toContain('disabledApps: [] as string[]')
|
||||
expect(store).toContain('disabledAppsFromConfigurator(response.data)')
|
||||
expect(source).toContain('!admin.disabledApps.includes(app.id)')
|
||||
})
|
||||
|
||||
it('connects every operation through the standard NUI callback bridge', () => {
|
||||
expect(bridge).toContain('admin = [[')
|
||||
for (const endpoint of [
|
||||
|
||||
@@ -134,7 +134,9 @@ const manageableApps = computed(() => {
|
||||
const needle = appQuery.value.trim().toLocaleLowerCase(phone.lang)
|
||||
return PHONE_APPS.filter(
|
||||
(app): app is LaunchablePhoneAppDefinition =>
|
||||
isLaunchablePhoneApp(app) && !app.adminOnly,
|
||||
isLaunchablePhoneApp(app) &&
|
||||
!app.adminOnly &&
|
||||
!admin.disabledApps.includes(app.id),
|
||||
)
|
||||
.filter(
|
||||
(app) =>
|
||||
|
||||
@@ -61,16 +61,22 @@ const passcodeError = ref('')
|
||||
const passcodeLength = ref<4 | 6>(phone.security.length === 4 ? 4 : 6)
|
||||
const notificationsEnabled = ref(true)
|
||||
const notificationSounds = ref(true)
|
||||
const selectedApps = ref<BuiltinPhoneAppId[]>(['banking', 'garage', 'skyride'])
|
||||
const selectedApps = ref<BuiltinPhoneAppId[]>(
|
||||
(['banking', 'garage', 'skyride'] as const).filter((id) =>
|
||||
appStore.isAvailable(id),
|
||||
),
|
||||
)
|
||||
const setupCompleteBusy = ref(false)
|
||||
const setupCompleteError = ref('')
|
||||
|
||||
const setupApps = (
|
||||
const setupApps = computed(() =>
|
||||
(
|
||||
['banking', 'garage', 'skyride', 'citymarkt', 'picstagram', 'snake'] as const
|
||||
).flatMap((id) => {
|
||||
).flatMap((id) => {
|
||||
const app = getPhoneApp(id)
|
||||
return app ? [app] : []
|
||||
})
|
||||
return app && appStore.isAvailable(id) ? [app] : []
|
||||
}),
|
||||
)
|
||||
const wallpaperChoices = WALLPAPER_IDS
|
||||
const progress = computed(() => `${((step.value + 1) / 10) * 100}%`)
|
||||
const displayName = computed(() => {
|
||||
|
||||
@@ -52,6 +52,41 @@ describe('phone inventory contracts', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('uses One Inventory slot ids and authoritative slot reads', () => {
|
||||
const adapter = readResourceFile(
|
||||
'source/bridge/server/inventory/one.lua',
|
||||
)
|
||||
|
||||
expect(adapter).toContain('inventory:GetSlotIdsWithItem(')
|
||||
expect(adapter).toContain(
|
||||
'local normalized = Bridge.Inventory.GetSlot(source, slot_id)',
|
||||
)
|
||||
expect(adapter).not.toContain('inventory:SearchInventory(')
|
||||
expect(adapter).toContain(
|
||||
'inventory:SetItemMetadata(source, slot.slot, requested_metadata) == false',
|
||||
)
|
||||
})
|
||||
|
||||
it('serializes and rate-limits phone bootstrap requests on both sides', () => {
|
||||
const phoneClient = readResourceFile('source/client/main.lua')
|
||||
const phoneServer = readResourceFile('source/server/phone.lua')
|
||||
|
||||
expect(phoneClient).toContain('local function request_phone_open(')
|
||||
expect(phoneClient).toMatch(
|
||||
/request_phone_open\(callback_name\)[\s\S]*?open_requested = true[\s\S]*?Bridge\.Callbacks\.Trigger\(callback_name, \{\}\)/,
|
||||
)
|
||||
expect(phoneClient).toMatch(
|
||||
/RegisterNetEvent\("sky_phone:device:error"[\s\S]*?if not is_open then[\s\S]*?open_requested = false/,
|
||||
)
|
||||
expect(phoneServer).toContain('local phone_open_in_progress = {}')
|
||||
expect(phoneServer).toContain(
|
||||
'SkyPhone.AllowOperation(source, "phone_open", request_limit, 60)',
|
||||
)
|
||||
expect(phoneServer).toContain(
|
||||
'pcall(perform_phone_open, source, used_item)',
|
||||
)
|
||||
})
|
||||
|
||||
it('auto-detects registered inventories and forces metadata-free adapters into compatible modes', () => {
|
||||
const inventoryBridge = readResourceFile(
|
||||
'source/bridge/server/inventory.lua',
|
||||
@@ -166,10 +201,10 @@ describe('phone inventory contracts', () => {
|
||||
'SkyPhoneCompatibility.RegisterExportAlias("lb-phone", "ToggleOpen"',
|
||||
)
|
||||
expect(phoneClient).toContain(
|
||||
'local result = Bridge.Callbacks.Trigger("sky_phone:device:open-request", {})',
|
||||
'local result = Bridge.Callbacks.Trigger(callback_name, {})',
|
||||
)
|
||||
expect(phoneClient).toMatch(
|
||||
/result\.success ~= true[\s\S]*open_without_focus = false[\s\S]*return false/,
|
||||
/result\.success == true[\s\S]*open_requested = false[\s\S]*open_without_focus = false[\s\S]*return false/,
|
||||
)
|
||||
expect(phoneBridge).toContain(
|
||||
'SkyPhoneCompatibility.RegisterExportAlias("lb-phone", "IsPhoneOnScreen"',
|
||||
@@ -265,7 +300,7 @@ describe('phone inventory contracts', () => {
|
||||
'if active_key_mapping_command == command_name then',
|
||||
)
|
||||
expect(phoneClient).toContain(
|
||||
'Bridge.Callbacks.Trigger("sky_phone:device:open-request", {})',
|
||||
'request_phone_open("sky_phone:device:open-request")',
|
||||
)
|
||||
expect(phoneServer).toContain(
|
||||
'Bridge.Callbacks.Register("sky_phone:device:open-request", function(source)',
|
||||
|
||||
@@ -28,6 +28,20 @@ const EMPTY_STATS: AdminStats = {
|
||||
simDevices: 0,
|
||||
}
|
||||
|
||||
function disabledAppsFromConfigurator(
|
||||
configurator: AdminConfigurator,
|
||||
): string[] {
|
||||
const apps = configurator.sections
|
||||
.flatMap((section) => section.fields)
|
||||
.find((field) => field.scope === 'config' && field.path === 'Apps')?.value
|
||||
if (!apps || typeof apps !== 'object' || Array.isArray(apps)) return []
|
||||
|
||||
return Object.entries(apps)
|
||||
.filter(([, enabled]) => enabled === false)
|
||||
.map(([appId]) => appId)
|
||||
.sort()
|
||||
}
|
||||
|
||||
export const useAdminStore = defineStore('admin', {
|
||||
state: () => ({
|
||||
actionKey: '',
|
||||
@@ -36,6 +50,7 @@ export const useAdminStore = defineStore('admin', {
|
||||
configurator: null as AdminConfigurator | null,
|
||||
configuratorLoading: false,
|
||||
detailLoading: false,
|
||||
disabledApps: [] as string[],
|
||||
error: '',
|
||||
initialized: false,
|
||||
loading: false,
|
||||
@@ -58,6 +73,9 @@ export const useAdminStore = defineStore('admin', {
|
||||
return false
|
||||
}
|
||||
this.players = response.data.players
|
||||
this.disabledApps = Array.isArray(response.data.disabledApps)
|
||||
? response.data.disabledApps
|
||||
: []
|
||||
this.stats = response.data.stats
|
||||
this.audit = response.data.audit
|
||||
this.error = ''
|
||||
@@ -158,6 +176,7 @@ export const useAdminStore = defineStore('admin', {
|
||||
return false
|
||||
}
|
||||
this.configurator = response.data
|
||||
this.disabledApps = disabledAppsFromConfigurator(response.data)
|
||||
this.error = ''
|
||||
return true
|
||||
},
|
||||
@@ -178,6 +197,7 @@ export const useAdminStore = defineStore('admin', {
|
||||
this.actionKey = ''
|
||||
if (response.success && response.data) {
|
||||
this.configurator = response.data
|
||||
this.disabledApps = disabledAppsFromConfigurator(response.data)
|
||||
this.error = ''
|
||||
} else {
|
||||
if (response.data) this.configurator = response.data
|
||||
|
||||
@@ -75,6 +75,21 @@ describe('app store', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('removes server-disabled apps without forgetting device claims', () => {
|
||||
const apps = useAppStoreStore()
|
||||
|
||||
apps.hydrate({ claimedApps: ['weather', 'snake'] }, ['weather'])
|
||||
|
||||
expect(apps.isAvailable('weather')).toBe(false)
|
||||
expect(apps.isInstalled('weather')).toBe(false)
|
||||
expect(apps.homeLayout.grid).not.toContain('weather')
|
||||
expect(apps.claimedApps).toContain('weather')
|
||||
expect(apps.isInstalled('snake')).toBe(true)
|
||||
|
||||
apps.hydrate({ claimedApps: apps.claimedApps }, [])
|
||||
expect(apps.isInstalled('weather')).toBe(true)
|
||||
})
|
||||
|
||||
it('drops the retired admin app from persisted phone layouts', () => {
|
||||
const apps = useAppStoreStore()
|
||||
|
||||
|
||||
@@ -45,28 +45,49 @@ const pendingInstallations = new WeakMap<
|
||||
Map<LaunchablePhoneAppId, PendingInstallation>
|
||||
>()
|
||||
|
||||
function getDefaultGridIds(): LaunchablePhoneAppId[] {
|
||||
return PHONE_APPS.filter((app) => app.dockOrder === null)
|
||||
function isAppDisabled(
|
||||
appId: LaunchablePhoneAppId,
|
||||
disabledApps: readonly LaunchablePhoneAppId[],
|
||||
): boolean {
|
||||
return disabledApps.includes(appId)
|
||||
}
|
||||
|
||||
function getDefaultGridIds(
|
||||
disabledApps: readonly LaunchablePhoneAppId[] = [],
|
||||
): LaunchablePhoneAppId[] {
|
||||
return PHONE_APPS.filter(
|
||||
(app) => app.dockOrder === null && !isAppDisabled(app.id, disabledApps),
|
||||
)
|
||||
.sort((a, b) => a.gridOrder - b.gridOrder)
|
||||
.map((app) => app.id)
|
||||
}
|
||||
|
||||
function getDefaultDockIds(): LaunchablePhoneAppId[] {
|
||||
return PHONE_APPS.filter((app) => app.dockOrder !== null)
|
||||
function getDefaultDockIds(
|
||||
disabledApps: readonly LaunchablePhoneAppId[] = [],
|
||||
): LaunchablePhoneAppId[] {
|
||||
return PHONE_APPS.filter(
|
||||
(app) => app.dockOrder !== null && !isAppDisabled(app.id, disabledApps),
|
||||
)
|
||||
.sort((a, b) => (a.dockOrder ?? 0) - (b.dockOrder ?? 0))
|
||||
.map((app) => app.id)
|
||||
}
|
||||
|
||||
function getDefaultInstalledIds(): LaunchablePhoneAppId[] {
|
||||
function getDefaultInstalledIds(
|
||||
disabledApps: readonly LaunchablePhoneAppId[] = [],
|
||||
): LaunchablePhoneAppId[] {
|
||||
return PHONE_APPS.filter((app) => {
|
||||
if (app.adminOnly) return false
|
||||
if (app.adminOnly || isAppDisabled(app.id, disabledApps)) return false
|
||||
return isExternalPhoneApp(app)
|
||||
? app.defaultInstalled
|
||||
: DEFAULT_INSTALLED_PHONE_APP_IDS.has(app.id)
|
||||
}).map((app) => app.id)
|
||||
}
|
||||
|
||||
function isProtectedHomeApp(appId: LaunchablePhoneAppId): boolean {
|
||||
function isProtectedHomeApp(
|
||||
appId: LaunchablePhoneAppId,
|
||||
disabledApps: readonly LaunchablePhoneAppId[],
|
||||
): boolean {
|
||||
if (isAppDisabled(appId, disabledApps)) return false
|
||||
const app = getPhoneApp(appId)
|
||||
return app
|
||||
? !isPhoneAppRemovable(app)
|
||||
@@ -104,6 +125,7 @@ function hasUninstalledBuiltinApp(
|
||||
export const useAppStoreStore = defineStore('app-store', {
|
||||
state: () => ({
|
||||
claimedApps: [] as LaunchablePhoneAppId[],
|
||||
disabledApps: [] as LaunchablePhoneAppId[],
|
||||
uninstalledApps: [] as LaunchablePhoneAppId[],
|
||||
homeLayout: createDefaultHomeLayout(
|
||||
getDefaultInstalledIds(),
|
||||
@@ -130,6 +152,7 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
return true
|
||||
},
|
||||
claimApp(id: LaunchablePhoneAppId): void {
|
||||
if (!this.isAvailable(id)) return
|
||||
this.uninstalledApps = this.uninstalledApps.filter(
|
||||
(appId) => appId !== id,
|
||||
)
|
||||
@@ -150,6 +173,7 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
this.installingApps = {}
|
||||
},
|
||||
installApp(id: LaunchablePhoneAppId): void {
|
||||
if (!this.isAvailable(id)) return
|
||||
const installed = this.isInstalled(id)
|
||||
if (
|
||||
this.installingApps[id] ||
|
||||
@@ -191,6 +215,10 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
)
|
||||
return
|
||||
}
|
||||
if (!this.isAvailable(id)) {
|
||||
delete this.installingApps[id]
|
||||
return
|
||||
}
|
||||
if (reportInstall && !isExternalPhoneApp(getPhoneApp(id))) {
|
||||
delete this.installingApps[id]
|
||||
console.error(
|
||||
@@ -220,8 +248,14 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
installations.set(id, { deviceImei, timer, token })
|
||||
pendingInstallations.set(this, installations)
|
||||
},
|
||||
hydrate(payload: unknown): void {
|
||||
hydrate(payload: unknown, disabledApps: unknown = []): void {
|
||||
this.cancelPendingInstalls()
|
||||
this.disabledApps = Array.isArray(disabledApps)
|
||||
? disabledApps.filter(
|
||||
(id): id is LaunchablePhoneAppId =>
|
||||
typeof id === 'string' && isPhoneAppId(id),
|
||||
)
|
||||
: []
|
||||
const data = payload as {
|
||||
claimedApps?: unknown
|
||||
homeLayout?: unknown
|
||||
@@ -256,16 +290,22 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
})
|
||||
: []
|
||||
const installedIds = [
|
||||
...new Set([...getDefaultInstalledIds(), ...this.claimedApps]),
|
||||
].filter((id) => !this.uninstalledApps.includes(id))
|
||||
...new Set([
|
||||
...getDefaultInstalledIds(this.disabledApps),
|
||||
...this.claimedApps,
|
||||
]),
|
||||
].filter(
|
||||
(id) =>
|
||||
!this.uninstalledApps.includes(id) && this.isAvailable(id),
|
||||
)
|
||||
const removedLegacyDefaults = hasUninstalledBuiltinApp(
|
||||
data?.homeLayout,
|
||||
installedIds,
|
||||
)
|
||||
const defaults = createDefaultHomeLayout(
|
||||
installedIds,
|
||||
getDefaultGridIds(),
|
||||
getDefaultDockIds(),
|
||||
getDefaultGridIds(this.disabledApps),
|
||||
getDefaultDockIds(this.disabledApps),
|
||||
)
|
||||
const parsedHomeLayout = parseHomeLayout(
|
||||
data?.homeLayout,
|
||||
@@ -277,8 +317,9 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
const removedDockGridDuplicates =
|
||||
normalizedHomeLayout !== parsedHomeLayout
|
||||
this.homeLayout = normalizedHomeLayout
|
||||
const protectedHiddenAppIds =
|
||||
this.homeLayout.hidden.filter(isProtectedHomeApp)
|
||||
const protectedHiddenAppIds = this.homeLayout.hidden.filter((appId) =>
|
||||
isProtectedHomeApp(appId, this.disabledApps),
|
||||
)
|
||||
for (const appId of protectedHiddenAppIds) {
|
||||
this.homeLayout = restoreHomeApp(this.homeLayout, appId)
|
||||
}
|
||||
@@ -310,7 +351,11 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
this.persist()
|
||||
}
|
||||
},
|
||||
isAvailable(appId: LaunchablePhoneAppId): boolean {
|
||||
return !isAppDisabled(appId, this.disabledApps)
|
||||
},
|
||||
isInstalled(appId: LaunchablePhoneAppId): boolean {
|
||||
if (!this.isAvailable(appId)) return false
|
||||
const app = getPhoneApp(appId)
|
||||
if (app?.adminOnly) return false
|
||||
if (this.uninstalledApps.includes(appId)) return false
|
||||
@@ -322,12 +367,18 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
},
|
||||
reconcileCatalog(): void {
|
||||
const installedIds = [
|
||||
...new Set([...getDefaultInstalledIds(), ...this.claimedApps]),
|
||||
].filter((id) => !this.uninstalledApps.includes(id))
|
||||
...new Set([
|
||||
...getDefaultInstalledIds(this.disabledApps),
|
||||
...this.claimedApps,
|
||||
]),
|
||||
].filter(
|
||||
(id) =>
|
||||
!this.uninstalledApps.includes(id) && this.isAvailable(id),
|
||||
)
|
||||
const defaults = createDefaultHomeLayout(
|
||||
installedIds,
|
||||
getDefaultGridIds(),
|
||||
getDefaultDockIds(),
|
||||
getDefaultGridIds(this.disabledApps),
|
||||
getDefaultDockIds(this.disabledApps),
|
||||
)
|
||||
const previous = JSON.stringify(this.homeLayout)
|
||||
this.homeLayout = removeDockGridDuplicates(
|
||||
@@ -335,7 +386,7 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
)
|
||||
|
||||
for (const appId of [...this.homeLayout.hidden]) {
|
||||
if (isProtectedHomeApp(appId)) {
|
||||
if (isProtectedHomeApp(appId, this.disabledApps)) {
|
||||
this.homeLayout = restoreHomeApp(this.homeLayout, appId)
|
||||
}
|
||||
}
|
||||
@@ -345,6 +396,7 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
}
|
||||
},
|
||||
recordLaunch(appId: LaunchablePhoneAppId): void {
|
||||
if (!this.isAvailable(appId)) return
|
||||
this.launchCounts[appId] = (this.launchCounts[appId] ?? 0) + 1
|
||||
this.persist()
|
||||
},
|
||||
@@ -480,12 +532,13 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
return true
|
||||
},
|
||||
removeHomeApp(appId: LaunchablePhoneAppId): void {
|
||||
if (isProtectedHomeApp(appId)) return
|
||||
if (isProtectedHomeApp(appId, this.disabledApps)) return
|
||||
|
||||
this.homeLayout = removeHomeApp(this.homeLayout, appId)
|
||||
this.persist()
|
||||
},
|
||||
restoreHomeApp(appId: LaunchablePhoneAppId): void {
|
||||
if (!this.isAvailable(appId)) return
|
||||
this.homeLayout = restoreHomeApp(this.homeLayout, appId)
|
||||
this.persist()
|
||||
},
|
||||
|
||||
@@ -33,6 +33,7 @@ export type PasscodeResponseData = {
|
||||
export type PhoneOpenPayload = {
|
||||
account?: DeviceBootstrap['account']
|
||||
device?: PhoneDevice
|
||||
disabledApps?: string[]
|
||||
fallbackLocales?: LocaleTree
|
||||
lang?: string
|
||||
locales?: LocaleTree
|
||||
|
||||
@@ -85,6 +85,7 @@ export type AdminAuditEntry = {
|
||||
|
||||
export type AdminBootstrap = {
|
||||
audit: AdminAuditEntry[]
|
||||
disabledApps: string[]
|
||||
players: AdminPlayerSummary[]
|
||||
stats: AdminStats
|
||||
}
|
||||
|
||||
@@ -2,11 +2,16 @@ import { describe, expect, it } from 'vitest'
|
||||
import type { LaunchablePhoneAppId } from '@/types/apps'
|
||||
import {
|
||||
DEFAULT_PHONE_PREFERENCES,
|
||||
PHONE_SCALE_STEP,
|
||||
PHONE_SETUP_LAST_STEP,
|
||||
parsePhonePreferences,
|
||||
WALLPAPER_IDS,
|
||||
} from './preferences'
|
||||
describe('preferences', () => {
|
||||
it('allows one-percent phone scale adjustments', () => {
|
||||
expect(PHONE_SCALE_STEP).toBe(1)
|
||||
})
|
||||
|
||||
it('starts Setup Assistant for a phone without saved settings', () => {
|
||||
const value = parsePhonePreferences(null)
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ export const WALLPAPER_IDS = [
|
||||
] as const
|
||||
export const PHONE_SCALE_MIN = 75
|
||||
export const PHONE_SCALE_MAX = 150
|
||||
export const PHONE_SCALE_STEP = 5
|
||||
export const PHONE_SCALE_STEP = 1
|
||||
export const PHONE_SETUP_LAST_STEP = 9
|
||||
|
||||
export type AppearanceMode = (typeof APPEARANCE_MODE_IDS)[number]
|
||||
|
||||
@@ -81,6 +81,7 @@ describe('AppStoreApp Sky navigation contract', () => {
|
||||
expect(source).toContain('@click="profileOpened = true"')
|
||||
expect(source).toContain('const installedApps = computed')
|
||||
expect(source).toContain('return !appStore.isInstalled(app.id)')
|
||||
expect(source).toContain('if (!appStore.isAvailable(app.id)) return false')
|
||||
expect(source).toContain(
|
||||
'class="store-account__apps phone-effect--expensive-shadow"',
|
||||
)
|
||||
|
||||
@@ -122,6 +122,7 @@ const catalog = computed(() =>
|
||||
if (!isLaunchablePhoneApp(app) || app.id === 'app-store' || app.adminOnly) {
|
||||
return false
|
||||
}
|
||||
if (!appStore.isAvailable(app.id)) return false
|
||||
|
||||
return !appStore.isInstalled(app.id)
|
||||
}).sort((a, b) => a.gridOrder - b.gridOrder),
|
||||
@@ -129,7 +130,9 @@ const catalog = computed(() =>
|
||||
const installedApps = computed(() =>
|
||||
PHONE_APPS.filter(
|
||||
(app): app is LaunchablePhoneAppDefinition =>
|
||||
isLaunchablePhoneApp(app) && appStore.isInstalled(app.id),
|
||||
isLaunchablePhoneApp(app) &&
|
||||
appStore.isAvailable(app.id) &&
|
||||
appStore.isInstalled(app.id),
|
||||
).sort((a, b) => a.gridOrder - b.gridOrder),
|
||||
)
|
||||
const installedGameCount = computed(
|
||||
@@ -143,6 +146,7 @@ const dailyCandidates = computed(() =>
|
||||
!app.adminOnly &&
|
||||
!isExternalPhoneApp(app) &&
|
||||
app.id !== 'app-store' &&
|
||||
appStore.isAvailable(app.id) &&
|
||||
!DEFAULT_INSTALLED_PHONE_APP_IDS.has(app.id) &&
|
||||
!appStore.isInstalled(app.id),
|
||||
),
|
||||
|
||||
@@ -369,6 +369,22 @@ describe('VaultX crypto app contracts', () => {
|
||||
expect(testServer).toContain('function advanceCryptoCycle(')
|
||||
})
|
||||
|
||||
it('does not keep market and settlement workers active while disabled', () => {
|
||||
expect(server).toContain('local function start_crypto_schedulers()')
|
||||
expect(server).toMatch(
|
||||
/start_crypto_schedulers\(\)[\s\S]*?if Config\.Crypto\.Enabled ~= true then\s+return/,
|
||||
)
|
||||
expect(server).toContain(
|
||||
'while scheduler_generation == generation and Config.Crypto.Enabled == true do',
|
||||
)
|
||||
expect(server).toContain(
|
||||
'AddEventHandler("sky_phone:configurator:serverUpdated", refresh_crypto_runtime)',
|
||||
)
|
||||
expect(server).not.toMatch(
|
||||
/CreateThread\(function\(\)\s+while true do\s+Wait\(5 \* 60 \* 1000\)/,
|
||||
)
|
||||
})
|
||||
|
||||
it('stores cash in price-scale minor units throughout the ledger', () => {
|
||||
expect(server).toContain(
|
||||
'local ledger_amount = amount * Config.Crypto.PriceScale',
|
||||
|
||||
@@ -6,6 +6,14 @@ const source = readFileSync(
|
||||
new URL('./DarkChatApp.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const server = readFileSync(
|
||||
new URL('../../../../sky_phone/source/server/darkchat.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const config = readFileSync(
|
||||
new URL('../../../../sky_phone/config/config.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
describe('DarkChatApp Sky UI contract', () => {
|
||||
it('uses first-party Sky UI without direct Konsta markup', () => {
|
||||
@@ -130,4 +138,13 @@ describe('DarkChatApp Sky UI contract', () => {
|
||||
/\.dc-inbox-navbar :deep\(\.sky-navbar__title-container > div\)\s*\{[^}]*translateY\(-14px\)/s,
|
||||
)
|
||||
})
|
||||
|
||||
it('cleans expired messages in bounded indexed batches', () => {
|
||||
expect(config).toContain('CleanupBatchSize = 250')
|
||||
expect(server).toContain('Config.DarkChat.CleanupBatchSize')
|
||||
expect(server).toMatch(
|
||||
/DELETE FROM `sky_phone_darkchat_messages`[\s\S]*?ORDER BY `expires_at`, `id`[\s\S]*?LIMIT \?/,
|
||||
)
|
||||
expect(server).toContain(']], { batch_size })')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,6 +16,12 @@ describe('PhoneApp EasyShare contract', () => {
|
||||
it('uses the shared full-width Sky tab bar for phone sections', () => {
|
||||
expect(source).toContain('<sky-tab-bar')
|
||||
expect(source).toContain('<sky-tab-button')
|
||||
expect(source).toContain(
|
||||
'calc(var(--sky-tabbar-height) + var(--sky-safe-area-bottom) + 16px);',
|
||||
)
|
||||
expect(source).not.toMatch(
|
||||
/\.phone-contacts\s*\{[^}]*padding-bottom:\s*20px;/s,
|
||||
)
|
||||
})
|
||||
|
||||
it('uses shared interactive liquid glass surfaces for phone controls', () => {
|
||||
|
||||
@@ -2250,7 +2250,6 @@ onBeforeUnmount(() => {
|
||||
position: relative;
|
||||
padding-top: 6px;
|
||||
padding-right: 24px;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
.phone-contacts-header {
|
||||
|
||||
@@ -116,6 +116,20 @@ describe('voice provider contracts', () => {
|
||||
expect(phoneApp).not.toContain('callMuted = !callMuted')
|
||||
})
|
||||
|
||||
it('supports explicit automatic call-provider discovery on client and server', () => {
|
||||
expect(config).toContain(
|
||||
'VoiceProvider = "pma", -- auto, yaca (alias: yaca-voice)',
|
||||
)
|
||||
expect(clientCalls).toContain('if configured == "auto" then')
|
||||
expect(serverVoice).toContain('if configured == "auto" then')
|
||||
expect(clientCalls).toContain(
|
||||
'for _, candidate in ipairs({ "yaca", "pma", "saltychat" }) do',
|
||||
)
|
||||
expect(serverVoice).toContain(
|
||||
'for _, candidate in ipairs({ "yaca", "pma", "saltychat" }) do',
|
||||
)
|
||||
})
|
||||
|
||||
it('passes Yaca radio volume arguments in the documented order', () => {
|
||||
expect(clientRadio).toContain(
|
||||
'changeRadioChannelVolumeRaw(volume / 100, 1)',
|
||||
|
||||
@@ -75,7 +75,7 @@ describe('admin configurator fixture', () => {
|
||||
root !== 'CommandPermissions',
|
||||
)
|
||||
|
||||
expect(sections).toHaveLength(45)
|
||||
expect(sections).toHaveLength(46)
|
||||
expect(
|
||||
fields.reduce(
|
||||
(total, field) => total + countStructure(field.structure),
|
||||
|
||||
@@ -4726,6 +4726,7 @@ function adminMockBootstrap() {
|
||||
targetSource: 2,
|
||||
},
|
||||
],
|
||||
disabledApps: [],
|
||||
players: [1, 2].map((source) => {
|
||||
const player = adminMockPlayerDetail(source)
|
||||
return {
|
||||
|
||||
@@ -51,6 +51,7 @@ Config.Phone = {
|
||||
Item = "phone",
|
||||
Unique = true, -- true: data follows each phone item; false: one persistent phone per character; forced false for metadata-free inventories
|
||||
Keybind = "F1", -- false disables the configurable phone key mapping
|
||||
OpenRequestsPerMinute = 20,
|
||||
AllowMovement = true, -- true: game input stays active while the mobile phone is open
|
||||
HoldToLook = {
|
||||
Enabled = true, -- hold the configured control to hide the cursor and look around; independent of AllowMovement
|
||||
@@ -60,6 +61,52 @@ Config.Phone = {
|
||||
DeviceName = "iFruit Phone",
|
||||
}
|
||||
|
||||
-- Server-wide availability for bundled apps. Set an entry to false to hide it
|
||||
-- from every phone, the App Store and per-device app management.
|
||||
Config.Apps = {
|
||||
["app-store"] = true,
|
||||
banking = true,
|
||||
billing = true,
|
||||
calculator = true,
|
||||
calendar = true,
|
||||
camera = true,
|
||||
citymarkt = true,
|
||||
citywarn = true,
|
||||
clock = true,
|
||||
companies = true,
|
||||
crewlink = true,
|
||||
crypto = true,
|
||||
darkchat = true,
|
||||
feather = true,
|
||||
flare = true,
|
||||
fliptok = true,
|
||||
garage = true,
|
||||
health = true,
|
||||
house = true,
|
||||
["local-pages"] = true,
|
||||
mail = true,
|
||||
map = true,
|
||||
memory = true,
|
||||
memos = true,
|
||||
messages = true,
|
||||
minesweeper = true,
|
||||
music = true,
|
||||
["neon-drop"] = true,
|
||||
notes = true,
|
||||
["number-merge"] = true,
|
||||
phone = true,
|
||||
photos = true,
|
||||
picstagram = true,
|
||||
radio = true,
|
||||
settings = true,
|
||||
["sky-flappy"] = true,
|
||||
skyride = true,
|
||||
snake = true,
|
||||
["tower-stack"] = true,
|
||||
weather = true,
|
||||
["weazel-news"] = true,
|
||||
}
|
||||
|
||||
Config.TestData = {
|
||||
Enabled = false, -- development/test servers only; keep disabled in production
|
||||
Command = "phonetestdata",
|
||||
@@ -116,7 +163,7 @@ Config.Speaker = {
|
||||
}
|
||||
|
||||
Config.Calls = {
|
||||
VoiceProvider = "pma", -- yaca (alias: yaca-voice), pma (alias: pma-voice), saltychat (alias: salty)
|
||||
VoiceProvider = "pma", -- auto, yaca (alias: yaca-voice), pma (alias: pma-voice), saltychat (alias: salty)
|
||||
RingSeconds = 30,
|
||||
ContactNameMaxLength = 80,
|
||||
ContactNotesMaxLength = 500,
|
||||
@@ -301,6 +348,7 @@ Config.DarkChat = {
|
||||
VoiceMaxBase64Length = 360000,
|
||||
VoiceWaveformSamples = 48,
|
||||
CleanupIntervalSeconds = 30,
|
||||
CleanupBatchSize = 250,
|
||||
AllowedDisappearTimers = {
|
||||
[0] = true,
|
||||
[-1] = true, -- after reading
|
||||
|
||||
@@ -43,6 +43,7 @@ Locales["de"] = {
|
||||
invalid_sim = "Diese SIM-Karte besitzt ungültige Metadaten.",
|
||||
phone_required = "Du benötigst ein Handy in deinem Inventar.",
|
||||
operation_in_progress = "Eine andere Handy-Aktion wird bereits ausgeführt.",
|
||||
rate_limited = "Das Handy wurde zu oft geöffnet. Versuch es gleich erneut.",
|
||||
voice_unavailable = "Der konfigurierte Sprachdienst ist nicht verfügbar.",
|
||||
default = "Das Handy konnte nicht geöffnet werden.",
|
||||
},
|
||||
|
||||
@@ -43,6 +43,7 @@ Locales["en"] = {
|
||||
invalid_sim = "This SIM card has invalid metadata.",
|
||||
phone_required = "You need a phone in your inventory.",
|
||||
operation_in_progress = "Another phone operation is already in progress.",
|
||||
rate_limited = "The phone was opened too often. Try again in a moment.",
|
||||
voice_unavailable = "The configured phone voice service is unavailable.",
|
||||
default = "The phone could not be opened.",
|
||||
},
|
||||
|
||||
@@ -43,6 +43,7 @@ Locales["es"] = {
|
||||
invalid_sim = "Esta tarjeta SIM tiene metadatos inválidos.",
|
||||
phone_required = "Necesitas un teléfono en tu inventario.",
|
||||
operation_in_progress = "Otra operación de teléfono ya está en progreso.",
|
||||
rate_limited = "El teléfono se abrió demasiadas veces. Inténtalo de nuevo en un momento.",
|
||||
voice_unavailable = "El servicio de voz configurado para el teléfono no está disponible.",
|
||||
default = "No se pudo abrir el teléfono.",
|
||||
},
|
||||
|
||||
@@ -11,6 +11,15 @@ local provider_aliases = {
|
||||
|
||||
local function resolve_provider()
|
||||
local configured = tostring(Config.Calls.VoiceProvider or "")
|
||||
if configured == "auto" then
|
||||
for _, candidate in ipairs({ "yaca", "pma", "saltychat" }) do
|
||||
if GetResourceState(provider_resources[candidate]) == "started" then
|
||||
return candidate
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local selected = provider_aliases[configured] or configured
|
||||
local resource_name = provider_resources[selected]
|
||||
if resource_name and GetResourceState(resource_name) == "started" then
|
||||
|
||||
@@ -23,8 +23,8 @@ end
|
||||
|
||||
function Bridge.Inventory.GetSlotsWithItem(source, item_name, metadata)
|
||||
local matches = {}
|
||||
for index, item in pairs(inventory:SearchInventory(source, item_name, metadata) or {}) do
|
||||
local normalized = normalize(item, tonumber(index) or index)
|
||||
for _, slot_id in ipairs(inventory:GetSlotIdsWithItem(source, item_name, metadata) or {}) do
|
||||
local normalized = Bridge.Inventory.GetSlot(source, slot_id)
|
||||
if normalized and normalized.name == item_name
|
||||
and Bridge.Inventory.MetadataMatches(normalized.metadata, metadata) then
|
||||
matches[#matches + 1] = normalized
|
||||
@@ -40,7 +40,7 @@ function Bridge.Inventory.SetSlotMetadata(source, slot_id, metadata)
|
||||
end
|
||||
|
||||
local requested_metadata = type(metadata) == "table" and metadata or {}
|
||||
if inventory:SetItemMetadata(source, slot.slot, requested_metadata) ~= true then
|
||||
if inventory:SetItemMetadata(source, slot.slot, requested_metadata) == false then
|
||||
return false
|
||||
end
|
||||
local updated = Bridge.Inventory.GetSlot(source, slot.slot)
|
||||
|
||||
@@ -41,6 +41,15 @@ end
|
||||
|
||||
local function resolve_call_provider()
|
||||
local configured = tostring(Config.Calls.VoiceProvider or "")
|
||||
if configured == "auto" then
|
||||
for _, candidate in ipairs({ "yaca", "pma", "saltychat" }) do
|
||||
if GetResourceState(call_provider_resources[candidate]) == "started" then
|
||||
return candidate
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local selected = call_provider_aliases[configured] or configured
|
||||
local resource_name = call_provider_resources[selected]
|
||||
if resource_name and GetResourceState(resource_name) == "started" then
|
||||
|
||||
@@ -75,6 +75,21 @@ Bridge.Debug("debug", "[sky_phone] Client script initialized.", { always = true
|
||||
|
||||
local locale, locale_name = SkyPhoneLocales.Resolve(Config.Bridge.Locale)
|
||||
|
||||
local function apply_disabled_apps(payload)
|
||||
if type(payload) ~= "table" then
|
||||
return
|
||||
end
|
||||
|
||||
local disabled = {}
|
||||
for app_id, enabled in pairs(Config.Apps or {}) do
|
||||
if type(app_id) == "string" and enabled == false then
|
||||
disabled[#disabled + 1] = app_id
|
||||
end
|
||||
end
|
||||
table.sort(disabled)
|
||||
payload.disabledApps = disabled
|
||||
end
|
||||
|
||||
local function send_admin_panel_open()
|
||||
SendNUIMessage({
|
||||
type = "admin:open",
|
||||
@@ -136,6 +151,7 @@ AddEventHandler("sky_phone:configurator:updated", function()
|
||||
refresh_admin_command_suggestion()
|
||||
SkyPhoneApps.SendCatalog()
|
||||
if is_open and device_payload then
|
||||
apply_disabled_apps(device_payload)
|
||||
device_payload.lang = locale_name
|
||||
device_payload.locales = locale.Nui
|
||||
device_payload.fallbackLocales = Locales.en.Nui
|
||||
@@ -175,6 +191,22 @@ local function close_phone(close_device_session)
|
||||
end
|
||||
end
|
||||
|
||||
local function request_phone_open(callback_name)
|
||||
if is_open or open_requested then
|
||||
return true
|
||||
end
|
||||
|
||||
open_requested = true
|
||||
local result = Bridge.Callbacks.Trigger(callback_name, {})
|
||||
if type(result) == "table" and result.success == true then
|
||||
return true
|
||||
end
|
||||
|
||||
open_requested = false
|
||||
open_without_focus = false
|
||||
return false
|
||||
end
|
||||
|
||||
local function toggle_phone(open, no_focus)
|
||||
if open ~= nil and type(open) ~= "boolean" then
|
||||
Bridge.Debug("error", "[sky_phone] Rejected invalid phone toggle state.")
|
||||
@@ -190,6 +222,9 @@ local function toggle_phone(open, no_focus)
|
||||
should_open = not (is_open or open_requested)
|
||||
end
|
||||
if not should_open then
|
||||
if open == nil and open_requested and not is_open then
|
||||
return true
|
||||
end
|
||||
close_phone()
|
||||
return true
|
||||
end
|
||||
@@ -202,12 +237,7 @@ local function toggle_phone(open, no_focus)
|
||||
return true
|
||||
end
|
||||
|
||||
local result = Bridge.Callbacks.Trigger("sky_phone:device:open-request", {})
|
||||
if type(result) ~= "table" or result.success ~= true then
|
||||
open_without_focus = false
|
||||
return false
|
||||
end
|
||||
return true
|
||||
return request_phone_open("sky_phone:device:open-request")
|
||||
end
|
||||
|
||||
SkyPhoneClient.Toggle = toggle_phone
|
||||
@@ -217,13 +247,16 @@ AddEventHandler("sky_phone:client:forceClose", function()
|
||||
end)
|
||||
|
||||
local function run_development_command()
|
||||
if is_open or open_requested then
|
||||
if is_open then
|
||||
close_phone()
|
||||
return
|
||||
end
|
||||
if open_requested then
|
||||
return
|
||||
end
|
||||
|
||||
open_without_focus = false
|
||||
Bridge.Callbacks.Trigger("sky_phone:device:development-open", {})
|
||||
request_phone_open("sky_phone:device:development-open")
|
||||
end
|
||||
|
||||
refresh_development_command = function()
|
||||
@@ -256,13 +289,16 @@ end
|
||||
refresh_development_command()
|
||||
|
||||
local function run_phone_toggle()
|
||||
if is_open or open_requested then
|
||||
if is_open then
|
||||
close_phone()
|
||||
return
|
||||
end
|
||||
if open_requested then
|
||||
return
|
||||
end
|
||||
|
||||
open_without_focus = false
|
||||
Bridge.Callbacks.Trigger("sky_phone:device:open-request", {})
|
||||
request_phone_open("sky_phone:device:open-request")
|
||||
end
|
||||
|
||||
RegisterCommand("sky_phone_live_activity_open", function()
|
||||
@@ -271,8 +307,7 @@ RegisterCommand("sky_phone_live_activity_open", function()
|
||||
end
|
||||
|
||||
open_home_requested = true
|
||||
local result = Bridge.Callbacks.Trigger("sky_phone:device:open-request", {})
|
||||
if not result or not result.success then
|
||||
if not request_phone_open("sky_phone:device:open-request") then
|
||||
open_home_requested = false
|
||||
end
|
||||
end, false)
|
||||
@@ -459,6 +494,10 @@ end)
|
||||
RegisterNetEvent("sky_phone:device:open", function(data)
|
||||
if type(data) ~= "table" or type(data.device) ~= "table" or type(data.device.imei) ~= "string" then
|
||||
Bridge.Debug("error", "[sky_phone] Rejected invalid device open data.")
|
||||
if not is_open then
|
||||
open_requested = false
|
||||
open_without_focus = false
|
||||
end
|
||||
return
|
||||
end
|
||||
Bridge.Debug(
|
||||
@@ -468,6 +507,7 @@ RegisterNetEvent("sky_phone:device:open", function(data)
|
||||
tostring(data.account ~= nil),
|
||||
{ always = true }
|
||||
)
|
||||
apply_disabled_apps(data)
|
||||
device_payload = data
|
||||
update_equipped_phone_number(data)
|
||||
open_requested = true
|
||||
@@ -484,6 +524,7 @@ RegisterNetEvent("sky_phone:device:updated", function(data)
|
||||
Bridge.Debug("error", "[sky_phone] Rejected invalid device update data.")
|
||||
return
|
||||
end
|
||||
apply_disabled_apps(data)
|
||||
device_payload = data
|
||||
update_equipped_phone_number(data)
|
||||
SendNUIMessage({ type = "device:updated", data = data })
|
||||
@@ -499,8 +540,10 @@ RegisterNetEvent("sky_phone:device:invalidated", function()
|
||||
end)
|
||||
|
||||
RegisterNetEvent("sky_phone:device:error", function(error_code)
|
||||
if not is_open and not open_requested then
|
||||
if not is_open then
|
||||
open_requested = false
|
||||
open_without_focus = false
|
||||
open_home_requested = false
|
||||
end
|
||||
Bridge.Debug(
|
||||
"debug",
|
||||
|
||||
@@ -373,6 +373,9 @@ end
|
||||
|
||||
local function app_metadata(app_id)
|
||||
if BUILTIN_APPS[app_id] then
|
||||
if not SkyPhone.IsAppEnabled(app_id) then
|
||||
return nil
|
||||
end
|
||||
return {
|
||||
defaultInstalled = DEFAULT_INSTALLED_APPS[app_id] == true,
|
||||
removable = PROTECTED_APPS[app_id] ~= true,
|
||||
@@ -481,6 +484,7 @@ Bridge.Callbacks.Register("sky_phone:admin:bootstrap", function(source)
|
||||
success = true,
|
||||
data = {
|
||||
players = players,
|
||||
disabledApps = SkyPhone.GetDisabledApps(),
|
||||
stats = {
|
||||
online = #players,
|
||||
devices = tonumber(stats.devices) or 0,
|
||||
|
||||
@@ -1368,8 +1368,6 @@ ensure_schema()
|
||||
migrate_crypto_keys()
|
||||
initialize_markets()
|
||||
|
||||
AddEventHandler("sky_phone:configurator:serverUpdated", initialize_markets)
|
||||
|
||||
local function reconcile_settlements(include_recent)
|
||||
local age_clause = include_recent and "" or " AND settlement.`updated_at` < DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 5 MINUTE)"
|
||||
local rows = Bridge.Database.Query([[
|
||||
@@ -1416,8 +1414,6 @@ local function reconcile_settlements(include_recent)
|
||||
end
|
||||
end
|
||||
|
||||
reconcile_settlements(true)
|
||||
|
||||
local function crypto_random_int(minimum, maximum, failure_message)
|
||||
local value = exports[GetCurrentResourceName()]:CryptoRandomInt(minimum, maximum)
|
||||
if type(value) ~= "number" then
|
||||
@@ -1500,21 +1496,38 @@ local function advance_market_cycle(config, dynamics)
|
||||
return dynamics.cycle_bias
|
||||
end
|
||||
|
||||
CreateThread(function()
|
||||
while true do
|
||||
local scheduler_generation = 0
|
||||
|
||||
local function start_crypto_schedulers()
|
||||
scheduler_generation = scheduler_generation + 1
|
||||
if Config.Crypto.Enabled ~= true then
|
||||
return
|
||||
end
|
||||
|
||||
local generation = scheduler_generation
|
||||
reconcile_settlements(true)
|
||||
|
||||
CreateThread(function()
|
||||
while scheduler_generation == generation and Config.Crypto.Enabled == true do
|
||||
Wait(5 * 60 * 1000)
|
||||
if scheduler_generation ~= generation or Config.Crypto.Enabled ~= true then
|
||||
break
|
||||
end
|
||||
reconcile_settlements(false)
|
||||
end
|
||||
end)
|
||||
end)
|
||||
|
||||
CreateThread(function()
|
||||
while true do
|
||||
CreateThread(function()
|
||||
while scheduler_generation == generation and Config.Crypto.Enabled == true do
|
||||
local tick_seconds = crypto_random_int(
|
||||
Config.Crypto.PriceTickMinimumSeconds,
|
||||
Config.Crypto.PriceTickMaximumSeconds + 1,
|
||||
"[sky_phone] Crypto entropy provider did not return a market tick interval."
|
||||
)
|
||||
Wait(tick_seconds * 1000)
|
||||
if scheduler_generation ~= generation or Config.Crypto.Enabled ~= true then
|
||||
break
|
||||
end
|
||||
with_exchange_lock(function()
|
||||
local market_count = crypto_random_int(
|
||||
Config.Crypto.MarketsPerTickMinimum,
|
||||
@@ -1628,6 +1641,15 @@ CreateThread(function()
|
||||
end
|
||||
end)
|
||||
end
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
local function refresh_crypto_runtime()
|
||||
initialize_markets()
|
||||
start_crypto_schedulers()
|
||||
end
|
||||
|
||||
AddEventHandler("sky_phone:configurator:serverUpdated", refresh_crypto_runtime)
|
||||
start_crypto_schedulers()
|
||||
|
||||
end)
|
||||
|
||||
@@ -931,10 +931,16 @@ end)
|
||||
CreateThread(function()
|
||||
while true do
|
||||
Wait(Config.DarkChat.CleanupIntervalSeconds * 1000)
|
||||
local batch_size = math.max(
|
||||
1,
|
||||
math.floor(tonumber(Config.DarkChat.CleanupBatchSize) or 250)
|
||||
)
|
||||
Bridge.Database.Query([[
|
||||
DELETE FROM `sky_phone_darkchat_messages`
|
||||
WHERE `expires_at` IS NOT NULL AND `expires_at` <= CURRENT_TIMESTAMP
|
||||
]], {})
|
||||
ORDER BY `expires_at`, `id`
|
||||
LIMIT ?
|
||||
]], { batch_size })
|
||||
end
|
||||
end)
|
||||
|
||||
|
||||
@@ -54,12 +54,28 @@ Bridge.Debug("debug", "[sky_phone] Server initialization started after database
|
||||
|
||||
SkyPhone = {}
|
||||
|
||||
function SkyPhone.IsAppEnabled(app_id)
|
||||
return type(Config.Apps) ~= "table" or Config.Apps[app_id] ~= false
|
||||
end
|
||||
|
||||
function SkyPhone.GetDisabledApps()
|
||||
local disabled = {}
|
||||
for app_id, enabled in pairs(Config.Apps or {}) do
|
||||
if type(app_id) == "string" and enabled == false then
|
||||
disabled[#disabled + 1] = app_id
|
||||
end
|
||||
end
|
||||
table.sort(disabled)
|
||||
return disabled
|
||||
end
|
||||
|
||||
local sessions = {}
|
||||
local preferred_device_imeis = {}
|
||||
local equipped_phone_numbers = {}
|
||||
local equipped_phone_identifiers = {}
|
||||
local equipped_phone_sources = {}
|
||||
local operation_attempts = {}
|
||||
local phone_open_in_progress = {}
|
||||
local character_device_cache = {}
|
||||
|
||||
local function trim(value)
|
||||
@@ -548,6 +564,7 @@ local function bootstrap(source, security, security_loaded)
|
||||
|
||||
return {
|
||||
token = session.token,
|
||||
disabledApps = SkyPhone.GetDisabledApps(),
|
||||
phoneNumberFormat = {
|
||||
length = Config.Sim.NumberLength,
|
||||
groups = Config.Sim.NumberGroups,
|
||||
@@ -839,7 +856,7 @@ function SkyPhone.RefreshDevice(imei)
|
||||
end
|
||||
end
|
||||
|
||||
local function open_phone(source, used_item)
|
||||
local function perform_phone_open(source, used_item)
|
||||
local opened_at = GetGameTimer()
|
||||
Bridge.Debug(
|
||||
"debug",
|
||||
@@ -926,6 +943,38 @@ local function open_phone(source, used_item)
|
||||
return true
|
||||
end
|
||||
|
||||
local function open_phone(source, used_item)
|
||||
if phone_open_in_progress[source] then
|
||||
TriggerClientEvent("sky_phone:device:error", source, "operation_in_progress")
|
||||
return false, "operation_in_progress"
|
||||
end
|
||||
|
||||
local request_limit = math.max(
|
||||
1,
|
||||
math.floor(tonumber(Config.Phone.OpenRequestsPerMinute) or 20)
|
||||
)
|
||||
if not SkyPhone.AllowOperation(source, "phone_open", request_limit, 60) then
|
||||
TriggerClientEvent("sky_phone:device:error", source, "rate_limited")
|
||||
return false, "rate_limited"
|
||||
end
|
||||
|
||||
phone_open_in_progress[source] = true
|
||||
local completed, success, error_code = pcall(perform_phone_open, source, used_item)
|
||||
phone_open_in_progress[source] = nil
|
||||
if not completed then
|
||||
Bridge.Debug(
|
||||
"error",
|
||||
"[sky_phone] Phone open failed unexpectedly for source %s: %s",
|
||||
tostring(source),
|
||||
tostring(success),
|
||||
{ always = true }
|
||||
)
|
||||
TriggerClientEvent("sky_phone:device:error", source, "request_failed")
|
||||
return false, "request_failed"
|
||||
end
|
||||
return success, error_code
|
||||
end
|
||||
|
||||
phone_open_handler = open_phone
|
||||
flush_pending_phone_opens()
|
||||
|
||||
@@ -1028,6 +1077,7 @@ AddEventHandler("playerDropped", function()
|
||||
sessions[source] = nil
|
||||
discard_equipped_phone_number(source)
|
||||
operation_attempts[source] = nil
|
||||
phone_open_in_progress[source] = nil
|
||||
character_device_cache[source] = nil
|
||||
preferred_device_imeis[source] = nil
|
||||
end)
|
||||
@@ -1036,6 +1086,7 @@ AddEventHandler("onResourceStop", function(resource_name)
|
||||
if resource_name == GetCurrentResourceName() then
|
||||
sessions = {}
|
||||
operation_attempts = {}
|
||||
phone_open_in_progress = {}
|
||||
character_device_cache = {}
|
||||
preferred_device_imeis = {}
|
||||
equipped_phone_numbers = {}
|
||||
|
||||
@@ -35,6 +35,7 @@ end
|
||||
local CLIENT_CONFIG_KEYS = {
|
||||
AdminPanel = true,
|
||||
Animations = true,
|
||||
Apps = true,
|
||||
Banking = true,
|
||||
Billing = true,
|
||||
Bridge = true,
|
||||
|
||||
@@ -36,6 +36,7 @@ Config.Phone = {
|
||||
Item = "phone",
|
||||
Unique = true, -- true: data follows each phone item; false: one persistent phone per character; forced false for metadata-free inventories
|
||||
Keybind = "F1", -- false disables the configurable phone key mapping
|
||||
OpenRequestsPerMinute = 20,
|
||||
AllowMovement = true, -- true: game input stays active while the mobile phone is open
|
||||
HoldToLook = {
|
||||
Enabled = true, -- hold the configured control to hide the cursor and look around; independent of AllowMovement
|
||||
@@ -45,6 +46,52 @@ Config.Phone = {
|
||||
DeviceName = "iFruit Phone",
|
||||
}
|
||||
|
||||
-- Server-wide availability for bundled apps. Set an entry to false to hide it
|
||||
-- from every phone, the App Store and per-device app management.
|
||||
Config.Apps = {
|
||||
["app-store"] = true,
|
||||
banking = true,
|
||||
billing = true,
|
||||
calculator = true,
|
||||
calendar = true,
|
||||
camera = true,
|
||||
citymarkt = true,
|
||||
citywarn = true,
|
||||
clock = true,
|
||||
companies = true,
|
||||
crewlink = true,
|
||||
crypto = true,
|
||||
darkchat = true,
|
||||
feather = true,
|
||||
flare = true,
|
||||
fliptok = true,
|
||||
garage = true,
|
||||
health = true,
|
||||
house = true,
|
||||
["local-pages"] = true,
|
||||
mail = true,
|
||||
map = true,
|
||||
memory = true,
|
||||
memos = true,
|
||||
messages = true,
|
||||
minesweeper = true,
|
||||
music = true,
|
||||
["neon-drop"] = true,
|
||||
notes = true,
|
||||
["number-merge"] = true,
|
||||
phone = true,
|
||||
photos = true,
|
||||
picstagram = true,
|
||||
radio = true,
|
||||
settings = true,
|
||||
["sky-flappy"] = true,
|
||||
skyride = true,
|
||||
snake = true,
|
||||
["tower-stack"] = true,
|
||||
weather = true,
|
||||
["weazel-news"] = true,
|
||||
}
|
||||
|
||||
Config.TestData = {
|
||||
Enabled = false, -- development/test servers only; keep disabled in production
|
||||
Command = "phonetestdata",
|
||||
@@ -101,7 +148,7 @@ Config.Speaker = {
|
||||
}
|
||||
|
||||
Config.Calls = {
|
||||
VoiceProvider = "pma", -- yaca (alias: yaca-voice), pma (alias: pma-voice), saltychat (alias: salty)
|
||||
VoiceProvider = "pma", -- auto, yaca (alias: yaca-voice), pma (alias: pma-voice), saltychat (alias: salty)
|
||||
RingSeconds = 30,
|
||||
ContactNameMaxLength = 80,
|
||||
ContactNotesMaxLength = 500,
|
||||
@@ -286,6 +333,7 @@ Config.DarkChat = {
|
||||
VoiceMaxBase64Length = 360000,
|
||||
VoiceWaveformSamples = 48,
|
||||
CleanupIntervalSeconds = 30,
|
||||
CleanupBatchSize = 250,
|
||||
AllowedDisappearTimers = {
|
||||
[0] = true,
|
||||
[-1] = true, -- after reading
|
||||
|
||||
@@ -159,13 +159,16 @@ function one:GetSlot(_, slot)
|
||||
return one_items[slot]
|
||||
end
|
||||
|
||||
function one:SearchInventory()
|
||||
return one_items
|
||||
function one:GetSlotIdsWithItem(_, item_name, metadata)
|
||||
assert(item_name == "phone")
|
||||
if metadata and metadata.imei ~= one_items[7].metadata.imei then
|
||||
return {}
|
||||
end
|
||||
return { 7 }
|
||||
end
|
||||
|
||||
function one:SetItemMetadata(_, slot, metadata)
|
||||
one_items[slot].metadata = metadata
|
||||
return true
|
||||
end
|
||||
|
||||
function one:CanCarryItem()
|
||||
|
||||
Reference in New Issue
Block a user