diff --git a/.github/scripts/validate-repository.mjs b/.github/scripts/validate-repository.mjs index 11feff4..576943f 100644 --- a/.github/scripts/validate-repository.mjs +++ b/.github/scripts/validate-repository.mjs @@ -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)) { diff --git a/README.md b/README.md index 2334acd..11fc060 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@

Live demo  •  - Download for free + Download for free  •  Discord support

@@ -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. diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 7855d0d..21f575a 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -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 & { @@ -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): 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 } diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts index a2ee63a..cfd7334 100644 --- a/frontend/src/stores/phone.ts +++ b/frontend/src/stores/phone.ts @@ -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 3–20 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 8–72 characters.', + invalid_password: + 'Use 8–72 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.', diff --git a/frontend/src/types/device.ts b/frontend/src/types/device.ts index 34ee1b4..cb2ca82 100644 --- a/frontend/src/types/device.ts +++ b/frontend/src/types/device.ts @@ -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 = { payload: T @@ -50,6 +50,7 @@ export type DeviceBootstrap = { device: PhoneDevice memos: MemoDto[] notes: Note[] + phoneNumberFormat: PhoneNumberFormat player: PhonePlayerIdentity security: DeviceSecurity token: string diff --git a/frontend/src/types/phone.ts b/frontend/src/types/phone.ts index 77a2ac3..18a21d0 100644 --- a/frontend/src/types/phone.ts +++ b/frontend/src/types/phone.ts @@ -1,5 +1,10 @@ export type SimType = 'registered' | 'anonymous' +export type PhoneNumberFormat = { + groups: number[] + length: number +} + export type PhoneSim = { id: string number: string diff --git a/frontend/src/utils/phone.test.ts b/frontend/src/utils/phone.test.ts index 9e93124..acf26af 100644 --- a/frontend/src/utils/phone.test.ts +++ b/frontend/src/utils/phone.test.ts @@ -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() + }) }) diff --git a/frontend/src/utils/phone.ts b/frontend/src/utils/phone.ts index 358a5b5..3bcf1d1 100644 --- a/frontend/src/utils/phone.ts +++ b/frontend/src/utils/phone.ts @@ -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(' ') } diff --git a/frontend/src/views/apps/CryptoApp.contract.test.ts b/frontend/src/views/apps/CryptoApp.contract.test.ts index 5604819..adf2e15 100644 --- a/frontend/src/views/apps/CryptoApp.contract.test.ts +++ b/frontend/src/views/apps/CryptoApp.contract.test.ts @@ -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, ) diff --git a/frontend/src/views/apps/CryptoApp.vue b/frontend/src/views/apps/CryptoApp.vue index 1cd357f..83a4a5e 100644 --- a/frontend/src/views/apps/CryptoApp.vue +++ b/frontend/src/views/apps/CryptoApp.vue @@ -824,6 +824,10 @@ onUnmounted(() => { :size="18" /> +

+ {{ t('auth.ruleLength') }} · {{ t('auth.ruleMixed') }} · + {{ t('auth.ruleNumber') }} · {{ t('auth.ruleSpecial') }} +

{{ formError }}

