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()