FIX - harden inventory contracts and resolve phone interaction issues (#20)

* FIX - repair inventory metadata and ESX contracts

Use the stable core_inventory metadata setter and validate the complete inventory adapter contract after provider bridges load. Preserve the real ESX configuration error instead of cascading into a missing RegisterUsableItem failure.

* FIX - clarify phone opening without SIM

* FIX - harden inventory and device contracts

Validate SIM number configuration before item handling, preserve QB metadata mutation contracts, and propagate phone-open failures to callers. Extend regression coverage for inventory bridges and device bootstrap errors.

* FIX - resolve reported phone interaction issues

Stop game-input passthrough while NUI text fields are focused and add an independent hold-to-look option. Propagate SIM number formatting to the frontend, migrate configured currency symbols to utf8mb4, and clarify SkyRide, VaultX, and DarkChat behavior with focused contract coverage.

* FIX - detect missing packaged phone UI

Validate the generated NUI entrypoint, its referenced assets, and required static media when the resource starts. Print an actionable server-console warning for incomplete source archives, package phone sounds through the manifest, and cover the detection contract with Lua regressions.
This commit is contained in:
DerEchteAlec
2026-08-21 17:19:27 +02:00
committed by GitHub
parent 698420584d
commit 97db163cd0
34 changed files with 912 additions and 79 deletions
+2
View File
@@ -17,6 +17,8 @@ for (const requiredFragment of [
"fx_version 'cerulean'",
"node_version '22'",
"use_experimental_fxv2_oal 'yes'",
"'source/server/nui_build_check.lua'",
"'source/html/sounds/**'",
"ui_page 'source/html/index.html'",
]) {
if (!manifest.includes(requiredFragment)) {
+20 -5
View File
@@ -29,7 +29,7 @@
<p align="center">
<a href="https://www.sky-systems.net/shop/phone#live-demo"><strong>Live demo</strong></a>
&nbsp;&bull;&nbsp;
<a href="https://github.com/sky-systems/sky_phone"><strong>Download for free</strong></a>
<a href="https://github.com/sky-systems/sky_phone/releases/latest"><strong>Download for free</strong></a>
&nbsp;&bull;&nbsp;
<a href="https://discord.gg/sky-systems"><strong>Discord support</strong></a>
</p>
@@ -44,7 +44,7 @@ Sky Phone is a **free and open-source FiveM phone script** built to give serious
This is not a cut-down free alternative. Sky Phone includes the core experience server owners and players expect from a leading paid FiveM phone, plus full source access, no purchase price, no feature paywalls, and no forced ecosystem lock-in.
The production frontend is included, so a normal server installation does not require Node.js or pnpm.
The production frontend is included in the published release package, so a normal server installation does not require Node.js or pnpm. GitHub's automatically generated source archives do not contain that build.
## Why Sky Phone stands out
@@ -160,8 +160,8 @@ Start the selected voice resource before Sky Phone.
## Quick installation
1. Copy the resource into your FiveM resources directory.
2. Keep the resource folder name `sky_phone`.
1. Download and extract the latest published [Sky Phone release](https://github.com/sky-systems/sky_phone/releases/latest). Do not use GitHub's automatically generated "Source code" archives for a server installation because they do not contain the built frontend.
2. Copy the included resource into your FiveM resources directory and keep its folder name `sky_phone`.
3. Start `oxmysql`, your framework, inventory, and voice resource before Sky Phone.
4. Review `sky_phone/config/config.lua` and `sky_phone/config/media.lua`.
5. Add the required inventory items.
@@ -182,6 +182,13 @@ Replace the example framework, inventory, and voice resources with the providers
Sky Phone creates and upgrades its database tables automatically. A manual SQL import is normally not required.
### How players open the phone
- Give the player the item configured in `Config.Phone.Item` (default: `phone`).
- Players can use that inventory item or press the configured keybind (default: `F1`).
- The keybind still verifies server-side that the player owns a configured phone item; it does not bypass inventory ownership.
- A SIM card is **not required to open or use the phone itself**. With `Config.Sim.Enabled = true`, only cellular features such as calls and messages require an inserted SIM.
## Configuration
Customer settings are organized in:
@@ -344,7 +351,7 @@ With unique phones, using an inventory item selects that exact handset whenever
| SIM mode | Behavior |
| --- | --- |
| `Enabled = true` | A registered or anonymous physical SIM item is required for cellular service. |
| `Enabled = true` | The phone opens with or without a SIM. A registered or anonymous physical SIM item is required only for cellular service such as calls and messages. |
| `Enabled = false` | Sky Phone creates a persistent automatic number for devices without a SIM. Physical SIM items are not required. |
When changing these modes on an existing production server, restart the resource and test with a copy of the database first. The first phone used after switching to non-unique mode may adopt an existing valid IMEI so its local data is preserved.
@@ -602,12 +609,20 @@ pnpm build
### The phone item does nothing
- A warning that the inventory returned no configured phone item means an item definition, `Config.Phone.Item`, inventory selection, or player ownership problem. It is not caused by a missing SIM card.
- Confirm the framework and inventory are supported and started first.
- Confirm the item name matches `Config.Phone.Item`.
- Confirm the item is usable.
- In unique mode, confirm the phone is non-stackable.
- Check the server console for inventory adapter warnings.
### The resource starts but the phone UI is missing
- On startup, the server console prints `SKY PHONE UI BUILD IS MISSING OR INCOMPLETE` and lists the missing or invalid packaged files.
- Install the latest published release package rather than GitHub's automatically generated source archive.
- Confirm `sky_phone/source/html/index.html`, `assets`, `img`, and `sounds` exist.
- Developers working from source must run the frontend production build before starting the resource.
### Calls connect without audio
- Confirm the configured voice resource is running.
+7 -2
View File
@@ -70,7 +70,7 @@ import type {
CompanyChangedPayload,
CompanyUnreadCounts,
} from '@/types/companies'
import type { PhoneCall } from '@/types/phone'
import type { PhoneCall, PhoneNumberFormat } from '@/types/phone'
import type { DynamicIslandActivity } from '@/types/dynamicIsland'
import type { EasyShareEvent } from '@/types/easyshare'
import type { CryptoMarketChangedData } from '@/types/crypto'
@@ -84,6 +84,7 @@ import { formatTimer } from '@/utils/clock'
import { parsePhonePreferences } from '@/utils/preferences'
import { getHairlinePixelStyle } from '@/utils/rendering'
import { isTextInputElement } from '@/utils/textInputFocus'
import { configurePhoneNumberFormat } from '@/utils/phone'
import { isTrustedRootMessageSource } from '@/utils/windowMessages'
import SpringboardView from '@/views/SpringboardView.vue'
@@ -133,6 +134,7 @@ type NavigationEventData = {
type SimPickerPayload = {
choices: SimPhoneChoice[]
number: string
phoneNumberFormat?: PhoneNumberFormat
}
type NotificationEventData = Omit<PhoneNotificationInput, 'device'> & {
@@ -491,6 +493,7 @@ function getViewportScale(): number {
}
function hydratePhone(payload: PhoneOpenPayload): void {
configurePhoneNumberFormat(payload.phoneNumberFormat)
if (payload.device?.imei) {
companies.bindDeviceScope(
payload.device.imei,
@@ -1222,7 +1225,9 @@ function onMessage(event: MessageEvent<AppMessage>): void {
loadUnlockedPhoneData()
}
} else if (event.data?.type === 'sim:picker' && event.data.data) {
simPicker.value = event.data.data as unknown as SimPickerPayload
const payload = event.data.data as unknown as SimPickerPayload
configurePhoneNumberFormat(payload.phoneNumberFormat)
simPicker.value = payload
} else if (event.data?.type === 'sim:picker-close') {
simPicker.value = null
}
+8 -3
View File
@@ -38,6 +38,7 @@ export type PhoneOpenPayload = {
locales?: LocaleTree
memos?: DeviceBootstrap['memos']
notes?: DeviceBootstrap['notes']
phoneNumberFormat?: DeviceBootstrap['phoneNumberFormat']
player?: DeviceBootstrap['player']
security?: DeviceSecurity
token?: string
@@ -638,7 +639,8 @@ const cryptoFallbackLocales = {
invalid_handle: 'Use 320 letters, numbers, dots or underscores.',
handle_taken: 'That VaultX handle is already taken.',
profile_exists: 'This character already owns a VaultX profile.',
invalid_password: 'Password must be 872 characters.',
invalid_password:
'Use 872 characters with uppercase, lowercase, a number, and a special character.',
password_mismatch: 'The passwords do not match.',
accept_terms: 'Confirm that this is a fictional in-game wallet.',
invalid_credentials: 'The password is incorrect.',
@@ -2086,7 +2088,7 @@ const defaultLocales: LocaleTree = {
to: 'To:',
connectPrivately: 'Connect privately',
newChatBody:
'Enter an exact Dark-ID or invitation code. Unknown identities require confirmation.',
"Enter another person's Dark-ID or invitation code. You can share your own ID from your profile.",
darkIdOrInvite: 'Dark-ID or invitation code',
continue: 'Continue',
contacts: 'DarkChat Contacts',
@@ -3036,6 +3038,8 @@ const defaultLocales: LocaleTree = {
viewRides: 'View Ride Options',
change: 'Change',
requestRide: 'Request SkyRide',
playerDriverNotice:
'SkyRide matches you with real player drivers. A driver must be online and accept your request.',
serviceMeta: '{eta} min away · {seats} seats',
distanceMeters: '{distance} m',
distanceKilometers: '{distance} km',
@@ -3106,7 +3110,8 @@ const defaultLocales: LocaleTree = {
cancelled: 'Cancelled',
},
statusBody: {
searching: 'We are matching you with a nearby driver.',
searching:
'Your request is waiting for an available player driver to accept it.',
accepted: 'Your driver is preparing to pick you up.',
driver_arriving: 'Your driver is on the way to your pickup.',
arrived: 'Your driver is waiting at the pickup point.',
+2 -1
View File
@@ -1,6 +1,6 @@
import type { Note } from '@/utils/notes'
import type { MemoDto } from '@/types/memos'
import type { PhoneSim } from '@/types/phone'
import type { PhoneNumberFormat, PhoneSim } from '@/types/phone'
export type DeviceDataEntry<T = unknown> = {
payload: T
@@ -50,6 +50,7 @@ export type DeviceBootstrap = {
device: PhoneDevice
memos: MemoDto[]
notes: Note[]
phoneNumberFormat: PhoneNumberFormat
player: PhonePlayerIdentity
security: DeviceSecurity
token: string
+5
View File
@@ -1,5 +1,10 @@
export type SimType = 'registered' | 'anonymous'
export type PhoneNumberFormat = {
groups: number[]
length: number
}
export type PhoneSim = {
id: string
number: string
+14 -1
View File
@@ -1,6 +1,10 @@
import { describe, expect, it } from 'vitest'
import { formatPhoneNumber, normalizePhoneNumber } from './phone'
import {
configurePhoneNumberFormat,
formatPhoneNumber,
normalizePhoneNumber,
} from './phone'
describe('phone numbers', () => {
it('normalizes formatted ten digit values', () => {
@@ -13,4 +17,13 @@ describe('phone numbers', () => {
expect(formatPhoneNumber('5551')).toBe('555 1')
expect(formatPhoneNumber(5551234567)).toBe('555 123 4567')
})
it('uses the server-provided number length and display groups', () => {
configurePhoneNumberFormat({ groups: [4, 3, 3], length: 10 })
expect(formatPhoneNumber('0171234567')).toBe('0171 234 567')
expect(normalizePhoneNumber('0171 234 567')).toBe('0171234567')
configurePhoneNumberFormat()
})
})
+45 -4
View File
@@ -1,12 +1,53 @@
import type { PhoneNumberFormat } from '@/types/phone'
export const PHONE_NUMBER_LENGTH = 10
const DEFAULT_PHONE_NUMBER_FORMAT: PhoneNumberFormat = {
groups: [3, 3, 4],
length: PHONE_NUMBER_LENGTH,
}
let phoneNumberFormat: PhoneNumberFormat = DEFAULT_PHONE_NUMBER_FORMAT
export function configurePhoneNumberFormat(value?: PhoneNumberFormat): void {
const length = value?.length
const groups = value?.groups
if (
typeof length !== 'number' ||
!Number.isInteger(length) ||
length < 1 ||
length > 24 ||
!Array.isArray(groups) ||
groups.length === 0 ||
groups.some((group) => !Number.isInteger(group) || group < 1)
) {
phoneNumberFormat = DEFAULT_PHONE_NUMBER_FORMAT
return
}
phoneNumberFormat = {
groups: [...groups],
length,
}
}
export function normalizePhoneNumber(value: string): string | null {
const digits = value.replace(/\D/g, '')
return digits.length === PHONE_NUMBER_LENGTH ? digits : null
return digits.length === phoneNumberFormat.length ? digits : null
}
export function formatPhoneNumber(value: string | number): string {
const digits = String(value).replace(/\D/g, '').slice(0, PHONE_NUMBER_LENGTH)
const groups = [digits.slice(0, 3), digits.slice(3, 6), digits.slice(6, 10)]
return groups.filter(Boolean).join(' ')
const digits = String(value)
.replace(/\D/g, '')
.slice(0, phoneNumberFormat.length)
const formatted: string[] = []
let offset = 0
for (const size of phoneNumberFormat.groups) {
const group = digits.slice(offset, offset + size)
if (!group) break
formatted.push(group)
offset += size
}
if (offset < digits.length) formatted.push(digits.slice(offset))
return formatted.join(' ')
}
@@ -119,6 +119,8 @@ describe('VaultX crypto app contracts', () => {
expect(source).not.toContain('class="auth-panel__heading"')
expect(source).not.toContain('class="auth-action-dock"')
expect(source).not.toContain('class="password-rules"')
expect(source).toContain('class="auth-password-hint"')
expect(source).toContain("t('auth.ruleSpecial')")
expect(source).toMatch(
/\.auth-shell\s*\{[^}]*width:\s*100%;[^}]*max-width:\s*338px;/s,
)
+10
View File
@@ -824,6 +824,10 @@ onUnmounted(() => {
:size="18"
/></button></template
></SkyField>
<p v-if="authMode === 'register'" class="auth-password-hint">
{{ t('auth.ruleLength') }} · {{ t('auth.ruleMixed') }} ·
{{ t('auth.ruleNumber') }} · {{ t('auth.ruleSpecial') }}
</p>
<p v-if="formError" class="error">{{ formError }}</p>
<SkyButton block large class="auth-submit" type="submit">{{
t(
@@ -2060,6 +2064,12 @@ onUnmounted(() => {
.auth-form {
gap: 12px;
}
.auth-password-hint {
margin: -3px 4px 0;
color: rgba(255, 255, 255, 0.58);
font-size: 11px;
line-height: 15px;
}
.auth-account {
display: grid;
grid-template-columns: 40px minmax(0, 1fr) 18px;
@@ -37,10 +37,17 @@ describe('DarkChatApp Sky UI contract', () => {
expect(newChat).toContain('class="dc-recipient-field"')
expect(newChat).toContain('layout="inline"')
expect(newChat).toContain('class="dc-new-chat-contacts"')
expect(newChat).toContain("{{ t('newChatBody') }}")
expect(newChat).toContain("{{ phone.t('Common.cancel') }}")
expect(newChat).not.toContain('class="dc-hero"')
})
it('rejects the current profile identifiers before opening confirmation', () => {
expect(source).toContain('darkchat.profile?.darkId')
expect(source).toContain('darkchat.profile?.inviteCode')
expect(source).toContain("showToast(errorText('self_chat'))")
})
it('keeps the search visually compact without shrinking its wrapper', () => {
expect(source).toMatch(
/\.dc-search :deep\(\.sky-searchbar__control\),[\s\S]*?height:\s*38px;[\s\S]*?min-height:\s*38px;/,
+22
View File
@@ -427,6 +427,20 @@ function closeReport(): void {
function requestStart(value = identifier.value): void {
const clean = value.trim()
if (!clean) return
const ownIdentifiers = [
darkchat.profile?.darkId,
darkchat.profile?.inviteCode,
]
if (
ownIdentifiers.some(
(ownIdentifier) =>
ownIdentifier?.toLocaleLowerCase(phone.lang) ===
clean.toLocaleLowerCase(phone.lang),
)
) {
showToast(errorText('self_chat'))
return
}
pendingIdentifier.value = clean
safetyOpen.value = true
}
@@ -1262,6 +1276,13 @@ onBeforeUnmount(() => {
padding-top: 2px;
}
.dc-new-chat-intro {
margin: 2px 2px 12px;
color: var(--dc-muted);
font-size: 13px;
line-height: 18px;
}
.dc-recipient-field,
.dc-new-chat-contacts {
margin: 0 calc(var(--sky-page-gutter) * -1) 12px;
@@ -2071,6 +2092,7 @@ onBeforeUnmount(() => {
</template>
</SkyNavbar>
<SkyScrollArea padded class="dc-new-chat-scroll">
<p class="dc-new-chat-intro">{{ t('newChatBody') }}</p>
<SkyList class="dc-recipient-field" density="compact" flush>
<SkyField
:label="t('to')"
@@ -18,6 +18,11 @@ describe('SkyRide app layout', () => {
expect(source).toContain('skyride-history-card__distance')
})
it('explains that requests require an online player driver', () => {
expect(source).toContain("phone.t('Apps.skyride.playerDriverNotice')")
expect(source).toContain('class="skyride-player-driver-notice"')
})
it('aligns rider and activity surfaces to one content width', () => {
expect(source).toMatch(
/\.skyride-location-list,\s*\.skyride-activity-list\s*\{[^}]*margin-inline:\s*2px !important;/s,
+10
View File
@@ -848,6 +848,9 @@ onBeforeUnmount(() => {
</k-button>
</div>
</k-card>
<p class="skyride-player-driver-notice">
{{ phone.t('Apps.skyride.playerDriverNotice') }}
</p>
<k-button
large
rounded
@@ -2109,6 +2112,13 @@ onBeforeUnmount(() => {
font-weight: 650;
}
.skyride-player-driver-notice {
margin: 2px 7px 8px;
color: var(--ride-muted);
font-size: 12px;
line-height: 17px;
}
.skyride-primary :deep(svg) {
margin-left: 4px;
}
+7 -3
View File
@@ -29,6 +29,10 @@ Config.Phone = {
Unique = true, -- true: data follows each phone item; false: one persistent phone per character; hex/esx require false
Keybind = "F1", -- false disables the configurable phone key mapping
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
Control = 19, -- INPUT_CHARACTER_WHEEL (Left Alt by default)
},
DevelopmentCommand = true,
DeviceName = "iFruit Phone",
}
@@ -65,9 +69,9 @@ Config.Sim = {
Enabled = true, -- false: devices receive a persistent random number automatically; hex/esx require false
RegisteredItem = "sky_phone_sim_registered",
AnonymousItem = "sky_phone_sim_anonymous",
NumberLength = 10,
NumberPrefix = "",
NumberGroups = { 3, 3, 4 },
NumberLength = 10, -- total number of digits, including NumberPrefix
NumberPrefix = "", -- digits only; use "555", not "555-"
NumberGroups = { 3, 3, 4 }, -- display groups separated by spaces
}
-- =============================================================================
+5 -5
View File
@@ -606,7 +606,7 @@ Locales["de"] = {
createIdentity = "Dunkle Identität erstellen", createIdentityBody = "Erstell eine private Identität, bevor du ein DarkChat-Gespräch anfängst.",
security = "Sicherheit", privateNetwork = "Privates Einladungs-Nur-Netzwerk", noResults = "Keine Ergebnisse", noResultsBody = "Versucht es mit einem anderen Alias oder Dark-ID.",
noChats = "Keine privaten Chats", noChatsBody = "Verbinde dich über eine Dark-ID oder einen Einladungscode. Es gibt keine öffentliche Suche.", newChat = "Neuer Chat", to = "An:",
connectPrivately = "Privat verbinden", newChatBody = "Gib einen genauen Dark-ID oder Einladungscode ein. Unbekannte Identitäten erfordern eine Bestätigung.", darkIdOrInvite = "Dark-ID oder Einladungscode", continue = "Weiter", contacts = "DarkChat Kontakte", contactsHint = "Gespeicherte private Identitäten erscheinen nur auf deinem DarkChat-Konto.", noContacts = "Keine gespeicherten DarkChat Kontakte", private = "Privat", shareIdentity = "Tippe auf deine private Identität",
connectPrivately = "Privat verbinden", newChatBody = "Gib die Dark-ID oder den Einladungscode einer anderen Person ein. Deine eigene ID kannst du im Profil teilen.", darkIdOrInvite = "Dark-ID oder Einladungscode", continue = "Weiter", contacts = "DarkChat Kontakte", contactsHint = "Gespeicherte private Identitäten erscheinen nur auf deinem DarkChat-Konto.", noContacts = "Keine gespeicherten DarkChat Kontakte", private = "Privat", shareIdentity = "Tippe auf deine private Identität",
message = "Dunkle Botschaft", activeNow = "Anteil der Tätigkeiten", encryptedSession = "Privatsitzung", serverPrivate = "Private server-gespeicherte Konversation",
emoji = "Emoji", gif = "GIF", gifs = "GIF", photo = "Foto", video = "Video", attachPhoto = "Foto anhängen", takePhoto = "Foto machen", attachGif = "GIF-Anlage", attachVideo = "Video anhängen", searchGifs = "GIF durchsuchen", loadMore = "Mehr laden",
voiceMessage = "Sprachnachricht", sending = "Senden", failed = "Nicht geliefert", delivered = "Lieferung", read = "Lesen", replying = "Antwort auf",
@@ -622,7 +622,7 @@ Locales["de"] = {
microphoneUnavailable = "Das Mikrofon ist nicht verfügbar.", recordingTooLarge = "Die Sprachbotschaft ist zu groß.",
errors = {
sign_out_failed = "Sky Cloud konnte sich nicht ausmelden.",
not_authenticated = "Melde dich zuerst bei deinem Sky Cloud-Konto an.", invalid_dark_id = "Gib einen gültigen Dark-ID oder Einladungscode ein.", profile_not_found = "Diese private Identität wurde nicht gefunden.", self_chat = "Du kannst deine Identität nicht selbst sagen.", conversation_not_found = "Diese Unterhaltung ist nicht verfügbar.", blocked = "Nachrichten werden in diesem Gespräch blockiert.", invalid_message = "Gib eine gültige Nachricht ein.", invalid_gif = "Diese GIF ist ungültig.", invalid_voice = "Diese Sprachnachricht ist ungültig.", invalid_attachment = "Dieses Foto oder Video ist nicht verfügbar.", invalid_profile = "Prüfe deinen Alias und deine Privatsphäre.", rate_limited = "Zu viele Anfragen. Versuch es gleich erneut.", gif_provider_unconfigured = "GIF-Suche ist nicht konfiguriert.", gif_provider_unauthorized = "Der GIF-Anbieterschlüssel ist ungültig.", gif_provider_rate_limited = "Die GIF-Suche ist ausgelastet. Versuch es gleich erneut.", gif_provider_failed = "GIFs sind vorübergehend nicht verfügbar.", default = "DarkChat konnte die Anfrage nicht abschließen.",
not_authenticated = "Melde dich zuerst bei deinem Sky Cloud-Konto an.", invalid_dark_id = "Gib eine gültige Dark-ID oder einen Einladungscode ein.", profile_not_found = "Diese private Identität wurde nicht gefunden.", self_chat = "Du kannst deiner eigenen Identität keine Nachricht senden.", conversation_not_found = "Diese Unterhaltung ist nicht verfügbar.", blocked = "Nachrichten werden in diesem Gespräch blockiert.", invalid_message = "Gib eine gültige Nachricht ein.", invalid_gif = "Dieses GIF ist ungültig.", invalid_voice = "Diese Sprachnachricht ist ungültig.", invalid_attachment = "Dieses Foto oder Video ist nicht verfügbar.", invalid_profile = "Prüfe deinen Alias und deine Privatsphäre.", rate_limited = "Zu viele Anfragen. Versuch es gleich erneut.", gif_provider_unconfigured = "GIF-Suche ist nicht konfiguriert.", gif_provider_unauthorized = "Der GIF-Anbieterschlüssel ist ungültig.", gif_provider_rate_limited = "Die GIF-Suche ist ausgelastet. Versuch es gleich erneut.", gif_provider_failed = "GIFs sind vorübergehend nicht verfügbar.", default = "DarkChat konnte die Anfrage nicht abschließen.",
},
},
messages = {
@@ -1069,7 +1069,7 @@ Locales["de"] = {
profile = { verified = "Charakterbesitz verifiziert", walletKey = "Öffentlicher Crypto-Key", walletKeyBody = "Teile diesen Key, um Krypto zu empfangen. Er gewährt keinen Kontozugriff.", copyKey = "Kopieren", copied = "Kopiert", shareKey = "Teilen", shareTitle = "Mein VaultX Crypto-Key", shareText = "Sende Krypto an meinen öffentlichen VaultX-Key:", trades = "Trades", volume = "Volumen", memberSince = "Mitglied seit", preferences = "Einstellungen", priceAlerts = "Kurswarnungen", priceAlertsBody = "Bei auffälligen Marktbewegungen benachrichtigen.", confirmations = "Handelsbestätigung", confirmationsBody = "Zusätzliche Bestätigung vor der Ausführung behalten.", hideBalances = "Privatsphäre-Modus", hideBalancesBody = "Beträge in VaultX verbergen.", identity = "Profilidentität", edit = "Bearbeiten", account = "Konto & Sicherheit", editTitle = "Profil bearbeiten", editBody = "Ändere deinen Benutzernamen oder lege ein neues Passwort fest. Dein aktuelles Passwort bestätigt Identitätsänderungen.", currentPassword = "Aktuelles Passwort", currentPasswordPlaceholder = "Für Namens- oder Passwortänderungen erforderlich", newPassword = "Neues Passwort", newPasswordPlaceholder = "Leer lassen, um das aktuelle Passwort zu behalten", passwordSecurity = "Passwörter werden speicherintensiv gehasht und nie wieder angezeigt.", saved = "Profil sicher gespeichert.", save = "Profil speichern", saveChanges = "Änderungen speichern", securityTitle = "Geschütztes Profil", securityBody = "Dein Profil bleibt an diesen Framework-Charakter und die Finanzsitzung gebunden." },
activityTypes = { buy = "Asset-Kauf", sell = "Asset-Verkauf", deposit = "Bankeinzahlung", withdrawal = "Bankauszahlung", transfer_in = "Krypto empfangen", transfer_out = "Krypto gesendet" },
statuses = { completed = "Abgeschlossen", pending = "Prüfung ausstehend", failed = "Fehlgeschlagen", manual_review = "Manuelle Prüfung" },
errors = { invalid_profile = "Prüfe deine VaultX-Profileinstellungen.", invalid_handle = "Nutze 320 Buchstaben, Zahlen, Punkte oder Unterstriche.", handle_taken = "Dieser VaultX-Benutzername ist bereits vergeben.", profile_exists = "Dieser Charakter besitzt bereits ein VaultX-Profil.", invalid_password = "Das Passwort muss 872 Zeichen lang sein.", password_mismatch = "Die Passwörter stimmen nicht überein.", accept_terms = "Bestätige, dass dies ein fiktives Ingame-Wallet ist.", invalid_credentials = "Das Passwort ist falsch.", locked = "Zu viele Versuche. Versuche es später erneut.", not_authenticated = "Logge dich zuerst bei VaultX ein.", invalid_amount = "Gib einen positiven ganzen Betrag ein.", invalid_quantity = "Gib eine Menge mit bis zu sechs Nachkommastellen ein.", invalid_wallet_key = "Gib einen vollständigen VX-Empfangsschlüssel ein.", recipient_not_found = "Zu diesem Key wurde kein aktives VaultX-Konto gefunden.", self_transfer = "Du kannst keine Krypto an deinen eigenen Key senden.", invalid_transfer = "Prüfe Empfänger, Asset und Krypto-Menge.", recipient_limit_exceeded = "Das Empfänger-Wallet kann diese Menge nicht aufnehmen.", insufficient_funds = "Dein verfügbarer Bestand reicht nicht aus.", insufficient_liquidity = "Die Börse kann diese Menge derzeit nicht ausführen.", quote_expired = "Das Angebot ist abgelaufen. Fordere ein neues an.", quote_unavailable = "Das Angebot ist nicht mehr verfügbar.", market_unavailable = "Dieser Markt ist vorübergehend pausiert.", limit_exceeded = "Die Anfrage überschreitet ein Börsenlimit.", duplicate_request = "Diese Anfrage wurde bereits verarbeitet.", rate_limited = "Zu viele Anfragen. Versuche es gleich erneut.", settlement_pending = "Eine Geldbewegung wartet bereits auf Prüfung.", service_unavailable = "VaultX ist vorübergehend nicht verfügbar.", request_failed = "Die sichere Börsenanfrage ist fehlgeschlagen.", default = "VaultX konnte die Anfrage nicht abschließen." },
errors = { invalid_profile = "Prüfe deine VaultX-Profileinstellungen.", invalid_handle = "Nutze 320 Buchstaben, Zahlen, Punkte oder Unterstriche.", handle_taken = "Dieser VaultX-Benutzername ist bereits vergeben.", profile_exists = "Dieser Charakter besitzt bereits ein VaultX-Profil.", invalid_password = "Nutze 872 Zeichen mit Groß- und Kleinbuchstaben, einer Zahl und einem Sonderzeichen.", password_mismatch = "Die Passwörter stimmen nicht überein.", accept_terms = "Bestätige, dass dies ein fiktives Ingame-Wallet ist.", invalid_credentials = "Das Passwort ist falsch.", locked = "Zu viele Versuche. Versuche es später erneut.", not_authenticated = "Logge dich zuerst bei VaultX ein.", invalid_amount = "Gib einen positiven ganzen Betrag ein.", invalid_quantity = "Gib eine Menge mit bis zu sechs Nachkommastellen ein.", invalid_wallet_key = "Gib einen vollständigen VX-Empfangsschlüssel ein.", recipient_not_found = "Zu diesem Key wurde kein aktives VaultX-Konto gefunden.", self_transfer = "Du kannst keine Krypto an deinen eigenen Key senden.", invalid_transfer = "Prüfe Empfänger, Asset und Krypto-Menge.", recipient_limit_exceeded = "Das Empfänger-Wallet kann diese Menge nicht aufnehmen.", insufficient_funds = "Dein verfügbarer Bestand reicht nicht aus.", insufficient_liquidity = "Die Börse kann diese Menge derzeit nicht ausführen.", quote_expired = "Das Angebot ist abgelaufen. Fordere ein neues an.", quote_unavailable = "Das Angebot ist nicht mehr verfügbar.", market_unavailable = "Dieser Markt ist vorübergehend pausiert.", limit_exceeded = "Die Anfrage überschreitet ein Börsenlimit.", duplicate_request = "Diese Anfrage wurde bereits verarbeitet.", rate_limited = "Zu viele Anfragen. Versuche es gleich erneut.", settlement_pending = "Eine Geldbewegung wartet bereits auf Prüfung.", service_unavailable = "VaultX ist vorübergehend nicht verfügbar.", request_failed = "Die sichere Börsenanfrage ist fehlgeschlagen.", default = "VaultX konnte die Anfrage nicht abschließen." },
},
banking = {
name = "Banking", welcome = "Willkommen zurück", totalBalance = "Gesamtsaldo", recentPeriod = "in jüngster Zeit",
@@ -1207,7 +1207,7 @@ Locales["de"] = {
zoomIn = "Vergrößern", zoomOut = "Auszoomen", resetMap = "Kartenansicht zurücksetzen",
savedPlaces = "Gespeicherte Plätze", savedPlace = "Gespeicherter Ort", quickDestinations = "Schnelle Reiseziele",
quickLocations = { ["legion-square"] = "Legionsplatz", ["diamond-casino"] = "Diamant Casino", airport = "Flughafen Los Santos", vinewood = "Weinholz" },
viewRides = "Ansicht Einstellungen für die Fahrt", change = "Veränderung", requestRide = "Anfrage SkyRide",
viewRides = "Fahrtoptionen anzeigen", change = "Ändern", requestRide = "SkyRide anfragen", playerDriverNotice = "SkyRide vermittelt an echte Spieler-Fahrer. Ein Fahrer muss online sein und deine Anfrage annehmen.",
serviceMeta = "{eta} min entfernt · {seats} Sitze", distanceMeters = "{distance} m",
distanceKilometers = "{distance} km", distanceMiles = "{distance} mi", durationMinutes = "{minutes} min",
fare = "Fahrpreis", calculatedFare = "Berechnet", customFare = "Eigener Preis",
@@ -1241,7 +1241,7 @@ Locales["de"] = {
arrived = "Fahrer angekommen", in_progress = "Auf dem Weg", completed = "Abgeschlossen", cancelled = "Abgebrochen",
},
statusBody = {
searching = "Wir passen zu dir mit einem Fahrer in der Nähe.", accepted = "Dein Fahrer will dich abholen.",
searching = "Deine Anfrage wartet darauf, dass ein verfügbarer Spieler-Fahrer sie annimmt.", accepted = "Dein Fahrer bereitet die Abholung vor.",
driver_arriving = "Dein Fahrer ist auf dem Weg zu deinem Pickup.", arrived = "Dein Fahrer wartet am Abholpunkt.",
in_progress = "Du bist auf dem Weg zu deinem Ziel.", completed = "Du bist am Ziel angekommen.",
cancelled = "Diese Fahrt ist nicht mehr aktiv.",
+4 -4
View File
@@ -606,7 +606,7 @@ Locales["en"] = {
createIdentity = "Create Dark Identity", createIdentityBody = "Create a private identity before starting a DarkChat conversation.",
security = "Security", privateNetwork = "Private invitation-only network", noResults = "No Results", noResultsBody = "Try another alias or Dark-ID.",
noChats = "No Private Chats", noChatsBody = "Connect with a Dark-ID or invitation code. There is no public search.", newChat = "New Chat", to = "To:",
connectPrivately = "Connect privately", newChatBody = "Enter an exact Dark-ID or invitation code. Unknown identities require confirmation.", darkIdOrInvite = "Dark-ID or invitation code", continue = "Continue", contacts = "DarkChat Contacts", contactsHint = "Saved private identities appear only on your DarkChat account.", noContacts = "No saved DarkChat contacts", private = "Private", shareIdentity = "Tap to share your private identity",
connectPrivately = "Connect privately", newChatBody = "Enter another person's Dark-ID or invitation code. You can share your own ID from your profile.", darkIdOrInvite = "Dark-ID or invitation code", continue = "Continue", contacts = "DarkChat Contacts", contactsHint = "Saved private identities appear only on your DarkChat account.", noContacts = "No saved DarkChat contacts", private = "Private", shareIdentity = "Tap to share your private identity",
message = "Dark message", activeNow = "Activity shared", encryptedSession = "Private session", serverPrivate = "Private server-stored conversation",
emoji = "Emoji", gif = "GIF", gifs = "GIFs", photo = "Photo", video = "Video", attachPhoto = "Attach Photo", takePhoto = "Take Photo", attachGif = "Attach GIF", attachVideo = "Attach Video", searchGifs = "Search GIFs", loadMore = "Load More",
voiceMessage = "Voice message", sending = "Sending", failed = "Not delivered", delivered = "Delivered", read = "Read", replying = "Replying to",
@@ -1069,7 +1069,7 @@ Locales["en"] = {
profile = { verified = "Character ownership verified", walletKey = "Public crypto key", walletKeyBody = "Share this key to receive crypto. It cannot access your account.", copyKey = "Copy", copied = "Copied", shareKey = "Share", shareTitle = "My VaultX crypto key", shareText = "Send crypto to my public VaultX key:", trades = "Trades", volume = "Volume", memberSince = "Member since", preferences = "Preferences", priceAlerts = "Price alerts", priceAlertsBody = "Notify me about notable market moves.", confirmations = "Trade confirmations", confirmationsBody = "Keep an extra confirmation before execution.", hideBalances = "Privacy mode", hideBalancesBody = "Hide balances across VaultX.", identity = "Profile identity", edit = "Edit", account = "Account & security", editTitle = "Edit profile", editBody = "Update your handle or set a new password. Your current password confirms identity changes.", currentPassword = "Current password", currentPasswordPlaceholder = "Required for handle or password changes", newPassword = "New password", newPasswordPlaceholder = "Leave blank to keep your current password", passwordSecurity = "Passwords use memory-hard hashing and are never shown again.", saved = "Profile saved securely.", save = "Save profile", saveChanges = "Save changes", securityTitle = "Protected profile", securityBody = "Your profile stays bound to this framework character and financial session." },
activityTypes = { buy = "Asset purchase", sell = "Asset sale", deposit = "Bank deposit", withdrawal = "Bank withdrawal", transfer_in = "Crypto received", transfer_out = "Crypto sent" },
statuses = { completed = "Completed", pending = "Pending review", failed = "Failed", manual_review = "Manual review" },
errors = { invalid_profile = "Check your VaultX profile settings.", invalid_handle = "Use 320 letters, numbers, dots or underscores.", handle_taken = "That VaultX handle is already taken.", profile_exists = "This character already owns a VaultX profile.", invalid_password = "Password must be 872 characters.", password_mismatch = "The passwords do not match.", accept_terms = "Confirm that this is a fictional in-game wallet.", invalid_credentials = "The password is incorrect.", locked = "Too many attempts. Try again later.", not_authenticated = "Sign in to VaultX first.", invalid_amount = "Enter a positive whole amount.", invalid_quantity = "Enter a quantity with up to six decimals.", invalid_wallet_key = "Enter a complete VX receiving key.", recipient_not_found = "No active VaultX account uses this key.", self_transfer = "You cannot send crypto to your own key.", invalid_transfer = "Verify a recipient, asset and valid crypto amount.", recipient_limit_exceeded = "The recipient wallet cannot hold that amount.", insufficient_funds = "Your available balance is too low.", insufficient_liquidity = "The exchange cannot fill that amount right now.", quote_expired = "This quote expired. Request a new one.", quote_unavailable = "The quote is no longer available.", market_unavailable = "This market is temporarily paused.", limit_exceeded = "This request exceeds an exchange limit.", duplicate_request = "This request was already processed.", rate_limited = "Too many requests. Try again shortly.", settlement_pending = "A money transfer is already pending review.", service_unavailable = "VaultX is temporarily unavailable.", request_failed = "The secure exchange request failed.", default = "VaultX could not complete the request." },
errors = { invalid_profile = "Check your VaultX profile settings.", invalid_handle = "Use 320 letters, numbers, dots or underscores.", handle_taken = "That VaultX handle is already taken.", profile_exists = "This character already owns a VaultX profile.", invalid_password = "Use 872 characters with uppercase, lowercase, a number, and a special character.", password_mismatch = "The passwords do not match.", accept_terms = "Confirm that this is a fictional in-game wallet.", invalid_credentials = "The password is incorrect.", locked = "Too many attempts. Try again later.", not_authenticated = "Sign in to VaultX first.", invalid_amount = "Enter a positive whole amount.", invalid_quantity = "Enter a quantity with up to six decimals.", invalid_wallet_key = "Enter a complete VX receiving key.", recipient_not_found = "No active VaultX account uses this key.", self_transfer = "You cannot send crypto to your own key.", invalid_transfer = "Verify a recipient, asset and valid crypto amount.", recipient_limit_exceeded = "The recipient wallet cannot hold that amount.", insufficient_funds = "Your available balance is too low.", insufficient_liquidity = "The exchange cannot fill that amount right now.", quote_expired = "This quote expired. Request a new one.", quote_unavailable = "The quote is no longer available.", market_unavailable = "This market is temporarily paused.", limit_exceeded = "This request exceeds an exchange limit.", duplicate_request = "This request was already processed.", rate_limited = "Too many requests. Try again shortly.", settlement_pending = "A money transfer is already pending review.", service_unavailable = "VaultX is temporarily unavailable.", request_failed = "The secure exchange request failed.", default = "VaultX could not complete the request." },
},
banking = {
name = "Banking", welcome = "Welcome back", totalBalance = "Total Balance", recentPeriod = "in recent activity",
@@ -1207,7 +1207,7 @@ Locales["en"] = {
zoomIn = "Zoom in", zoomOut = "Zoom out", resetMap = "Reset map view",
savedPlaces = "Saved places", savedPlace = "Saved place", quickDestinations = "Quick destinations",
quickLocations = { ["legion-square"] = "Legion Square", ["diamond-casino"] = "Diamond Casino", airport = "Los Santos Airport", vinewood = "Vinewood" },
viewRides = "View Ride Options", change = "Change", requestRide = "Request SkyRide",
viewRides = "View Ride Options", change = "Change", requestRide = "Request SkyRide", playerDriverNotice = "SkyRide matches you with real player drivers. A driver must be online and accept your request.",
serviceMeta = "{eta} min away · {seats} seats", distanceMeters = "{distance} m",
distanceKilometers = "{distance} km", distanceMiles = "{distance} mi", durationMinutes = "{minutes} min",
fare = "Trip fare", calculatedFare = "Calculated", customFare = "Own price",
@@ -1241,7 +1241,7 @@ Locales["en"] = {
arrived = "Driver arrived", in_progress = "On the way", completed = "Completed", cancelled = "Cancelled",
},
statusBody = {
searching = "We are matching you with a nearby driver.", accepted = "Your driver is preparing to pick you up.",
searching = "Your request is waiting for an available player driver to accept it.", accepted = "Your driver is preparing to pick you up.",
driver_arriving = "Your driver is on the way to your pickup.", arrived = "Your driver is waiting at the pickup point.",
in_progress = "You are on the way to your destination.", completed = "You have arrived at your destination.",
cancelled = "This ride is no longer active.",
+3
View File
@@ -76,6 +76,7 @@ server_scripts {
'config/media.lua',
'config/locales/en.lua',
'config/locales/de.lua',
'source/server/nui_build_check.lua',
'source/server/update_check.lua',
'source/bridge/server/database.lua',
'source/bridge/server/migrations.lua',
@@ -86,6 +87,7 @@ server_scripts {
'source/bridge/server/housing/*.lua',
'source/bridge/server/inventory.lua',
'source/bridge/server/inventory/*.lua',
'source/bridge/server/inventory_contract.lua',
'source/bridge/server/voice.lua',
'source/server/custom_apps.lua',
'source/server/media_metadata.lua',
@@ -150,6 +152,7 @@ files {
'source/html/index.html',
'source/html/assets/**',
'source/html/img/**',
'source/html/sounds/**',
'config/music/**',
}
+7 -9
View File
@@ -41,20 +41,18 @@ if not supported_inventories[configured_inventory] then
error(("[sky_phone] Unsupported or unavailable inventory '%s'. Configure a supported inventory adapter."):format(tostring(configured_inventory)))
end
Bridge.Inventory.Name = configured_inventory
if configured_inventory == "hex" or configured_inventory == "esx" then
if Bridge.Framework.GetName() ~= "esx" then
error(("[sky_phone] Inventory '%s' is only supported with ESX."):format(configured_inventory))
end
if Config.Phone.Unique ~= false then
error(("[sky_phone] Inventory '%s' cannot store unique phone metadata. Set Config.Phone.Unique = false or configure a metadata-capable inventory."):format(configured_inventory))
end
if Config.Sim.Enabled ~= false then
error(("[sky_phone] Inventory '%s' cannot store physical SIM metadata. Set Config.Sim.Enabled = false or configure a metadata-capable inventory."):format(configured_inventory))
Bridge.Inventory.ConfigurationError =
("[sky_phone] Inventory '%s' is only supported with ESX."):format(configured_inventory)
elseif Config.Phone.Unique ~= false or Config.Sim.Enabled ~= false then
Bridge.Inventory.ConfigurationError = ("[sky_phone] Inventory '%s' cannot store unique phone or physical SIM metadata. Set Config.Phone.Unique = false and Config.Sim.Enabled = false, or configure a metadata-capable inventory.")
:format(configured_inventory)
end
end
Bridge.Inventory.Name = configured_inventory
function Bridge.Inventory.NormalizeItem(item, metadata_field)
if not item then
return nil
@@ -38,13 +38,12 @@ function Bridge.Inventory.GetSlotsWithItem(source, item_name, metadata)
end
function Bridge.Inventory.SetSlotMetadata(source, slot_id, metadata)
local slot = Bridge.Inventory.GetSlot(source, slot_id)
if not slot then
local numeric_slot = tonumber(slot_id)
if not numeric_slot then
return false
end
inventory:updateMetadata(source, slot.slot, metadata or {})
local updated = Bridge.Inventory.GetSlot(source, slot.slot)
return updated and Bridge.Inventory.MetadataMatches(updated.metadata, metadata or {}) or false
inventory:setMetadata(source, numeric_slot, metadata or {})
return true
end
function Bridge.Inventory.CanCarryItem()
@@ -15,12 +15,16 @@ local function normalize(item)
return nil
end
local metadata = {}
for key, value in pairs(type(item.info) == "table" and item.info or {}) do
metadata[key] = value
end
return {
name = item.name,
slot = tonumber(item.slot),
count = tonumber(item.amount) or 0,
amount = tonumber(item.amount) or 0,
metadata = type(item.info) == "table" and item.info or {},
metadata = metadata,
}
end
@@ -77,11 +81,33 @@ function Bridge.Inventory.SetSlotMetadata(source, slot_id, metadata)
if not player then
return false
end
player.PlayerData.items[slot.slot].info = metadata or {}
local requested_metadata = type(metadata) == "table" and metadata or {}
player.PlayerData.items[slot.slot].info = requested_metadata
player.Functions.SetInventory(player.PlayerData.items, true)
return true
local updated = player.PlayerData.items[slot.slot]
return updated ~= nil and Bridge.Inventory.MetadataMatches(updated.info, requested_metadata)
end
return inventory:SetItemData(source, slot.name, "info", metadata or {}, slot.slot) == true
local amount = tonumber(slot.amount or slot.count) or 0
if amount <= 0 then
return false
end
local requested_metadata = type(metadata) == "table" and metadata or {}
if inventory:RemoveItem(source, slot.name, amount, slot.slot, "sky_phone:metadata-update") ~= true then
return false
end
if inventory:AddItem(source, slot.name, amount, slot.slot, requested_metadata, "sky_phone:metadata-update") ~= true then
return false
end
local updated = Bridge.Inventory.GetSlot(source, slot.slot)
return updated
and updated.name == slot.name
and updated.amount == amount
and Bridge.Inventory.MetadataMatches(updated.metadata, requested_metadata)
or false
end
function Bridge.Inventory.CanCarryItem(source, item_name, count)
@@ -0,0 +1,26 @@
local required_methods = {
"GetResourceName",
"GetSlot",
"GetSlotsWithItem",
"SetSlotMetadata",
"CanCarryItem",
"AddItem",
"RemoveItem",
"RegisterUsableItem",
}
local configuration_error = Bridge.Inventory.ConfigurationError
for _, method_name in ipairs(required_methods) do
if configuration_error then
Bridge.Inventory[method_name] = function()
error(configuration_error, 2)
end
elseif type(Bridge.Inventory[method_name]) ~= "function" then
local contract_error = ("[sky_phone] Inventory adapter '%s' is missing required method '%s'.")
:format(tostring(Bridge.Inventory.Name), method_name)
Bridge.Inventory[method_name] = function()
error(contract_error, 2)
end
end
end
+50 -8
View File
@@ -3,6 +3,16 @@ SkyPhoneFocus = {}
local blocked_phone_controls = { 24, 140, 141, 142, 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_config = Config.Phone.HoldToLook
local hold_to_look_enabled = type(hold_to_look_config) == "table" and hold_to_look_config.Enabled == true
local hold_to_look_control = hold_to_look_enabled and tonumber(hold_to_look_config.Control) or nil
if hold_to_look_enabled and (
not hold_to_look_control
or hold_to_look_control ~= math.floor(hold_to_look_control)
or hold_to_look_control < 0
) then
error("[sky_phone] Config.Phone.HoldToLook.Control must be a non-negative whole control index.")
end
local state = {
activity_suspended = false,
allow_movement = Config.Phone.AllowMovement,
@@ -13,6 +23,7 @@ local state = {
external_game_input = nil,
external_game_input_owner = nil,
is_open = false,
look_passthrough = false,
notification_focus = false,
payphone_focus = false,
sim_picker_open = false,
@@ -22,6 +33,14 @@ local block_game = false
local block_look = false
local game_input = false
local function allows_game_input(value)
local allowed = value.external_game_input
if allowed == nil then
allowed = value.allow_movement
end
return allowed == true
end
function SkyPhoneFocus.ApplyFocusedControls()
for _, group in ipairs(focused_control_groups) do
DisableAllControlActions(group)
@@ -51,20 +70,19 @@ function SkyPhoneFocus.Resolve(state)
if state.camera_active and not state.camera_nui_focused then
return { block_game = false, block_look = false, cursor = false, focused = true, game_input = true, keep_input = true }
end
local allow_game_input = state.external_game_input
if allow_game_input == nil then
allow_game_input = state.allow_movement
end
local game_input = state.is_open and allow_game_input and not state.camera_active
local game_input = state.is_open
and allows_game_input(state)
and not state.camera_active
and not state.text_input_focused
local focused = state.is_open
or state.notification_focus
or state.payphone_focus
or state.sim_picker_open
or (state.camera_active and state.camera_nui_focused)
local cursor = focused and not (state.is_open and state.cursor_disabled)
local cursor = focused and not (state.is_open and (state.cursor_disabled or state.look_passthrough))
return {
block_game = cursor and (not game_input or state.text_input_focused),
block_look = game_input and not state.cursor_disabled,
block_game = cursor and not game_input,
block_look = game_input and cursor,
cursor = cursor,
focused = focused,
game_input = game_input,
@@ -73,6 +91,16 @@ function SkyPhoneFocus.Resolve(state)
end
function SkyPhoneFocus.Reapply()
if state.look_passthrough and (
not hold_to_look_enabled
or not state.is_open
or state.cursor_disabled
or state.text_input_focused
or state.camera_active
or not allows_game_input(state)
) then
state.look_passthrough = false
end
local focus = SkyPhoneFocus.Resolve(state)
SetNuiFocus(focus.focused, focus.cursor)
SetNuiFocusKeepInput(focus.keep_input)
@@ -90,6 +118,7 @@ end
function SkyPhoneFocus.BeginNuiHydration()
-- Browser-owned focus claims cannot survive a CEF reload.
state.notification_focus = false
state.look_passthrough = false
state.text_input_focused = false
end
@@ -107,6 +136,7 @@ function SkyPhoneFocus.SetPhone(open, cursor_disabled)
state.cursor_disabled = false
state.external_game_input = nil
state.external_game_input_owner = nil
state.look_passthrough = false
state.text_input_focused = false
end
SkyPhoneFocus.Reapply()
@@ -124,6 +154,9 @@ end
function SkyPhoneFocus.SetTextInputFocused(active)
state.text_input_focused = active == true
if state.text_input_focused then
state.look_passthrough = false
end
SkyPhoneFocus.Reapply()
end
@@ -150,6 +183,7 @@ function SkyPhoneFocus.Reset()
state.external_game_input = nil
state.external_game_input_owner = nil
state.is_open = false
state.look_passthrough = false
state.notification_focus = false
state.payphone_focus = false
state.sim_picker_open = false
@@ -164,6 +198,14 @@ end
CreateThread(function()
while true do
if game_input or block_game then
local look_passthrough = hold_to_look_enabled
and game_input
and not state.cursor_disabled
and IsControlPressed(0, hold_to_look_control)
if look_passthrough ~= state.look_passthrough then
state.look_passthrough = look_passthrough
SkyPhoneFocus.Reapply()
end
if block_game then
SkyPhoneFocus.ApplyFocusedControls()
else
+5 -5
View File
@@ -699,7 +699,7 @@ local schema = {
{ name = "title", type = "VARCHAR(160) NOT NULL" },
{ name = "description", type = "VARCHAR(1000) NOT NULL DEFAULT ''" },
{ name = "amount", type = "BIGINT UNSIGNED NOT NULL" },
{ name = "currency", type = "VARCHAR(8) NOT NULL", characterSet = "ascii", collation = "ascii_general_ci" },
{ name = "currency", type = "VARCHAR(8) NOT NULL", characterSet = "utf8mb4", collation = "utf8mb4_unicode_ci" },
{ name = "status", type = "ENUM('open', 'processing', 'paid', 'disputed', 'cancelled', 'refunded') NOT NULL DEFAULT 'open'" },
{ name = "read_at", type = "DATETIME NULL" },
{ name = "issued_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
@@ -1001,8 +1001,8 @@ local schema = {
{
name = "handle",
type = "VARCHAR(24) NOT NULL",
characterSet = "ascii",
collation = "ascii_general_ci",
characterSet = "utf8mb4",
collation = "utf8mb4_unicode_ci",
},
{ name = "bio", type = "VARCHAR(160) NOT NULL DEFAULT ''" },
{ name = "avatar_media_id", type = "BIGINT UNSIGNED NULL" },
@@ -2156,8 +2156,8 @@ local schema = {
{
name = "currency",
type = "VARCHAR(8) NOT NULL",
characterSet = "ascii",
collation = "ascii_general_ci",
characterSet = "utf8mb4",
collation = "utf8mb4_unicode_ci",
},
{ name = "driver_vehicle_model", type = "VARCHAR(64) NULL" },
{ name = "driver_vehicle_color", type = "VARCHAR(64) NULL" },
+124
View File
@@ -0,0 +1,124 @@
local resource_name = GetCurrentResourceName()
local index_path = "source/html/index.html"
local required_static_files = {
"source/html/img/custom-app.svg",
"source/html/sounds/button.mp3",
}
local function load_non_empty_file(path)
local content = LoadResourceFile(resource_name, path)
if type(content) ~= "string" or content == "" then
return nil
end
return content
end
local function add_missing_path(missing_paths, seen_paths, path)
if seen_paths[path] then
return
end
seen_paths[path] = true
missing_paths[#missing_paths + 1] = path
end
local function normalize_entry_asset_path(url)
if type(url) ~= "string" then
return nil
end
local path = url:gsub("[?#].*$", "")
while path:sub(1, 2) == "./" do
path = path:sub(3)
end
if path:sub(1, 7) ~= "assets/" then
return nil
end
return "source/html/" .. path
end
local function collect_entry_asset_paths(index_html)
local paths = {}
local seen_paths = {}
for url in index_html:gmatch("src%s*=%s*[\"']([^\"']+)[\"']") do
local path = normalize_entry_asset_path(url)
if path and not seen_paths[path] then
seen_paths[path] = true
paths[#paths + 1] = path
end
end
for url in index_html:gmatch("href%s*=%s*[\"']([^\"']+)[\"']") do
local path = normalize_entry_asset_path(url)
if path and not seen_paths[path] then
seen_paths[path] = true
paths[#paths + 1] = path
end
end
return paths
end
local function find_missing_nui_files()
local missing_paths = {}
local seen_paths = {}
local index_html = load_non_empty_file(index_path)
if not index_html then
add_missing_path(missing_paths, seen_paths, index_path)
add_missing_path(missing_paths, seen_paths, "source/html/assets/* (generated entry assets)")
else
local entry_asset_paths = collect_entry_asset_paths(index_html)
if #entry_asset_paths == 0 then
add_missing_path(missing_paths, seen_paths, "source/html/assets/* (no entry asset referenced by index.html)")
else
for index = 1, #entry_asset_paths do
local path = entry_asset_paths[index]
if not load_non_empty_file(path) then
add_missing_path(missing_paths, seen_paths, path)
end
end
end
end
for index = 1, #required_static_files do
local path = required_static_files[index]
if not load_non_empty_file(path) then
add_missing_path(missing_paths, seen_paths, path)
end
end
return missing_paths
end
local function print_missing_nui_notice(missing_paths)
local border = "======================================================================"
local lines = {
("^1%s^0"):format(border),
"^1 SKY PHONE UI BUILD IS MISSING OR INCOMPLETE ^0",
("^1%s^0"):format(border),
"^3 The resource started, but the packaged phone UI cannot load.^0",
"",
"^3 Missing or invalid files:^0",
}
for index = 1, #missing_paths do
lines[#lines + 1] = ("^1 - %s^0"):format(missing_paths[index])
end
lines[#lines + 1] = ""
lines[#lines + 1] = "^3 Install the latest published release package, not GitHub's automatic source archive.^0"
lines[#lines + 1] = "^5 Release: https://github.com/sky-systems/sky_phone/releases/latest^0"
lines[#lines + 1] = "^3 Developers working from source must run build_frontend.bat before starting sky_phone.^0"
lines[#lines + 1] = ("^1%s^0"):format(border)
print(table.concat(lines, "\n"))
end
local missing_paths = find_missing_nui_files()
if #missing_paths > 0 then
print_missing_nui_notice(missing_paths)
end
+43 -11
View File
@@ -19,7 +19,8 @@ Bridge.Callbacks.Register("sky_phone:device:open-request", function(source)
return { success = true, data = { queued = true } }
end
return { success = phone_open_handler(source, nil) }
local success, error_code = phone_open_handler(source, nil)
return { success = success, error = error_code }
end)
Bridge.Callbacks.Register("sky_phone:device:development-open", function(source)
@@ -31,7 +32,8 @@ Bridge.Callbacks.Register("sky_phone:device:development-open", function(source)
return { success = true, data = { queued = true } }
end
return { success = phone_open_handler(source, nil) }
local success, error_code = phone_open_handler(source, nil)
return { success = success, error = error_code }
end)
AddEventHandler("playerDropped", function()
@@ -394,9 +396,10 @@ local function resolve_used_slot(source, used_item)
Bridge.Debug(
"warn",
"[sky_phone] No phone item slot was available for source %s (%s candidates).",
tostring(source),
tostring(#slots)
"[sky_phone] Inventory '%s' returned no '%s' phone item for source %s. This is an item/configuration problem, not a missing SIM card.",
tostring(Bridge.Inventory.GetResourceName()),
tostring(Config.Phone.Item),
tostring(source)
)
return nil, "phone_required"
end
@@ -547,6 +550,10 @@ local function bootstrap(source, security, security_loaded)
return {
token = session.token,
phoneNumberFormat = {
length = Config.Sim.NumberLength,
groups = Config.Sim.NumberGroups,
},
security = SkyPhoneSecurity.Status(device.imei, security, security_loaded),
device = {
imei = device.imei,
@@ -852,7 +859,7 @@ local function open_phone(source, used_item)
{ always = true }
)
TriggerClientEvent("sky_phone:device:error", source, slot_error)
return false
return false, slot_error
end
local imei, error_code = ensure_device(source, slot)
@@ -866,7 +873,7 @@ local function open_phone(source, used_item)
{ always = true }
)
TriggerClientEvent("sky_phone:device:error", source, error_code)
return false
return false, error_code
end
local prepared, prepare_error = SkyPhoneSim.PrepareDevice(source, slot, imei)
if not prepared then
@@ -877,8 +884,9 @@ local function open_phone(source, used_item)
imei,
tostring(prepare_error)
)
TriggerClientEvent("sky_phone:device:error", source, prepare_error or "request_failed")
return false
local error_code = prepare_error or "request_failed"
TriggerClientEvent("sky_phone:device:error", source, error_code)
return false, error_code
end
local security = SkyPhoneSecurity.Load(imei)
@@ -892,7 +900,20 @@ local function open_phone(source, used_item)
unlocked = security == nil,
}
preferred_device_imeis[source] = imei
local payload = bootstrap(source, security, true)
local payload, bootstrap_error = bootstrap(source, security, true)
if not payload then
local error_code = type(bootstrap_error) == "table" and bootstrap_error.error or "request_failed"
Bridge.Debug(
"error",
"[sky_phone] Phone bootstrap failed for source %s slot %s IMEI %s: %s.",
tostring(source),
tostring(slot.slot),
imei,
tostring(error_code)
)
TriggerClientEvent("sky_phone:device:error", source, error_code)
return false, error_code
end
Bridge.Debug(
"debug",
"[sky_phone] Triggering client open for source %s slot %s IMEI %s account_linked=%s after %sms.",
@@ -932,7 +953,18 @@ function SkyPhone.OpenDeviceForCall(source, imei)
}
end
preferred_device_imeis[source] = imei
TriggerClientEvent("sky_phone:device:open", source, bootstrap(source))
local payload, bootstrap_error = bootstrap(source)
if not payload then
Bridge.Debug(
"error",
"[sky_phone] Call notification bootstrap failed for source %s IMEI %s: %s.",
tostring(source),
tostring(imei),
tostring(type(bootstrap_error) == "table" and bootstrap_error.error or "request_failed")
)
return false
end
TriggerClientEvent("sky_phone:device:open", source, payload)
return true
end
+12
View File
@@ -2,6 +2,14 @@ Bridge.Database.AfterMigration("sky_phone", function()
SkyPhoneSim = {}
local valid_number_configuration, number_configuration_error = SkyPhoneSimNumber.ValidateConfiguration(
Config.Sim.NumberLength,
Config.Sim.NumberPrefix
)
if not valid_number_configuration then
error(("[sky_phone] Invalid SIM number configuration: %s."):format(number_configuration_error))
end
local unique_phones = Config.Phone.Unique ~= false
local sim_cards_enabled = Config.Sim.Enabled ~= false
local pending_insertions = {}
@@ -393,6 +401,10 @@ local function use_sim(source, used_item)
TriggerClientEvent("sky_phone:sim:picker", source, {
choices = choices,
number = sim.phone_number,
phoneNumberFormat = {
length = Config.Sim.NumberLength,
groups = Config.Sim.NumberGroups,
},
})
return true
end
+19
View File
@@ -1,9 +1,25 @@
SkyPhoneSimNumber = {}
function SkyPhoneSimNumber.ValidateConfiguration(length, prefix)
if type(length) ~= "number" or length ~= math.floor(length) or length < 1 or length > 24 then
return false, "NumberLength must be a whole number between 1 and 24"
end
if type(prefix) ~= "string" or prefix:find("%D") then
return false, "NumberPrefix must contain digits only"
end
if #prefix > length then
return false, "NumberPrefix cannot be longer than NumberLength"
end
return true
end
function SkyPhoneSimNumber.Normalize(value, length, prefix)
if type(value) ~= "string" and type(value) ~= "number" then
return nil
end
if not SkyPhoneSimNumber.ValidateConfiguration(length, prefix) then
return nil
end
local number = tostring(value):gsub("%D", "")
if #number ~= length or number:sub(1, #prefix) ~= prefix then
return nil
@@ -26,6 +42,9 @@ function SkyPhoneSimNumber.FromEntropy(entropy, length, prefix)
if type(entropy) ~= "string" or entropy == "" then
return nil
end
if not SkyPhoneSimNumber.ValidateConfiguration(length, prefix) then
return nil
end
local source = entropy:gsub("%D", "")
if source == "" then
return nil
+2 -2
View File
@@ -360,7 +360,7 @@ CREATE TABLE IF NOT EXISTS `sky_phone_billing_invoices` (
`title` VARCHAR(160) NOT NULL,
`description` VARCHAR(1000) NOT NULL DEFAULT '',
`amount` BIGINT UNSIGNED NOT NULL,
`currency` VARCHAR(8) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL,
`currency` VARCHAR(8) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
`status` ENUM('open', 'processing', 'paid', 'disputed', 'cancelled', 'refunded') NOT NULL DEFAULT 'open',
`read_at` DATETIME NULL,
`issued_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
@@ -944,7 +944,7 @@ CREATE TABLE IF NOT EXISTS `sky_phone_skyride_rides` (
`duration_seconds` INT UNSIGNED NOT NULL,
`price` INT UNSIGNED NOT NULL,
`payout_amount` INT UNSIGNED NOT NULL,
`currency` VARCHAR(8) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL,
`currency` VARCHAR(8) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
`driver_vehicle_model` VARCHAR(64) NULL,
`driver_vehicle_color` VARCHAR(64) NULL,
`driver_vehicle_plate` VARCHAR(16) NULL,
+31 -6
View File
@@ -6,7 +6,12 @@ local nui_callbacks = {}
local nui_focus = nil
local nui_keep_input = nil
Config = { Phone = { AllowMovement = true } }
Config = {
Phone = {
AllowMovement = true,
HoldToLook = { Enabled = true, Control = 19 },
},
}
Bridge = { Debug = function() end }
function CreateThread(callback)
@@ -61,6 +66,7 @@ local function resolve(overrides)
cursor_disabled = false,
external_game_input = nil,
is_open = false,
look_passthrough = false,
notification_focus = false,
payphone_focus = false,
sim_picker_open = false,
@@ -115,10 +121,10 @@ local typing_phone = resolve({
assert(
typing_phone.cursor
and typing_phone.focused
and typing_phone.keep_input
and typing_phone.game_input
and not typing_phone.keep_input
and not typing_phone.game_input
and typing_phone.block_game,
"a focused phone text input must block GTA controls without hiding the NUI cursor"
"a focused phone text input must stop game-input passthrough and retain the NUI cursor"
)
local external_movement_phone = resolve({
@@ -142,8 +148,8 @@ local external_movement_typing_phone = resolve({
assert(
external_movement_typing_phone.cursor
and external_movement_typing_phone.focused
and external_movement_typing_phone.keep_input
and external_movement_typing_phone.game_input
and not external_movement_typing_phone.keep_input
and not external_movement_typing_phone.game_input
and external_movement_typing_phone.block_game,
"a focused text input must override an external movement claim"
)
@@ -177,6 +183,21 @@ assert(
"LB noFocus must preserve movement and camera look without retaining the NUI cursor"
)
local movable_look_passthrough_phone = resolve({
allow_movement = true,
is_open = true,
look_passthrough = true,
})
assert(
not movable_look_passthrough_phone.cursor
and movable_look_passthrough_phone.focused
and movable_look_passthrough_phone.keep_input
and movable_look_passthrough_phone.game_input
and not movable_look_passthrough_phone.block_game
and not movable_look_passthrough_phone.block_look,
"HoldToLook must temporarily release the NUI cursor without disabling configured movement"
)
local stationary_cursor_disabled_phone = resolve({
cursor_disabled = true,
is_open = true,
@@ -289,6 +310,10 @@ assert(
SkyPhoneFocus.SetPhone(false)
SkyPhoneFocus.SetPhone(true)
assert(nui_focus.cursor, "closing the phone must clear the previous no-focus claim")
SkyPhoneFocus.SetTextInputFocused(true)
assert(not nui_keep_input, "runtime text focus must stop game-input passthrough")
SkyPhoneFocus.SetTextInputFocused(false)
assert(nui_keep_input, "leaving a text input must restore configured phone movement")
local external_success, external_error = SkyPhoneFocus.SetExternalGameInput("custom_app", true)
assert(external_success and external_error == nil and nui_keep_input, "external movement claim must apply")
+209
View File
@@ -0,0 +1,209 @@
local function reset_bridge(inventory_name, unique_phones, sim_cards_enabled)
Config = {
Bridge = {
Inventory = inventory_name,
},
Phone = {
Unique = unique_phones,
},
Sim = {
Enabled = sim_cards_enabled,
},
}
Bridge = {
Debug = function()
end,
Framework = {
GetName = function()
return inventory_name == "esx" and "esx" or "qb"
end,
},
Inventory = {},
}
end
local function load_inventory_contract(adapter_path)
dofile("sky_phone/source/bridge/server/inventory.lua")
dofile(adapter_path)
dofile("sky_phone/source/bridge/server/inventory_contract.lua")
end
local core_items = {
{
name = "phone",
slot = 15,
count = 1,
metadata = {},
},
}
local metadata_write
local core_inventory = {}
function core_inventory:getInventory(source)
assert(source == 7)
return core_items
end
function core_inventory:setMetadata(source, slot, metadata)
metadata_write = { source = source, slot = slot, metadata = metadata }
core_items[1].metadata = metadata
end
function core_inventory:updateMetadata()
error("core bridge must use the stable setMetadata contract")
end
reset_bridge("core", true, true)
exports = {
core_inventory = core_inventory,
}
GetResourceState = function(resource_name)
return resource_name == "core_inventory" and "started" or "missing"
end
load_inventory_contract("sky_phone/source/bridge/server/inventory/core.lua")
assert(Bridge.Inventory.SetSlotMetadata(7, "15", { imei = "123456789012345" }))
assert(metadata_write.source == 7)
assert(metadata_write.slot == 15, "core metadata slot must be numeric")
assert(metadata_write.metadata.imei == "123456789012345")
assert(Bridge.Inventory.GetSlot(7, 15).metadata.imei == "123456789012345")
local qb_item = {
name = "phone",
slot = 2,
amount = 1,
info = { owner = "kept" },
}
local qb_write_mode = "persist"
local qb_inventory = {}
function qb_inventory:GetItemBySlot(source, slot)
assert(source == 9 and slot == 2)
return qb_item
end
function qb_inventory:GetItemsByName(source, item_name)
assert(source == 9 and item_name == "phone")
return qb_item and { qb_item } or {}
end
function qb_inventory:SetItemData()
error("qb bridge must not rely on an unverified direct metadata setter")
end
function qb_inventory:RemoveItem(source, item_name, amount, slot, reason)
assert(source == 9 and item_name == "phone" and amount == 1 and slot == 2)
assert(reason == "sky_phone:metadata-update")
if not qb_item then
return false
end
qb_item = nil
return true
end
function qb_inventory:AddItem(source, item_name, amount, slot, info, reason)
assert(source == 9 and item_name == "phone" and amount == 1 and slot == 2)
assert(reason == "sky_phone:metadata-update")
qb_item = {
name = item_name,
slot = slot,
amount = amount,
info = qb_write_mode == "drop_metadata" and {} or info,
}
return true
end
function qb_inventory:CanAddItem()
return true
end
reset_bridge("qb", true, true)
exports = {
["qb-inventory"] = qb_inventory,
}
GetResourceState = function(resource_name)
return resource_name == "qb-inventory" and "started" or "missing"
end
load_inventory_contract("sky_phone/source/bridge/server/inventory/qb.lua")
assert(Bridge.Inventory.SetSlotMetadata(9, 2, { owner = "kept", imei = "123456789012345" }))
assert(qb_item.info.owner == "kept" and qb_item.info.imei == "123456789012345")
qb_write_mode = "drop_metadata"
assert(not Bridge.Inventory.SetSlotMetadata(9, 2, { owner = "kept", imei = "999999999999999" }))
assert(qb_item and qb_item.slot == 2, "QB metadata verification must leave the item in its exact slot")
local function create_esx()
local usable_items = {}
local player = {}
function player.getInventoryItem(item_name)
return {
name = item_name,
count = 1,
}
end
local esx = {}
function esx.GetPlayerFromId(source)
assert(source == 11)
return player
end
function esx.RegisterUsableItem(item_name, callback)
usable_items[item_name] = callback
end
return esx, usable_items
end
local esx, usable_items = create_esx()
reset_bridge("esx", false, false)
exports = {
es_extended = {
getSharedObject = function()
return esx
end,
},
}
GetResourceState = function(resource_name)
return resource_name == "es_extended" and "started" or "missing"
end
load_inventory_contract("sky_phone/source/bridge/server/inventory/esx.lua")
local used_item
assert(Bridge.Inventory.RegisterUsableItem("phone", function(source, item)
assert(source == 11)
used_item = item
end))
usable_items.phone(11)
assert(used_item.name == "phone")
assert(used_item.slot == "phone")
assert(used_item.count == 1)
esx = create_esx()
reset_bridge("esx", true, true)
exports = {
es_extended = {
getSharedObject = function()
return esx
end,
},
}
load_inventory_contract("sky_phone/source/bridge/server/inventory/esx.lua")
local ok, configuration_error = pcall(Bridge.Inventory.RegisterUsableItem, "phone", function()
end)
assert(not ok)
assert(configuration_error:find("Config.Phone.Unique = false", 1, true))
assert(configuration_error:find("Config.Sim.Enabled = false", 1, true))
local manifest_file = assert(io.open("sky_phone/fxmanifest.lua", "rb"))
local manifest = manifest_file:read("*a")
manifest_file:close()
local adapters = assert(manifest:find("source/bridge/server/inventory/*.lua", 1, true))
local contract = assert(manifest:find("source/bridge/server/inventory_contract.lua", 1, true))
assert(adapters < contract, "inventory contract must load after provider adapters")
print("inventory bridge regression checks passed")
+64
View File
@@ -0,0 +1,64 @@
local source_path = "sky_phone/source/server/nui_build_check.lua"
local original_print = print
local function run_check(files)
local output = {}
print = function(message)
output[#output + 1] = tostring(message)
end
GetCurrentResourceName = function()
return "sky_phone"
end
LoadResourceFile = function(resource_name, path)
assert(resource_name == "sky_phone", "NUI build check must inspect its own resource")
return files[path]
end
dofile(source_path)
return table.concat(output, "\n")
end
local valid_files = {
["source/html/index.html"] = [[
<link rel="stylesheet" href="./assets/sky-index.css">
<script type="module" src="./assets/sky-index.js"></script>
]],
["source/html/assets/sky-index.css"] = "body{}",
["source/html/assets/sky-index.js"] = "console.log('ready')",
["source/html/img/custom-app.svg"] = "<svg></svg>",
["source/html/sounds/button.mp3"] = "audio",
}
assert(run_check(valid_files) == "", "a complete NUI build must not print a warning")
local missing_build_output = run_check({})
assert(missing_build_output:find("SKY PHONE UI BUILD IS MISSING OR INCOMPLETE", 1, true))
assert(missing_build_output:find("source/html/index.html", 1, true))
assert(missing_build_output:find("source/html/assets/*", 1, true))
assert(missing_build_output:find("source/html/img/custom-app.svg", 1, true))
assert(missing_build_output:find("source/html/sounds/button.mp3", 1, true))
assert(missing_build_output:find("not GitHub's automatic source archive", 1, true))
assert(missing_build_output:find("build_frontend.bat", 1, true))
local missing_asset_files = {}
for path, content in pairs(valid_files) do
missing_asset_files[path] = content
end
missing_asset_files["source/html/assets/sky-index.js"] = nil
local missing_asset_output = run_check(missing_asset_files)
assert(missing_asset_output:find("source/html/assets/sky-index.js", 1, true))
assert(not missing_asset_output:find("source/html/assets/sky-index.css", 1, true))
local query_asset_files = {}
for path, content in pairs(valid_files) do
query_asset_files[path] = content
end
query_asset_files["source/html/index.html"] = [[
<script type="module" src="./assets/sky-index.js?v=1#entry"></script>
]]
assert(run_check(query_asset_files) == "", "asset query strings and fragments must be ignored")
print = original_print
io.write("Sky Phone NUI build check tests passed\n")
+104 -1
View File
@@ -1,5 +1,6 @@
local registered_callbacks = {}
local migration_callbacks = {}
local event_handlers = {}
Bridge = {
Callbacks = {
@@ -75,8 +76,10 @@ json = {
end,
}
function AddEventHandler(_, callback)
function AddEventHandler(name, callback)
assert(type(callback) == "function")
event_handlers[name] = event_handlers[name] or {}
event_handlers[name][#event_handlers[name] + 1] = callback
end
function TriggerClientEvent()
@@ -177,6 +180,106 @@ for _, callback_name in ipairs({
assert(response.success == false and response.error == "device_not_open", "callback not bound to core: " .. callback_name)
end
local phone_item = {
name = "phone",
slot = 4,
amount = 1,
metadata = { imei = "123456789012345" },
}
local opened_event
local device_error
local hide_phone_during_prepare = false
Bridge.Framework.GetIdentifier = function(source)
assert(source == 1)
return "license:test-player"
end
Bridge.Framework.GetFirstname = function()
return "Test"
end
Bridge.Framework.GetLastname = function()
return "Player"
end
Bridge.Inventory.GetSlot = function(source, slot)
assert(source == 1 and slot == phone_item.slot)
return phone_item
end
Bridge.Inventory.GetSlotsWithItem = function(source, item_name)
assert(source == 1 and item_name == Config.Phone.Item)
if hide_phone_during_prepare == true then
return {}
end
return { phone_item }
end
Bridge.Inventory.SetSlotMetadata = function()
error("existing phone metadata must not be rewritten")
end
Bridge.Database.Query = function(query)
if query:find("FROM `sky_phone_devices` d", 1, true) then
return {
{
imei = phone_item.metadata.imei,
device_name = Config.Phone.DeviceName,
account_id = nil,
sim_id = nil,
},
}
end
return {}
end
SkyPhoneImei.IsValid = function(imei)
return imei == phone_item.metadata.imei
end
SkyPhoneSim = {
PrepareDevice = function(source, slot, imei)
assert(source == 1 and slot == phone_item and imei == phone_item.metadata.imei)
if hide_phone_during_prepare == "next" then
hide_phone_during_prepare = true
end
return true
end,
}
SkyPhoneNotes = {
List = function()
return {}
end,
}
SkyPhoneMemos = {
List = function()
return {}
end,
}
SkyPhoneCompanies = {
ClearCallAvailability = function()
end,
}
TriggerClientEvent = function(event_name, source, payload)
if event_name == "sky_phone:device:open" then
opened_event = { source = source, payload = payload }
elseif event_name == "sky_phone:device:error" then
device_error = { source = source, error = payload }
end
end
for _, callback in ipairs(event_handlers.onServerResourceStart or {}) do
callback("sky_phone")
end
local no_sim_open = registered_callbacks["sky_phone:device:open-request"](1, {})
assert(no_sim_open.success == true, "a phone item without a SIM must still open")
assert(opened_event and opened_event.source == 1, "no-SIM open must reach the client")
assert(opened_event.payload.device.imei == phone_item.metadata.imei)
assert(opened_event.payload.device.sim == nil, "no-SIM bootstrap must keep device.sim nullable")
opened_event = nil
device_error = nil
hide_phone_during_prepare = "next"
local lost_phone_open = registered_callbacks["sky_phone:device:open-request"](1, {})
assert(lost_phone_open.success == false, "bootstrap ownership loss must fail the open request")
assert(lost_phone_open.error == "device_not_owned", "bootstrap ownership loss must return its error code")
assert(opened_event == nil, "bootstrap ownership loss must not open the NUI")
assert(device_error and device_error.error == "device_not_owned", "bootstrap ownership loss must notify the client")
local manifest_file = assert(io.open("sky_phone/fxmanifest.lua", "rb"))
local manifest = manifest_file:read("*a")
manifest_file:close()
+4
View File
@@ -9,6 +9,10 @@ assert(SkyPhoneSimNumber.NormalizeService("9-1-1", 10) == "911", "formatted serv
assert(SkyPhoneSimNumber.NormalizeService("0", 10) == "0", "single-digit service numbers must normalize")
assert(SkyPhoneSimNumber.NormalizeService("", 10) == nil, "empty service numbers must fail")
assert(SkyPhoneSimNumber.NormalizeService("12345678901", 10) == nil, "long service numbers must fail")
assert(SkyPhoneSimNumber.ValidateConfiguration(9, "555"), "numeric SIM prefixes must be valid")
assert(SkyPhoneSimNumber.FromEntropy("550e8400-e29b-41d4-a716-446655440000", 9, "555") == "555550840")
assert(not SkyPhoneSimNumber.ValidateConfiguration(9, "555-"), "formatted SIM prefixes must be rejected")
assert(SkyPhoneSimNumber.FromEntropy("550e8400-e29b-41d4-a716-446655440000", 9, "555-") == nil)
local attempts = 0
local reserved = SkyPhoneSimNumber.Reserve(function()