{{ 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; diff --git a/frontend/src/views/apps/DarkChatApp.contract.test.ts b/frontend/src/views/apps/DarkChatApp.contract.test.ts index 7a59114..13937a8 100644 --- a/frontend/src/views/apps/DarkChatApp.contract.test.ts +++ b/frontend/src/views/apps/DarkChatApp.contract.test.ts @@ -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;/, diff --git a/frontend/src/views/apps/DarkChatApp.vue b/frontend/src/views/apps/DarkChatApp.vue index 88e1e59..eed0ab1 100644 --- a/frontend/src/views/apps/DarkChatApp.vue +++ b/frontend/src/views/apps/DarkChatApp.vue @@ -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(() => { +

{{ t('newChatBody') }}

{ 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, diff --git a/frontend/src/views/apps/SkyRideApp.vue b/frontend/src/views/apps/SkyRideApp.vue index 391537e..e8bf809 100644 --- a/frontend/src/views/apps/SkyRideApp.vue +++ b/frontend/src/views/apps/SkyRideApp.vue @@ -848,6 +848,9 @@ onBeforeUnmount(() => { +

+ {{ phone.t('Apps.skyride.playerDriverNotice') }} +

{ 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; } diff --git a/sky_phone/config/config.lua b/sky_phone/config/config.lua index c9ac25c..eb35208 100644 --- a/sky_phone/config/config.lua +++ b/sky_phone/config/config.lua @@ -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 } -- ============================================================================= diff --git a/sky_phone/config/locales/de.lua b/sky_phone/config/locales/de.lua index d2bde7f..98c2873 100644 --- a/sky_phone/config/locales/de.lua +++ b/sky_phone/config/locales/de.lua @@ -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 3–20 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 8–72 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 3–20 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 8–72 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.", diff --git a/sky_phone/config/locales/en.lua b/sky_phone/config/locales/en.lua index 6a83388..6111be6 100644 --- a/sky_phone/config/locales/en.lua +++ b/sky_phone/config/locales/en.lua @@ -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 3–20 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 8–72 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 3–20 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 8–72 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.", diff --git a/sky_phone/fxmanifest.lua b/sky_phone/fxmanifest.lua index 9146e39..5ec709f 100644 --- a/sky_phone/fxmanifest.lua +++ b/sky_phone/fxmanifest.lua @@ -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/**', } diff --git a/sky_phone/source/bridge/server/inventory.lua b/sky_phone/source/bridge/server/inventory.lua index 5278d57..69cc749 100644 --- a/sky_phone/source/bridge/server/inventory.lua +++ b/sky_phone/source/bridge/server/inventory.lua @@ -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 diff --git a/sky_phone/source/bridge/server/inventory/core.lua b/sky_phone/source/bridge/server/inventory/core.lua index 939ab5c..279ff93 100644 --- a/sky_phone/source/bridge/server/inventory/core.lua +++ b/sky_phone/source/bridge/server/inventory/core.lua @@ -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() diff --git a/sky_phone/source/bridge/server/inventory/qb.lua b/sky_phone/source/bridge/server/inventory/qb.lua index acd0781..765b353 100644 --- a/sky_phone/source/bridge/server/inventory/qb.lua +++ b/sky_phone/source/bridge/server/inventory/qb.lua @@ -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) diff --git a/sky_phone/source/bridge/server/inventory_contract.lua b/sky_phone/source/bridge/server/inventory_contract.lua new file mode 100644 index 0000000..7824813 --- /dev/null +++ b/sky_phone/source/bridge/server/inventory_contract.lua @@ -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 diff --git a/sky_phone/source/client/focus.lua b/sky_phone/source/client/focus.lua index f4d0d5f..c1b598d 100644 --- a/sky_phone/source/client/focus.lua +++ b/sky_phone/source/client/focus.lua @@ -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 diff --git a/sky_phone/source/server/db_migrate.lua b/sky_phone/source/server/db_migrate.lua index 5b93561..3a930a1 100644 --- a/sky_phone/source/server/db_migrate.lua +++ b/sky_phone/source/server/db_migrate.lua @@ -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" }, diff --git a/sky_phone/source/server/nui_build_check.lua b/sky_phone/source/server/nui_build_check.lua new file mode 100644 index 0000000..2ab7ab8 --- /dev/null +++ b/sky_phone/source/server/nui_build_check.lua @@ -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 diff --git a/sky_phone/source/server/phone.lua b/sky_phone/source/server/phone.lua index 147fbad..8397dcb 100644 --- a/sky_phone/source/server/phone.lua +++ b/sky_phone/source/server/phone.lua @@ -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 diff --git a/sky_phone/source/server/sim.lua b/sky_phone/source/server/sim.lua index 8a64c83..2581143 100644 --- a/sky_phone/source/server/sim.lua +++ b/sky_phone/source/server/sim.lua @@ -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 diff --git a/sky_phone/source/shared/sim_number.lua b/sky_phone/source/shared/sim_number.lua index 355f15a..f69d625 100644 --- a/sky_phone/source/shared/sim_number.lua +++ b/sky_phone/source/shared/sim_number.lua @@ -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 diff --git a/sky_phone/sql/install.sql b/sky_phone/sql/install.sql index eb1795f..0a2b7a5 100644 --- a/sky_phone/sql/install.sql +++ b/sky_phone/sql/install.sql @@ -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, diff --git a/tests/client_focus.lua b/tests/client_focus.lua index d19a882..970f4fb 100644 --- a/tests/client_focus.lua +++ b/tests/client_focus.lua @@ -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") diff --git a/tests/inventory_bridges.lua b/tests/inventory_bridges.lua new file mode 100644 index 0000000..dbac13b --- /dev/null +++ b/tests/inventory_bridges.lua @@ -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") diff --git a/tests/nui_build_check.lua b/tests/nui_build_check.lua new file mode 100644 index 0000000..1c1834c --- /dev/null +++ b/tests/nui_build_check.lua @@ -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"] = [[ + + + ]], + ["source/html/assets/sky-index.css"] = "body{}", + ["source/html/assets/sky-index.js"] = "console.log('ready')", + ["source/html/img/custom-app.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"] = [[ + +]] +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") diff --git a/tests/server_phone_modules.lua b/tests/server_phone_modules.lua index da9d4cf..6be1077 100644 --- a/tests/server_phone_modules.lua +++ b/tests/server_phone_modules.lua @@ -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() diff --git a/tests/sim_number.lua b/tests/sim_number.lua index 1afd8e5..770d736 100644 --- a/tests/sim_number.lua +++ b/tests/sim_number.lua @@ -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()