mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 01:08:59 +00:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3f192ae389 | |||
| 21913b585c | |||
| e056d0f05c | |||
| edd9677f43 | |||
| 97db163cd0 | |||
| 698420584d | |||
| 56901621a0 | |||
| 7b4e445394 |
@@ -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)) {
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
<p align="center">
|
||||
<a href="https://www.sky-systems.net/shop/phone#live-demo"><strong>Live demo</strong></a>
|
||||
•
|
||||
<a href="https://github.com/sky-systems/sky_phone"><strong>Download for free</strong></a>
|
||||
<a href="https://github.com/sky-systems/sky_phone/releases/latest"><strong>Download for free</strong></a>
|
||||
•
|
||||
<a href="https://discord.gg/sky-systems"><strong>Discord support</strong></a>
|
||||
</p>
|
||||
@@ -44,7 +44,7 @@ Sky Phone is a **free and open-source FiveM phone script** built to give serious
|
||||
|
||||
This is not a cut-down free alternative. Sky Phone includes the core experience server owners and players expect from a leading paid FiveM phone, plus full source access, no purchase price, no feature paywalls, and no forced ecosystem lock-in.
|
||||
|
||||
The production frontend is included, so a normal server installation does not require Node.js or pnpm.
|
||||
The production frontend is included in the published release package, so a normal server installation does not require Node.js or pnpm. GitHub's automatically generated source archives do not contain that build.
|
||||
|
||||
## Why Sky Phone stands out
|
||||
|
||||
@@ -160,8 +160,8 @@ Start the selected voice resource before Sky Phone.
|
||||
|
||||
## Quick installation
|
||||
|
||||
1. Copy the resource into your FiveM resources directory.
|
||||
2. Keep the resource folder name `sky_phone`.
|
||||
1. Download and extract the latest published [Sky Phone release](https://github.com/sky-systems/sky_phone/releases/latest). Do not use GitHub's automatically generated "Source code" archives for a server installation because they do not contain the built frontend.
|
||||
2. Copy the included resource into your FiveM resources directory and keep its folder name `sky_phone`.
|
||||
3. Start `oxmysql`, your framework, inventory, and voice resource before Sky Phone.
|
||||
4. Review `sky_phone/config/config.lua` and `sky_phone/config/media.lua`.
|
||||
5. Add the required inventory items.
|
||||
@@ -182,6 +182,13 @@ Replace the example framework, inventory, and voice resources with the providers
|
||||
|
||||
Sky Phone creates and upgrades its database tables automatically. A manual SQL import is normally not required.
|
||||
|
||||
### How players open the phone
|
||||
|
||||
- Give the player the item configured in `Config.Phone.Item` (default: `phone`).
|
||||
- Players can use that inventory item or press the configured keybind (default: `F1`).
|
||||
- The keybind still verifies server-side that the player owns a configured phone item; it does not bypass inventory ownership.
|
||||
- A SIM card is **not required to open or use the phone itself**. With `Config.Sim.Enabled = true`, only cellular features such as calls and messages require an inserted SIM.
|
||||
|
||||
## Configuration
|
||||
|
||||
Customer settings are organized in:
|
||||
@@ -344,7 +351,7 @@ With unique phones, using an inventory item selects that exact handset whenever
|
||||
|
||||
| SIM mode | Behavior |
|
||||
| --- | --- |
|
||||
| `Enabled = true` | A registered or anonymous physical SIM item is required for cellular service. |
|
||||
| `Enabled = true` | The phone opens with or without a SIM. A registered or anonymous physical SIM item is required only for cellular service such as calls and messages. |
|
||||
| `Enabled = false` | Sky Phone creates a persistent automatic number for devices without a SIM. Physical SIM items are not required. |
|
||||
|
||||
When changing these modes on an existing production server, restart the resource and test with a copy of the database first. The first phone used after switching to non-unique mode may adopt an existing valid IMEI so its local data is preserved.
|
||||
@@ -602,12 +609,20 @@ pnpm build
|
||||
|
||||
### The phone item does nothing
|
||||
|
||||
- A warning that the inventory returned no configured phone item means an item definition, `Config.Phone.Item`, inventory selection, or player ownership problem. It is not caused by a missing SIM card.
|
||||
- Confirm the framework and inventory are supported and started first.
|
||||
- Confirm the item name matches `Config.Phone.Item`.
|
||||
- Confirm the item is usable.
|
||||
- In unique mode, confirm the phone is non-stackable.
|
||||
- Check the server console for inventory adapter warnings.
|
||||
|
||||
### The resource starts but the phone UI is missing
|
||||
|
||||
- On startup, the server console prints `SKY PHONE UI BUILD IS MISSING OR INCOMPLETE`, lists the missing or invalid packaged files, and shows repository-native build commands.
|
||||
- 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.
|
||||
|
||||
@@ -70,7 +70,7 @@ import type {
|
||||
CompanyChangedPayload,
|
||||
CompanyUnreadCounts,
|
||||
} from '@/types/companies'
|
||||
import type { PhoneCall } from '@/types/phone'
|
||||
import type { PhoneCall, PhoneNumberFormat } from '@/types/phone'
|
||||
import type { DynamicIslandActivity } from '@/types/dynamicIsland'
|
||||
import type { EasyShareEvent } from '@/types/easyshare'
|
||||
import type { CryptoMarketChangedData } from '@/types/crypto'
|
||||
@@ -84,6 +84,7 @@ import { formatTimer } from '@/utils/clock'
|
||||
import { parsePhonePreferences } from '@/utils/preferences'
|
||||
import { getHairlinePixelStyle } from '@/utils/rendering'
|
||||
import { isTextInputElement } from '@/utils/textInputFocus'
|
||||
import { configurePhoneNumberFormat } from '@/utils/phone'
|
||||
import { isTrustedRootMessageSource } from '@/utils/windowMessages'
|
||||
import SpringboardView from '@/views/SpringboardView.vue'
|
||||
|
||||
@@ -133,6 +134,7 @@ type NavigationEventData = {
|
||||
type SimPickerPayload = {
|
||||
choices: SimPhoneChoice[]
|
||||
number: string
|
||||
phoneNumberFormat?: PhoneNumberFormat
|
||||
}
|
||||
|
||||
type NotificationEventData = Omit<PhoneNotificationInput, 'device'> & {
|
||||
@@ -491,6 +493,7 @@ function getViewportScale(): number {
|
||||
}
|
||||
|
||||
function hydratePhone(payload: PhoneOpenPayload): void {
|
||||
configurePhoneNumberFormat(payload.phoneNumberFormat)
|
||||
if (payload.device?.imei) {
|
||||
companies.bindDeviceScope(
|
||||
payload.device.imei,
|
||||
@@ -1222,7 +1225,9 @@ function onMessage(event: MessageEvent<AppMessage>): void {
|
||||
loadUnlockedPhoneData()
|
||||
}
|
||||
} else if (event.data?.type === 'sim:picker' && event.data.data) {
|
||||
simPicker.value = event.data.data as unknown as SimPickerPayload
|
||||
const payload = event.data.data as unknown as SimPickerPayload
|
||||
configurePhoneNumberFormat(payload.phoneNumberFormat)
|
||||
simPicker.value = payload
|
||||
} else if (event.data?.type === 'sim:picker-close') {
|
||||
simPicker.value = null
|
||||
}
|
||||
|
||||
@@ -14,6 +14,12 @@ import { isTrustedRootMessageSource } from '@/utils/windowMessages'
|
||||
|
||||
type RecordingChunk = { blob: Blob; durationMs: number }
|
||||
type PendingVideo = { blob: Blob; fileName: string }
|
||||
type UploadFailureDebug = {
|
||||
correlationId: string
|
||||
message: string
|
||||
stage: string
|
||||
status?: number
|
||||
}
|
||||
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
||||
const pendingVideos = new Map<string, PendingVideo>()
|
||||
@@ -130,7 +136,10 @@ function cleanupRecording(): void {
|
||||
try {
|
||||
activeRecorder.stop()
|
||||
} catch (error) {
|
||||
console.error('[Camera] Could not stop the failed media recorder.', error)
|
||||
console.error(
|
||||
'[Camera] Could not stop the failed media recorder.',
|
||||
error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -245,8 +254,7 @@ async function startRecording(data: Record<string, unknown>): Promise<void> {
|
||||
const activeRecorder = recorder
|
||||
removeRecorderErrorListener = bindMediaRecorderError(
|
||||
activeRecorder,
|
||||
() =>
|
||||
generation === recordingGeneration && recorder === activeRecorder,
|
||||
() => generation === recordingGeneration && recorder === activeRecorder,
|
||||
(event) => {
|
||||
console.error('[Camera] Media recorder failed while recording.', event)
|
||||
cleanupRecording()
|
||||
@@ -417,8 +425,32 @@ async function capturePhotoBlob(ready: UploadReady): Promise<Blob> {
|
||||
}
|
||||
}
|
||||
|
||||
async function failUpload(requestId: string, error: string): Promise<void> {
|
||||
await nuiCall('media:failUpload', { error, requestId })
|
||||
async function failUpload(
|
||||
requestId: string,
|
||||
error: string,
|
||||
debug: UploadFailureDebug,
|
||||
): Promise<void> {
|
||||
console.error('[Sky Phone Media] Upload failed.', {
|
||||
correlationId: debug.correlationId,
|
||||
detail: debug.message,
|
||||
error,
|
||||
stage: debug.stage,
|
||||
status: debug.status,
|
||||
})
|
||||
const response = await nuiCall('media:failUpload', {
|
||||
correlationId: debug.correlationId,
|
||||
debugMessage: debug.message,
|
||||
debugStage: debug.stage,
|
||||
debugStatus: debug.status,
|
||||
error,
|
||||
requestId,
|
||||
})
|
||||
if (!response.success) {
|
||||
console.error('[Sky Phone Media] Could not forward upload diagnostics.', {
|
||||
correlationId: debug.correlationId,
|
||||
error: response.error,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadReady(ready: UploadReady): Promise<void> {
|
||||
@@ -435,49 +467,87 @@ async function uploadReady(ready: UploadReady): Promise<void> {
|
||||
blob = await capturePhotoBlob(ready)
|
||||
fileName = `camera-${ready.correlationId}.${ready.photo?.Encoding ?? 'jpg'}`
|
||||
}
|
||||
} catch {
|
||||
await failUpload(ready.requestId, 'capture_failed')
|
||||
} catch (error) {
|
||||
await failUpload(ready.requestId, 'capture_failed', {
|
||||
correlationId: ready.correlationId,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
stage: 'capture',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
console.info('[Sky Phone Media] Capture prepared for upload.', {
|
||||
bytes: blob.size,
|
||||
correlationId: ready.correlationId,
|
||||
mimeType: blob.type,
|
||||
type: ready.mediaType,
|
||||
})
|
||||
|
||||
const form = new FormData()
|
||||
form.append('file', blob, fileName)
|
||||
form.append(
|
||||
'metadata',
|
||||
JSON.stringify({ captureToken: ready.captureToken, source: 'sky_phone' }),
|
||||
)
|
||||
const controller = new AbortController()
|
||||
const timeout = window.setTimeout(
|
||||
() => controller.abort(),
|
||||
ready.uploadTimeoutMs ?? 25000,
|
||||
)
|
||||
let debugStage = 'provider_request'
|
||||
let debugStatus: number | undefined
|
||||
try {
|
||||
const response = await fetch(ready.presignedUrl, {
|
||||
body: form,
|
||||
method: 'POST',
|
||||
signal: controller.signal,
|
||||
})
|
||||
debugStatus = response.status
|
||||
debugStage = 'provider_response'
|
||||
console.info('[Sky Phone Media] FiveManage upload responded.', {
|
||||
correlationId: ready.correlationId,
|
||||
status: response.status,
|
||||
})
|
||||
const text = await response.text()
|
||||
const body = JSON.parse(text) as {
|
||||
data?: { id?: string; url?: string }
|
||||
error?: string
|
||||
id?: string
|
||||
message?: string
|
||||
url?: string
|
||||
}
|
||||
const uploaded = body.data ?? body
|
||||
if (!response.ok || !uploaded.id || !uploaded.url) {
|
||||
throw new Error('upload_failed')
|
||||
throw new Error(
|
||||
(typeof body.error === 'string' && body.error) ||
|
||||
(typeof body.message === 'string' && body.message) ||
|
||||
'upload_failed',
|
||||
)
|
||||
}
|
||||
await nuiCall('media:completeUpload', {
|
||||
debugStage = 'completion_callback'
|
||||
const completion = await nuiCall('media:completeUpload', {
|
||||
correlationId: ready.correlationId,
|
||||
remoteId: uploaded.id,
|
||||
requestId: ready.requestId,
|
||||
url: uploaded.url,
|
||||
})
|
||||
if (!completion.success) {
|
||||
throw new Error(completion.error ?? 'completion_callback_failed')
|
||||
}
|
||||
console.info(
|
||||
'[Sky Phone Media] Upload completion forwarded to the server.',
|
||||
{
|
||||
correlationId: ready.correlationId,
|
||||
},
|
||||
)
|
||||
} catch (error) {
|
||||
await failUpload(
|
||||
ready.requestId,
|
||||
error instanceof DOMException && error.name === 'AbortError'
|
||||
? 'upload_timeout'
|
||||
: 'upload_failed',
|
||||
{
|
||||
correlationId: ready.correlationId,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
stage: debugStage,
|
||||
status: debugStatus,
|
||||
},
|
||||
)
|
||||
} finally {
|
||||
window.clearTimeout(timeout)
|
||||
@@ -523,7 +593,12 @@ function onMessage(event: MessageEvent): void {
|
||||
)
|
||||
}
|
||||
} else if (message.type === 'media:uploadReady') {
|
||||
void uploadReady(message.data as UploadReady)
|
||||
const ready = message.data as UploadReady
|
||||
console.info('[Sky Phone Media] Upload-ready received.', {
|
||||
correlationId: ready.correlationId,
|
||||
type: ready.mediaType,
|
||||
})
|
||||
void uploadReady(ready)
|
||||
} else if (message.type === 'media:uploadResult') {
|
||||
const correlationId = String(message.data?.correlationId ?? '')
|
||||
if (correlationId) pendingVideos.delete(correlationId)
|
||||
|
||||
@@ -365,6 +365,7 @@ async function stopRecording(data: Record<string, unknown>): Promise<void> {
|
||||
mimeType,
|
||||
note: finalMetadata.note,
|
||||
pinned: finalMetadata.pinned,
|
||||
sizeBytes: blob.size,
|
||||
title: finalMetadata.title,
|
||||
waveform,
|
||||
}
|
||||
@@ -458,14 +459,6 @@ async function uploadReady(ready: MemoUploadReady): Promise<void> {
|
||||
pending.requestId = ready.requestId
|
||||
const form = new FormData()
|
||||
form.append('file', pending.blob, pending.fileName)
|
||||
form.append(
|
||||
'metadata',
|
||||
JSON.stringify({
|
||||
captureToken: ready.captureToken,
|
||||
purpose: 'memo',
|
||||
source: 'sky_phone',
|
||||
}),
|
||||
)
|
||||
const controller = new AbortController()
|
||||
pending.abortController = controller
|
||||
const timeout = window.setTimeout(
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const mediaConfig = readFileSync(
|
||||
new URL('../../sky_phone/config/media.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const manifest = readFileSync(
|
||||
new URL('../../sky_phone/fxmanifest.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const mediaProviderConfig = readFileSync(
|
||||
new URL(
|
||||
'../../sky_phone/source/server/media_provider_config.lua',
|
||||
import.meta.url,
|
||||
),
|
||||
'utf8',
|
||||
)
|
||||
const mediaImportAdapter = readFileSync(
|
||||
new URL(
|
||||
'../../sky_phone/source/server/media_import/fivemanage.lua',
|
||||
import.meta.url,
|
||||
),
|
||||
'utf8',
|
||||
)
|
||||
const mediaServer = readFileSync(
|
||||
new URL('../../sky_phone/source/server/media.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const memoServer = readFileSync(
|
||||
new URL('../../sky_phone/source/server/memos.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const mediaCapture = readFileSync(
|
||||
new URL('./components/PhoneMediaCapture.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const memoRecorder = readFileSync(
|
||||
new URL('./components/PhoneMemoRecorder.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
describe('FiveManage server configuration contract', () => {
|
||||
it('keeps the provider token in the server-only media config', () => {
|
||||
expect(mediaConfig).toMatch(/FiveManage\s*=\s*{\s*ApiKey\s*=/)
|
||||
expect(mediaProviderConfig).toContain(
|
||||
'return trim_key(Config.Media.FiveManage.ApiKey)',
|
||||
)
|
||||
expect(mediaProviderConfig).not.toContain('GetConvar')
|
||||
})
|
||||
|
||||
it('uses one resolver for Camera uploads and FiveManage imports', () => {
|
||||
expect(
|
||||
manifest.indexOf("'source/server/media_provider_config.lua'"),
|
||||
).toBeLessThan(manifest.indexOf("'source/server/media_import.lua'"))
|
||||
expect(mediaServer).toContain(
|
||||
'SkyPhoneMediaProviderConfig.FiveManageApiKey()',
|
||||
)
|
||||
expect(mediaImportAdapter).toContain(
|
||||
'SkyPhoneMediaProviderConfig.FiveManageApiKey(website.ApiKey)',
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the direct FiveManage upload response flow for Camera and voice memos', () => {
|
||||
expect(mediaConfig).not.toContain('VerificationRetryDelaysMs')
|
||||
expect(mediaCapture).toContain("form.append('file', blob, fileName)")
|
||||
expect(mediaCapture).not.toContain("form.append('path'")
|
||||
expect(mediaCapture).not.toContain("form.append(\n 'metadata'")
|
||||
expect(memoRecorder).toContain(
|
||||
"form.append('file', pending.blob, pending.fileName)",
|
||||
)
|
||||
expect(memoRecorder).not.toContain("form.append('path'")
|
||||
expect(memoRecorder).not.toContain("form.append(\n 'metadata'")
|
||||
expect(mediaServer).toContain(
|
||||
'Accepting the direct FiveManage upload response',
|
||||
)
|
||||
expect(mediaServer).toContain('remote_id = remote_id')
|
||||
expect(mediaServer).toContain('url = uploaded_url')
|
||||
expect(mediaServer).not.toContain('"HEAD"')
|
||||
expect(mediaServer).not.toContain('authenticated upload-path lookup')
|
||||
})
|
||||
|
||||
it('allowlists the FiveManage API and media hosts', () => {
|
||||
expect(mediaServer).toContain('["api.fivemanage.com"] = true')
|
||||
expect(mediaServer).toContain('["fmapi.net"] = true')
|
||||
expect(mediaServer).toContain(
|
||||
'uploaded_host:lower() ~= "r2.fivemanage.com"',
|
||||
)
|
||||
})
|
||||
|
||||
it('validates and preserves the recorded memo size before upload', () => {
|
||||
expect(memoRecorder).toContain('sizeBytes: blob.size')
|
||||
expect(memoServer).toContain(
|
||||
'local size_bytes = tonumber(data.sizeBytes)',
|
||||
)
|
||||
expect(memoServer).toContain(
|
||||
'size_bytes < 1 or size_bytes > Config.Memos.MaximumBytes',
|
||||
)
|
||||
expect(memoServer).toContain('size_bytes = memo.size_bytes')
|
||||
expect(mediaServer).toContain('size = state.size_bytes')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const mediaCapture = readFileSync(
|
||||
new URL('./components/PhoneMediaCapture.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const mediaServer = readFileSync(
|
||||
new URL('../../sky_phone/source/server/media.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
describe('media upload diagnostics contracts', () => {
|
||||
it('forwards browser upload failure context to the server log', () => {
|
||||
expect(mediaCapture).toContain("let debugStage = 'provider_request'")
|
||||
expect(mediaCapture).toContain('debugStatus: debug.status')
|
||||
expect(mediaServer).toContain('Client-reported upload failure')
|
||||
expect(mediaServer).toContain('diagnostic_text(data.debugMessage, 240)')
|
||||
})
|
||||
})
|
||||
@@ -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.',
|
||||
@@ -4012,6 +4017,11 @@ const defaultLocales: LocaleTree = {
|
||||
invalid_media_type: 'The uploaded media type is invalid.',
|
||||
invalid_upload: 'The upload could not be verified.',
|
||||
invalid_upload_token: 'The upload session is no longer valid.',
|
||||
media_provider_failed: 'The camera upload service is unavailable.',
|
||||
media_provider_rate_limited:
|
||||
'The camera upload service is busy. Try again shortly.',
|
||||
media_provider_unauthorized:
|
||||
'The configured FiveManage API key was rejected.',
|
||||
missing_config: 'Camera uploads are not configured.',
|
||||
microphone_unavailable:
|
||||
'Allow microphone access or mute the microphone before recording.',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Note } from '@/utils/notes'
|
||||
import type { MemoDto } from '@/types/memos'
|
||||
import type { PhoneSim } from '@/types/phone'
|
||||
import type { PhoneNumberFormat, PhoneSim } from '@/types/phone'
|
||||
|
||||
export type DeviceDataEntry<T = unknown> = {
|
||||
payload: T
|
||||
@@ -50,6 +50,7 @@ export type DeviceBootstrap = {
|
||||
device: PhoneDevice
|
||||
memos: MemoDto[]
|
||||
notes: Note[]
|
||||
phoneNumberFormat: PhoneNumberFormat
|
||||
player: PhonePlayerIdentity
|
||||
security: DeviceSecurity
|
||||
token: string
|
||||
|
||||
@@ -70,7 +70,6 @@ export type MediaImportResult = {
|
||||
}
|
||||
|
||||
export type UploadReady = {
|
||||
captureToken: string
|
||||
correlationId: string
|
||||
mediaType: MediaType
|
||||
photo?: {
|
||||
|
||||
@@ -39,7 +39,6 @@ export type MemoRecordingMetadata = {
|
||||
export type MemoUploadReady = {
|
||||
requestId: string
|
||||
correlationId: string
|
||||
captureToken: string
|
||||
presignedUrl: string
|
||||
uploadTimeoutMs?: number
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
export type SimType = 'registered' | 'anonymous'
|
||||
|
||||
export type PhoneNumberFormat = {
|
||||
groups: number[]
|
||||
length: number
|
||||
}
|
||||
|
||||
export type PhoneSim = {
|
||||
id: string
|
||||
number: string
|
||||
|
||||
@@ -158,6 +158,13 @@ describe('media utilities', () => {
|
||||
expect(mediaErrorKey('profile_photo_required')).toBe(
|
||||
'profile_photo_required',
|
||||
)
|
||||
expect(mediaErrorKey('media_provider_failed')).toBe('media_provider_failed')
|
||||
expect(mediaErrorKey('media_provider_rate_limited')).toBe(
|
||||
'media_provider_rate_limited',
|
||||
)
|
||||
expect(mediaErrorKey('media_provider_unauthorized')).toBe(
|
||||
'media_provider_unauthorized',
|
||||
)
|
||||
expect(mediaErrorKey('private_provider_error')).toBe('request_failed')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -95,6 +95,9 @@ export function mediaErrorKey(error?: string): string {
|
||||
'invalid_import_url',
|
||||
'invalid_upload',
|
||||
'invalid_upload_token',
|
||||
'media_provider_failed',
|
||||
'media_provider_rate_limited',
|
||||
'media_provider_unauthorized',
|
||||
'missing_config',
|
||||
'import_media_not_allowed',
|
||||
'import_media_too_large',
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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(' ')
|
||||
}
|
||||
|
||||
@@ -134,10 +134,30 @@ async function requestPhoto(): Promise<void> {
|
||||
window.setTimeout(() => void completeDevelopmentCapture(id, 'photo'), 700)
|
||||
return
|
||||
}
|
||||
await nuiCall('media:requestUpload', {
|
||||
console.info('[Sky Phone Media] Camera requested a photo upload.', {
|
||||
correlationId: id,
|
||||
})
|
||||
const response = await nuiCall('media:requestUpload', {
|
||||
correlationId: id,
|
||||
mediaType: 'photo',
|
||||
})
|
||||
if (!response.success) {
|
||||
console.error('[Sky Phone Media] Photo upload request was rejected.', {
|
||||
correlationId: id,
|
||||
error: response.error,
|
||||
})
|
||||
window.postMessage(
|
||||
{
|
||||
data: {
|
||||
correlationId: id,
|
||||
error: response.error ?? 'request_failed',
|
||||
success: false,
|
||||
},
|
||||
type: 'media:uploadResult',
|
||||
},
|
||||
'*',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function startRecording(): void {
|
||||
@@ -374,6 +394,11 @@ function onMessage(event: MessageEvent): void {
|
||||
} else if (message.type === 'media:uploadResult') {
|
||||
const result = message.data as UploadResult
|
||||
if (!result?.correlationId) return
|
||||
console.info('[Sky Phone Media] Camera received an upload result.', {
|
||||
correlationId: result.correlationId,
|
||||
error: result.error,
|
||||
success: result.success,
|
||||
})
|
||||
savingVideo.value = false
|
||||
if (result.success && result.media) {
|
||||
latestMedia.value = result.media
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -824,6 +824,10 @@ onUnmounted(() => {
|
||||
:size="18"
|
||||
/></button></template
|
||||
></SkyField>
|
||||
<p v-if="authMode === 'register'" class="auth-password-hint">
|
||||
{{ t('auth.ruleLength') }} · {{ t('auth.ruleMixed') }} ·
|
||||
{{ t('auth.ruleNumber') }} · {{ t('auth.ruleSpecial') }}
|
||||
</p>
|
||||
<p v-if="formError" class="error">{{ formError }}</p>
|
||||
<SkyButton block large class="auth-submit" type="submit">{{
|
||||
t(
|
||||
@@ -2060,6 +2064,12 @@ onUnmounted(() => {
|
||||
.auth-form {
|
||||
gap: 12px;
|
||||
}
|
||||
.auth-password-hint {
|
||||
margin: -3px 4px 0;
|
||||
color: rgba(255, 255, 255, 0.58);
|
||||
font-size: 11px;
|
||||
line-height: 15px;
|
||||
}
|
||||
.auth-account {
|
||||
display: grid;
|
||||
grid-template-columns: 40px minmax(0, 1fr) 18px;
|
||||
|
||||
@@ -37,10 +37,17 @@ describe('DarkChatApp Sky UI contract', () => {
|
||||
expect(newChat).toContain('class="dc-recipient-field"')
|
||||
expect(newChat).toContain('layout="inline"')
|
||||
expect(newChat).toContain('class="dc-new-chat-contacts"')
|
||||
expect(newChat).toContain("{{ t('newChatBody') }}")
|
||||
expect(newChat).toContain("{{ phone.t('Common.cancel') }}")
|
||||
expect(newChat).not.toContain('class="dc-hero"')
|
||||
})
|
||||
|
||||
it('rejects the current profile identifiers before opening confirmation', () => {
|
||||
expect(source).toContain('darkchat.profile?.darkId')
|
||||
expect(source).toContain('darkchat.profile?.inviteCode')
|
||||
expect(source).toContain("showToast(errorText('self_chat'))")
|
||||
})
|
||||
|
||||
it('keeps the search visually compact without shrinking its wrapper', () => {
|
||||
expect(source).toMatch(
|
||||
/\.dc-search :deep\(\.sky-searchbar__control\),[\s\S]*?height:\s*38px;[\s\S]*?min-height:\s*38px;/,
|
||||
|
||||
@@ -427,6 +427,20 @@ function closeReport(): void {
|
||||
function requestStart(value = identifier.value): void {
|
||||
const clean = value.trim()
|
||||
if (!clean) return
|
||||
const ownIdentifiers = [
|
||||
darkchat.profile?.darkId,
|
||||
darkchat.profile?.inviteCode,
|
||||
]
|
||||
if (
|
||||
ownIdentifiers.some(
|
||||
(ownIdentifier) =>
|
||||
ownIdentifier?.toLocaleLowerCase(phone.lang) ===
|
||||
clean.toLocaleLowerCase(phone.lang),
|
||||
)
|
||||
) {
|
||||
showToast(errorText('self_chat'))
|
||||
return
|
||||
}
|
||||
pendingIdentifier.value = clean
|
||||
safetyOpen.value = true
|
||||
}
|
||||
@@ -1262,6 +1276,13 @@ onBeforeUnmount(() => {
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.dc-new-chat-intro {
|
||||
margin: 2px 2px 12px;
|
||||
color: var(--dc-muted);
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.dc-recipient-field,
|
||||
.dc-new-chat-contacts {
|
||||
margin: 0 calc(var(--sky-page-gutter) * -1) 12px;
|
||||
@@ -2071,6 +2092,7 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
</SkyNavbar>
|
||||
<SkyScrollArea padded class="dc-new-chat-scroll">
|
||||
<p class="dc-new-chat-intro">{{ t('newChatBody') }}</p>
|
||||
<SkyList class="dc-recipient-field" density="compact" flush>
|
||||
<SkyField
|
||||
:label="t('to')"
|
||||
|
||||
@@ -18,6 +18,11 @@ describe('SkyRide app layout', () => {
|
||||
expect(source).toContain('skyride-history-card__distance')
|
||||
})
|
||||
|
||||
it('explains that requests require an online player driver', () => {
|
||||
expect(source).toContain("phone.t('Apps.skyride.playerDriverNotice')")
|
||||
expect(source).toContain('class="skyride-player-driver-notice"')
|
||||
})
|
||||
|
||||
it('aligns rider and activity surfaces to one content width', () => {
|
||||
expect(source).toMatch(
|
||||
/\.skyride-location-list,\s*\.skyride-activity-list\s*\{[^}]*margin-inline:\s*2px !important;/s,
|
||||
|
||||
@@ -848,6 +848,9 @@ onBeforeUnmount(() => {
|
||||
</k-button>
|
||||
</div>
|
||||
</k-card>
|
||||
<p class="skyride-player-driver-notice">
|
||||
{{ phone.t('Apps.skyride.playerDriverNotice') }}
|
||||
</p>
|
||||
<k-button
|
||||
large
|
||||
rounded
|
||||
@@ -2109,6 +2112,13 @@ onBeforeUnmount(() => {
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.skyride-player-driver-notice {
|
||||
margin: 2px 7px 8px;
|
||||
color: var(--ride-muted);
|
||||
font-size: 12px;
|
||||
line-height: 17px;
|
||||
}
|
||||
|
||||
.skyride-primary :deep(svg) {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
-- =============================================================================
|
||||
|
||||
@@ -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.",
|
||||
@@ -1426,7 +1426,9 @@ Locales["de"] = {
|
||||
errors = {
|
||||
cancelled = "Die Aufnahme ist abgesagt.", capture_failed = "Die Spielansicht kann nicht erfasst werden.",
|
||||
invalid_media_type = "Der hochgeladene Medientyp ist ungültig.", invalid_upload = "Der Upload konnte nicht überprüft werden.",
|
||||
invalid_upload_token = "Die Upload-Sitzung ist nicht mehr gültig.", missing_config = "Kamera-Uploads sind nicht konfiguriert.",
|
||||
invalid_upload_token = "Die Upload-Sitzung ist nicht mehr gültig.", media_provider_failed = "Der Kamera-Upload-Dienst ist nicht verfügbar.",
|
||||
media_provider_rate_limited = "Der Kamera-Upload-Dienst ist ausgelastet. Versuch es gleich erneut.", media_provider_unauthorized = "Der konfigurierte FiveManage API-Schlüssel wurde abgelehnt.",
|
||||
missing_config = "Kamera-Uploads sind nicht konfiguriert.",
|
||||
microphone_unavailable = "Erlaube den Mikrofonzugriff oder schalte das Mikrofon vor der Aufnahme stumm.",
|
||||
not_found = "Das Medienobjekt existiert nicht mehr.", owner_changed = "Das aktive Telefonkonto hat sich beim Upload geändert.",
|
||||
operation_in_progress = "Eine weitere Medienoperation ist bereits im Gange.",
|
||||
|
||||
@@ -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.",
|
||||
@@ -1426,7 +1426,9 @@ Locales["en"] = {
|
||||
errors = {
|
||||
cancelled = "Capture cancelled.", capture_failed = "Unable to capture the game view.",
|
||||
invalid_media_type = "The uploaded media type is invalid.", invalid_upload = "The upload could not be verified.",
|
||||
invalid_upload_token = "The upload session is no longer valid.", missing_config = "Camera uploads are not configured.",
|
||||
invalid_upload_token = "The upload session is no longer valid.", media_provider_failed = "The camera upload service is unavailable.",
|
||||
media_provider_rate_limited = "The camera upload service is busy. Try again shortly.", media_provider_unauthorized = "The configured FiveManage API key was rejected.",
|
||||
missing_config = "Camera uploads are not configured.",
|
||||
microphone_unavailable = "Allow microphone access or mute the microphone before recording.",
|
||||
not_found = "The media item no longer exists.", owner_changed = "The active phone account changed during upload.",
|
||||
operation_in_progress = "Another media operation is already in progress.",
|
||||
|
||||
@@ -6,7 +6,7 @@ use_experimental_fxv2_oal 'yes'
|
||||
|
||||
author 'Sky-Systems'
|
||||
description 'Sky Phone'
|
||||
version '0.2.0'
|
||||
version '0.2.5'
|
||||
|
||||
provide 'lb-phone'
|
||||
provide '17mov_Phone'
|
||||
@@ -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',
|
||||
@@ -106,6 +108,7 @@ server_scripts {
|
||||
'source/server/notifications.lua',
|
||||
'source/shared/public_api.lua',
|
||||
'source/server/public_api.lua',
|
||||
'source/server/media_provider_config.lua',
|
||||
'source/server/media_import.lua',
|
||||
'source/server/media_import/fivemanage.lua',
|
||||
'source/server/media_import/manifest.lua',
|
||||
@@ -149,6 +152,7 @@ files {
|
||||
'source/html/index.html',
|
||||
'source/html/assets/**',
|
||||
'source/html/img/**',
|
||||
'source/html/sounds/**',
|
||||
'config/music/**',
|
||||
}
|
||||
|
||||
|
||||
@@ -41,20 +41,18 @@ if not supported_inventories[configured_inventory] then
|
||||
error(("[sky_phone] Unsupported or unavailable inventory '%s'. Configure a supported inventory adapter."):format(tostring(configured_inventory)))
|
||||
end
|
||||
|
||||
Bridge.Inventory.Name = configured_inventory
|
||||
|
||||
if configured_inventory == "hex" or configured_inventory == "esx" then
|
||||
if Bridge.Framework.GetName() ~= "esx" then
|
||||
error(("[sky_phone] Inventory '%s' is only supported with ESX."):format(configured_inventory))
|
||||
end
|
||||
if Config.Phone.Unique ~= false then
|
||||
error(("[sky_phone] Inventory '%s' cannot store unique phone metadata. Set Config.Phone.Unique = false or configure a metadata-capable inventory."):format(configured_inventory))
|
||||
end
|
||||
if Config.Sim.Enabled ~= false then
|
||||
error(("[sky_phone] Inventory '%s' cannot store physical SIM metadata. Set Config.Sim.Enabled = false or configure a metadata-capable inventory."):format(configured_inventory))
|
||||
Bridge.Inventory.ConfigurationError =
|
||||
("[sky_phone] Inventory '%s' is only supported with ESX."):format(configured_inventory)
|
||||
elseif Config.Phone.Unique ~= false or Config.Sim.Enabled ~= false then
|
||||
Bridge.Inventory.ConfigurationError = ("[sky_phone] Inventory '%s' cannot store unique phone or physical SIM metadata. Set Config.Phone.Unique = false and Config.Sim.Enabled = false, or configure a metadata-capable inventory.")
|
||||
:format(configured_inventory)
|
||||
end
|
||||
end
|
||||
|
||||
Bridge.Inventory.Name = configured_inventory
|
||||
|
||||
function Bridge.Inventory.NormalizeItem(item, metadata_field)
|
||||
if not item then
|
||||
return nil
|
||||
|
||||
@@ -38,13 +38,12 @@ function Bridge.Inventory.GetSlotsWithItem(source, item_name, metadata)
|
||||
end
|
||||
|
||||
function Bridge.Inventory.SetSlotMetadata(source, slot_id, metadata)
|
||||
local slot = Bridge.Inventory.GetSlot(source, slot_id)
|
||||
if not slot then
|
||||
local numeric_slot = tonumber(slot_id)
|
||||
if not numeric_slot then
|
||||
return false
|
||||
end
|
||||
inventory:updateMetadata(source, slot.slot, metadata or {})
|
||||
local updated = Bridge.Inventory.GetSlot(source, slot.slot)
|
||||
return updated and Bridge.Inventory.MetadataMatches(updated.metadata, metadata or {}) or false
|
||||
inventory:setMetadata(source, numeric_slot, metadata or {})
|
||||
return true
|
||||
end
|
||||
|
||||
function Bridge.Inventory.CanCarryItem()
|
||||
|
||||
@@ -15,12 +15,16 @@ local function normalize(item)
|
||||
return nil
|
||||
end
|
||||
|
||||
local metadata = {}
|
||||
for key, value in pairs(type(item.info) == "table" and item.info or {}) do
|
||||
metadata[key] = value
|
||||
end
|
||||
return {
|
||||
name = item.name,
|
||||
slot = tonumber(item.slot),
|
||||
count = tonumber(item.amount) or 0,
|
||||
amount = tonumber(item.amount) or 0,
|
||||
metadata = type(item.info) == "table" and item.info or {},
|
||||
metadata = metadata,
|
||||
}
|
||||
end
|
||||
|
||||
@@ -77,11 +81,33 @@ function Bridge.Inventory.SetSlotMetadata(source, slot_id, metadata)
|
||||
if not player then
|
||||
return false
|
||||
end
|
||||
player.PlayerData.items[slot.slot].info = metadata or {}
|
||||
local requested_metadata = type(metadata) == "table" and metadata or {}
|
||||
player.PlayerData.items[slot.slot].info = requested_metadata
|
||||
player.Functions.SetInventory(player.PlayerData.items, true)
|
||||
return true
|
||||
local updated = player.PlayerData.items[slot.slot]
|
||||
return updated ~= nil and Bridge.Inventory.MetadataMatches(updated.info, requested_metadata)
|
||||
end
|
||||
return inventory:SetItemData(source, slot.name, "info", metadata or {}, slot.slot) == true
|
||||
|
||||
local amount = tonumber(slot.amount or slot.count) or 0
|
||||
if amount <= 0 then
|
||||
return false
|
||||
end
|
||||
|
||||
local requested_metadata = type(metadata) == "table" and metadata or {}
|
||||
if inventory:RemoveItem(source, slot.name, amount, slot.slot, "sky_phone:metadata-update") ~= true then
|
||||
return false
|
||||
end
|
||||
|
||||
if inventory:AddItem(source, slot.name, amount, slot.slot, requested_metadata, "sky_phone:metadata-update") ~= true then
|
||||
return false
|
||||
end
|
||||
|
||||
local updated = Bridge.Inventory.GetSlot(source, slot.slot)
|
||||
return updated
|
||||
and updated.name == slot.name
|
||||
and updated.amount == amount
|
||||
and Bridge.Inventory.MetadataMatches(updated.metadata, requested_metadata)
|
||||
or false
|
||||
end
|
||||
|
||||
function Bridge.Inventory.CanCarryItem(source, item_name, count)
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
local required_methods = {
|
||||
"GetResourceName",
|
||||
"GetSlot",
|
||||
"GetSlotsWithItem",
|
||||
"SetSlotMetadata",
|
||||
"CanCarryItem",
|
||||
"AddItem",
|
||||
"RemoveItem",
|
||||
"RegisterUsableItem",
|
||||
}
|
||||
|
||||
local configuration_error = Bridge.Inventory.ConfigurationError
|
||||
|
||||
for _, method_name in ipairs(required_methods) do
|
||||
if configuration_error then
|
||||
Bridge.Inventory[method_name] = function()
|
||||
error(configuration_error, 2)
|
||||
end
|
||||
elseif type(Bridge.Inventory[method_name]) ~= "function" then
|
||||
local contract_error = ("[sky_phone] Inventory adapter '%s' is missing required method '%s'.")
|
||||
:format(tostring(Bridge.Inventory.Name), method_name)
|
||||
Bridge.Inventory[method_name] = function()
|
||||
error(contract_error, 2)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -448,6 +448,13 @@ RegisterNUICallback("media:requestUpload", function(data, cb)
|
||||
cb({ success = false, error = "invalid_request" })
|
||||
return
|
||||
end
|
||||
Bridge.Debug(
|
||||
"debug",
|
||||
"[sky_phone][media-debug] NUI requested an upload (correlation=%s, type=%s).",
|
||||
tostring(data.correlationId),
|
||||
tostring(data.mediaType),
|
||||
{ notice = true }
|
||||
)
|
||||
TriggerServerEvent("sky_phone:media:request-upload", data)
|
||||
cb({ success = true })
|
||||
end)
|
||||
@@ -457,6 +464,15 @@ RegisterNUICallback("media:completeUpload", function(data, cb)
|
||||
cb({ success = false, error = "invalid_request" })
|
||||
return
|
||||
end
|
||||
Bridge.Debug(
|
||||
"debug",
|
||||
"[sky_phone][media-debug] NUI completed the provider upload (correlation=%s, remote-id=%s, url=%s, original-url=%s).",
|
||||
tostring(data.correlationId),
|
||||
type(data.remoteId) == "string" and "present" or "missing",
|
||||
type(data.url) == "string" and "present" or "missing",
|
||||
type(data.originalUrl) == "string" and "present" or "missing",
|
||||
{ notice = true }
|
||||
)
|
||||
TriggerServerEvent("sky_phone:media:complete-upload", data)
|
||||
cb({ success = true })
|
||||
end)
|
||||
@@ -475,6 +491,16 @@ RegisterNUICallback("media:failUpload", function(data, cb)
|
||||
cb({ success = false, error = "invalid_request" })
|
||||
return
|
||||
end
|
||||
local debug_message = tostring(data.debugMessage or "unknown"):gsub("[\r\n]", " "):sub(1, 240)
|
||||
Bridge.Debug(
|
||||
"error",
|
||||
"[sky_phone][media-debug] NUI reported an upload failure (correlation=%s, error=%s, stage=%s, status=%s, detail=%s).",
|
||||
tostring(data.correlationId),
|
||||
tostring(data.error),
|
||||
tostring(data.debugStage),
|
||||
tostring(data.debugStatus),
|
||||
debug_message
|
||||
)
|
||||
TriggerServerEvent("sky_phone:media:fail-upload", data)
|
||||
cb({ success = true })
|
||||
end)
|
||||
@@ -534,10 +560,26 @@ RegisterNUICallback("memos:failUpload", function(data, cb)
|
||||
end)
|
||||
|
||||
RegisterNetEvent("sky_phone:media:upload-ready", function(data)
|
||||
Bridge.Debug(
|
||||
"debug",
|
||||
"[sky_phone][media-debug] Client received upload-ready (correlation=%s, type=%s, presigned-url=%s).",
|
||||
tostring(type(data) == "table" and data.correlationId),
|
||||
tostring(type(data) == "table" and data.mediaType),
|
||||
type(data) == "table" and type(data.presignedUrl) == "string" and "present" or "missing",
|
||||
{ notice = true }
|
||||
)
|
||||
SendNUIMessage({ type = "media:uploadReady", data = data })
|
||||
end)
|
||||
|
||||
RegisterNetEvent("sky_phone:media:upload-result", function(data)
|
||||
Bridge.Debug(
|
||||
"debug",
|
||||
"[sky_phone][media-debug] Client received upload-result (correlation=%s, success=%s, error=%s).",
|
||||
tostring(type(data) == "table" and data.correlationId),
|
||||
tostring(type(data) == "table" and data.success),
|
||||
tostring(type(data) == "table" and data.error),
|
||||
{ notice = true }
|
||||
)
|
||||
SendNUIMessage({ type = "media:uploadResult", data = data })
|
||||
end)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" },
|
||||
|
||||
+216
-113
@@ -4,36 +4,19 @@ SkyPhoneMediaImport.Initialize()
|
||||
|
||||
local pending_uploads = {}
|
||||
local pending_deletes = {}
|
||||
local allowed_remote_mimes = {
|
||||
audio = {
|
||||
["audio/ogg"] = true,
|
||||
["audio/webm"] = true,
|
||||
},
|
||||
photo = {
|
||||
["image/jpeg"] = true,
|
||||
["image/png"] = true,
|
||||
["image/webp"] = true,
|
||||
},
|
||||
video = {
|
||||
["video/mp4"] = true,
|
||||
["video/webm"] = true,
|
||||
},
|
||||
local allowed_fivemanage_hosts = {
|
||||
["api.fivemanage.com"] = true,
|
||||
["fmapi.net"] = true,
|
||||
}
|
||||
|
||||
local function media_config()
|
||||
return Config.Media.FiveManage
|
||||
local function diagnostic_text(value, maximum_length)
|
||||
return tostring(value or "unknown"):gsub("[\r\n]", " "):sub(1, maximum_length)
|
||||
end
|
||||
|
||||
local function media_api_key()
|
||||
local api_key = media_config().ApiKey
|
||||
if type(api_key) ~= "string" then
|
||||
return ""
|
||||
end
|
||||
return api_key:match("^%s*(.-)%s*$")
|
||||
end
|
||||
|
||||
local function api_configured()
|
||||
return media_api_key() ~= ""
|
||||
local function media_debug(message, ...)
|
||||
local arguments = { ... }
|
||||
arguments[#arguments + 1] = { notice = true }
|
||||
Bridge.Debug("debug", "[sky_phone][media-debug] " .. message, table.unpack(arguments))
|
||||
end
|
||||
|
||||
local function http_request(url, method, body, headers, timeout_ms)
|
||||
@@ -65,6 +48,9 @@ local function response_error_message(response)
|
||||
if type(response) ~= "table" then
|
||||
return "invalid response"
|
||||
end
|
||||
if type(response.status) == "number" and response.status >= 200 and response.status < 300 then
|
||||
return "none"
|
||||
end
|
||||
if type(response.error) == "string" and response.error ~= "" then
|
||||
return response.error:sub(1, 240)
|
||||
end
|
||||
@@ -75,6 +61,10 @@ local function response_error_message(response)
|
||||
return message:sub(1, 240)
|
||||
end
|
||||
end
|
||||
local response_body = tostring(response.body or ""):gsub("[\r\n]", " ")
|
||||
if response_body ~= "" then
|
||||
return response_body:sub(1, 240)
|
||||
end
|
||||
return "no provider error message"
|
||||
end
|
||||
|
||||
@@ -95,8 +85,22 @@ local function decode_response(response)
|
||||
return decoded.data or decoded
|
||||
end
|
||||
|
||||
local function fivemanage_file_base(value)
|
||||
if type(value) ~= "string" then
|
||||
return nil
|
||||
end
|
||||
local host = value:match("^https://([^/%?#]+)")
|
||||
host = host and host:lower() or nil
|
||||
if not host or not allowed_fivemanage_hosts[host] then
|
||||
return nil
|
||||
end
|
||||
return ("https://%s/api/v3/file"):format(host), host
|
||||
end
|
||||
|
||||
local function request_presigned_url()
|
||||
if not api_configured() then
|
||||
local api_key = SkyPhoneMediaProviderConfig.FiveManageApiKey()
|
||||
media_debug("Starting FiveManage presigned upload request (api-key=%s).", api_key ~= "" and "present" or "missing")
|
||||
if api_key == "" then
|
||||
Bridge.Debug(
|
||||
"error",
|
||||
"[sky_phone] FiveManage presigned upload request failed: Config.Media.FiveManage.ApiKey is empty or invalid.",
|
||||
@@ -109,9 +113,10 @@ local function request_presigned_url()
|
||||
tostring(config.BaseUrl):gsub("/+$", "") .. "/presigned-url",
|
||||
"GET",
|
||||
"",
|
||||
{ ["Authorization"] = media_api_key() },
|
||||
{ ["Authorization"] = api_key },
|
||||
tonumber(config.RequestTimeoutMs) or 10000
|
||||
)
|
||||
media_debug("FiveManage presigned upload request returned HTTP %s.", tostring(response.status))
|
||||
if response.status == 401 or response.status == 403 then
|
||||
Bridge.Debug(
|
||||
"error",
|
||||
@@ -150,40 +155,41 @@ local function request_presigned_url()
|
||||
)
|
||||
return nil, "media_provider_failed"
|
||||
end
|
||||
local provider_base_url, provider_host = fivemanage_file_base(presigned_url)
|
||||
if not provider_base_url then
|
||||
Bridge.Debug(
|
||||
"error",
|
||||
"[sky_phone] FiveManage returned a presigned URL on an unexpected host.",
|
||||
{ always = true }
|
||||
)
|
||||
return nil, "media_provider_failed"
|
||||
end
|
||||
media_debug("FiveManage returned a valid presigned upload URL (host=%s).", provider_host)
|
||||
return presigned_url
|
||||
end
|
||||
|
||||
local function get_remote_file(remote_id)
|
||||
if not api_configured() then
|
||||
return nil, "missing_config"
|
||||
local function encode_remote_path(value)
|
||||
local segments = {}
|
||||
for segment in tostring(value):gmatch("[^/]+") do
|
||||
segments[#segments + 1] = SkyPhoneMediaImport.UrlEncode(segment)
|
||||
end
|
||||
local config = Config.Media.FiveManage
|
||||
local response = http_request(
|
||||
("%s/%s"):format(
|
||||
tostring(config.BaseUrl):gsub("/+$", ""),
|
||||
SkyPhoneMediaImport.UrlEncode(remote_id)
|
||||
),
|
||||
"GET",
|
||||
"",
|
||||
{ ["Authorization"] = media_api_key() },
|
||||
tonumber(config.RequestTimeoutMs) or 10000
|
||||
)
|
||||
return decode_response(response)
|
||||
return table.concat(segments, "/")
|
||||
end
|
||||
|
||||
local function delete_remote_file(remote_id)
|
||||
if not api_configured() then
|
||||
local api_key = SkyPhoneMediaProviderConfig.FiveManageApiKey()
|
||||
if api_key == "" then
|
||||
return false, "missing_config"
|
||||
end
|
||||
local config = Config.Media.FiveManage
|
||||
local response = http_request(
|
||||
("%s/%s"):format(
|
||||
tostring(config.BaseUrl):gsub("/+$", ""),
|
||||
SkyPhoneMediaImport.UrlEncode(remote_id)
|
||||
encode_remote_path(remote_id)
|
||||
),
|
||||
"DELETE",
|
||||
"",
|
||||
{ ["Authorization"] = media_api_key() },
|
||||
{ ["Authorization"] = api_key },
|
||||
tonumber(config.RequestTimeoutMs) or 10000
|
||||
)
|
||||
if response.status < 200 or response.status >= 300 then
|
||||
@@ -289,6 +295,14 @@ function SkyPhoneMedia.ResolveOwnedMedia(source, media_id, media_type)
|
||||
end
|
||||
|
||||
local function upload_result(source, correlation_id, success, error_code, media)
|
||||
media_debug(
|
||||
"Sending upload result (source=%s, correlation=%s, success=%s, error=%s, media=%s).",
|
||||
tostring(source),
|
||||
diagnostic_text(correlation_id, 80),
|
||||
tostring(success),
|
||||
diagnostic_text(error_code, 80),
|
||||
type(media) == "table" and "present" or "missing"
|
||||
)
|
||||
TriggerClientEvent("sky_phone:media:upload-result", source, {
|
||||
correlationId = correlation_id,
|
||||
success = success,
|
||||
@@ -315,79 +329,42 @@ local function delete_many_result(source, correlation_id, success, error_code, d
|
||||
})
|
||||
end
|
||||
|
||||
local function parse_metadata(value)
|
||||
if type(value) == "table" then
|
||||
return value
|
||||
end
|
||||
if type(value) ~= "string" then
|
||||
return nil
|
||||
end
|
||||
local success, decoded = pcall(json.decode, value)
|
||||
return success and type(decoded) == "table" and decoded or nil
|
||||
end
|
||||
|
||||
local function valid_remote_id(value)
|
||||
return type(value) == "string" and #value >= 4 and #value <= 128 and value:match("^[%w_%-]+$") ~= nil
|
||||
end
|
||||
|
||||
local function verify_remote_upload(state, remote_id, uploaded_url)
|
||||
if not valid_remote_id(remote_id) or type(uploaded_url) ~= "string" or #uploaded_url > 2048
|
||||
if not valid_remote_id(remote_id) or type(uploaded_url) ~= "string"
|
||||
or #uploaded_url > Config.Media.UrlMaxLength
|
||||
or not uploaded_url:match("^https://")
|
||||
then
|
||||
Bridge.Debug(
|
||||
"error",
|
||||
"[sky_phone][media-debug] Upload completion payload is invalid (remote-id=%s, url=%s).",
|
||||
valid_remote_id(remote_id) and "valid" or "invalid",
|
||||
type(uploaded_url) == "string" and uploaded_url:match("^https://") and "https" or "invalid"
|
||||
)
|
||||
return nil, "invalid_upload"
|
||||
end
|
||||
local remote, remote_error = get_remote_file(remote_id)
|
||||
if not remote then
|
||||
return nil, remote_error
|
||||
end
|
||||
if remote.id ~= remote_id then
|
||||
|
||||
local uploaded_host = uploaded_url:match("^https://([^/%?#]+)")
|
||||
if not uploaded_host or uploaded_host:lower() ~= "r2.fivemanage.com" then
|
||||
Bridge.Debug(
|
||||
"error",
|
||||
"[sky_phone][media-debug] FiveManage upload returned an unexpected media host."
|
||||
)
|
||||
return nil, "invalid_upload"
|
||||
end
|
||||
if remote.url ~= uploaded_url and remote.originalUrl ~= uploaded_url then
|
||||
return nil, "invalid_upload"
|
||||
end
|
||||
local verified_url = remote.url or uploaded_url
|
||||
if type(verified_url) ~= "string" or #verified_url > Config.Media.UrlMaxLength
|
||||
or not verified_url:match("^https://")
|
||||
then
|
||||
return nil, "invalid_upload"
|
||||
end
|
||||
local metadata = parse_metadata(remote.metadata)
|
||||
if not metadata or metadata.captureToken ~= state.capture_token or metadata.source ~= "sky_phone" then
|
||||
return nil, "invalid_upload_token"
|
||||
end
|
||||
if state.purpose and metadata.purpose ~= state.purpose then
|
||||
return nil, "invalid_upload", true
|
||||
end
|
||||
local allowed_mimes = allowed_remote_mimes[state.media_type]
|
||||
if not allowed_mimes then
|
||||
return nil, "invalid_media_type", true
|
||||
end
|
||||
local remote_mime = tostring(remote.mimeType or ""):lower():match("^%s*([^;%s]+)") or ""
|
||||
local remote_type = tostring(remote.type or ""):lower():match("^%s*([^;%s]+)") or ""
|
||||
if remote_mime == "" and allowed_mimes[remote_type] then
|
||||
remote_mime = remote_type
|
||||
end
|
||||
if remote_type == "" then
|
||||
remote_type = remote_mime
|
||||
end
|
||||
if state.media_type == "photo" and remote_type ~= "" and not remote_type:find("image", 1, true) then
|
||||
return nil, "invalid_media_type", true
|
||||
end
|
||||
if state.media_type == "video" and remote_type ~= "" and not remote_type:find("video", 1, true) then
|
||||
return nil, "invalid_media_type", true
|
||||
end
|
||||
if state.media_type == "audio" and remote_type ~= "" and not remote_type:find("audio", 1, true) then
|
||||
return nil, "invalid_media_type", true
|
||||
end
|
||||
if remote_mime ~= "" and not allowed_mimes[remote_mime] then
|
||||
return nil, "invalid_media_type", true
|
||||
end
|
||||
media_debug(
|
||||
"Accepting the direct FiveManage upload response (type=%s, size=%s).",
|
||||
tostring(state.media_type),
|
||||
tostring(state.size_bytes)
|
||||
)
|
||||
return {
|
||||
mime_type = allowed_mimes[remote_mime] and remote_mime or state.mime_type,
|
||||
mime_type = state.mime_type,
|
||||
remote_id = remote_id,
|
||||
size = tonumber(remote.size),
|
||||
url = verified_url,
|
||||
size = state.size_bytes,
|
||||
url = uploaded_url,
|
||||
}, nil, true
|
||||
end
|
||||
|
||||
@@ -401,6 +378,12 @@ local function expire_upload(request_id)
|
||||
return
|
||||
end
|
||||
pending_uploads[request_id] = nil
|
||||
Bridge.Debug(
|
||||
"error",
|
||||
"[sky_phone][media-debug] Upload session expired before completion (source=%s, correlation=%s).",
|
||||
tostring(state.source),
|
||||
diagnostic_text(state.correlation_id, 80)
|
||||
)
|
||||
upload_result(state.source, state.correlation_id, false, "upload_timeout")
|
||||
end
|
||||
|
||||
@@ -660,35 +643,81 @@ RegisterNetEvent("sky_phone:media:request-upload", function(data)
|
||||
data = type(data) == "table" and data or {}
|
||||
local correlation_id = data.correlationId
|
||||
local media_type = data.mediaType
|
||||
media_debug(
|
||||
"Server received upload request (source=%s, correlation=%s, type=%s).",
|
||||
tostring(src),
|
||||
diagnostic_text(correlation_id, 80),
|
||||
diagnostic_text(media_type, 20)
|
||||
)
|
||||
if type(correlation_id) ~= "string" or #correlation_id > 80
|
||||
or (media_type ~= "photo" and media_type ~= "video")
|
||||
then
|
||||
Bridge.Debug(
|
||||
"error",
|
||||
"[sky_phone][media-debug] Upload request validation failed (source=%s, correlation-type=%s, correlation-length=%s, media-type=%s).",
|
||||
tostring(src),
|
||||
type(correlation_id),
|
||||
type(correlation_id) == "string" and tostring(#correlation_id) or "invalid",
|
||||
diagnostic_text(media_type, 20)
|
||||
)
|
||||
upload_result(src, correlation_id, false, "invalid_request")
|
||||
return
|
||||
end
|
||||
if not SkyPhone.AllowOperation(src, "media_write", 20, 60) then
|
||||
Bridge.Debug(
|
||||
"warn",
|
||||
"[sky_phone][media-debug] Upload request was rate limited (source=%s, correlation=%s).",
|
||||
tostring(src),
|
||||
diagnostic_text(correlation_id, 80)
|
||||
)
|
||||
upload_result(src, correlation_id, false, "rate_limited")
|
||||
return
|
||||
end
|
||||
local owner, error_response = session_owner(src)
|
||||
if not owner then
|
||||
Bridge.Debug(
|
||||
"error",
|
||||
"[sky_phone][media-debug] Upload request has no valid phone session (source=%s, correlation=%s, error=%s).",
|
||||
tostring(src),
|
||||
diagnostic_text(correlation_id, 80),
|
||||
diagnostic_text(error_response and error_response.error, 80)
|
||||
)
|
||||
upload_result(src, correlation_id, false, error_response.error)
|
||||
return
|
||||
end
|
||||
media_debug(
|
||||
"Upload request session resolved (source=%s, correlation=%s, owner=%s).",
|
||||
tostring(src),
|
||||
diagnostic_text(correlation_id, 80),
|
||||
owner.account_id and "account" or "device"
|
||||
)
|
||||
local presigned_url, presigned_error = request_presigned_url()
|
||||
if not presigned_url then
|
||||
Bridge.Debug(
|
||||
"error",
|
||||
"[sky_phone][media-debug] Presigned upload request failed (source=%s, correlation=%s, error=%s).",
|
||||
tostring(src),
|
||||
diagnostic_text(correlation_id, 80),
|
||||
diagnostic_text(presigned_error, 80)
|
||||
)
|
||||
upload_result(src, correlation_id, false, presigned_error)
|
||||
return
|
||||
end
|
||||
local ids = Bridge.Database.Query("SELECT UUID() AS `request_id`, UUID() AS `capture_token`", {})
|
||||
local ids = Bridge.Database.Query("SELECT UUID() AS `request_id`", {})
|
||||
local request_id = ids[1] and ids[1].request_id
|
||||
local capture_token = ids[1] and ids[1].capture_token
|
||||
if type(request_id) ~= "string" or type(capture_token) ~= "string" then
|
||||
if type(request_id) ~= "string" then
|
||||
Bridge.Debug(
|
||||
"error",
|
||||
"[sky_phone][media-debug] Database did not generate an upload request ID (source=%s, correlation=%s, rows=%s, request-id=%s).",
|
||||
tostring(src),
|
||||
diagnostic_text(correlation_id, 80),
|
||||
tostring(type(ids) == "table" and #ids or 0),
|
||||
type(request_id)
|
||||
)
|
||||
upload_result(src, correlation_id, false, "request_failed")
|
||||
return
|
||||
end
|
||||
pending_uploads[request_id] = {
|
||||
capture_token = capture_token,
|
||||
correlation_id = correlation_id,
|
||||
media_type = media_type,
|
||||
mime_type = media_type == "video" and "video/webm"
|
||||
@@ -700,8 +729,13 @@ RegisterNetEvent("sky_phone:media:request-upload", function(data)
|
||||
SetTimeout(tonumber(Config.Media.UploadSessionTimeoutMs) or 60000, function()
|
||||
expire_upload(request_id)
|
||||
end)
|
||||
media_debug(
|
||||
"Sending upload-ready to client (source=%s, correlation=%s, type=%s).",
|
||||
tostring(src),
|
||||
diagnostic_text(correlation_id, 80),
|
||||
tostring(media_type)
|
||||
)
|
||||
TriggerClientEvent("sky_phone:media:upload-ready", src, {
|
||||
captureToken = capture_token,
|
||||
correlationId = correlation_id,
|
||||
mediaType = media_type,
|
||||
photo = Config.Media.Photo,
|
||||
@@ -718,18 +752,55 @@ RegisterNetEvent("sky_phone:media:complete-upload", function(data)
|
||||
local request_id = data.requestId
|
||||
local state = type(request_id) == "string" and pending_uploads[request_id] or nil
|
||||
if not state or state.source ~= src or state.completing then
|
||||
Bridge.Debug(
|
||||
"warn",
|
||||
"[sky_phone][media-debug] Rejected upload completion (source=%s, request-id=%s, session=%s, owner-match=%s, completing=%s).",
|
||||
tostring(src),
|
||||
type(request_id) == "string" and "present" or "invalid",
|
||||
state and "present" or "missing",
|
||||
tostring(state and state.source == src),
|
||||
tostring(state and state.completing)
|
||||
)
|
||||
return
|
||||
end
|
||||
media_debug(
|
||||
"Server received upload completion (source=%s, correlation=%s, remote-id=%s, url=%s).",
|
||||
tostring(src),
|
||||
diagnostic_text(state.correlation_id, 80),
|
||||
type(data.remoteId) == "string" and "present" or "missing",
|
||||
type(data.url) == "string" and "present" or "missing"
|
||||
)
|
||||
state.completing = true
|
||||
local owner, error_response = session_owner(src)
|
||||
if not owner or not owners_match(owner, state.owner) then
|
||||
pending_uploads[request_id] = nil
|
||||
Bridge.Debug(
|
||||
"error",
|
||||
"[sky_phone][media-debug] Upload owner changed before completion (source=%s, correlation=%s, session=%s, owner-match=%s, error=%s).",
|
||||
tostring(src),
|
||||
diagnostic_text(state.correlation_id, 80),
|
||||
owner and "present" or "missing",
|
||||
tostring(owner and owners_match(owner, state.owner)),
|
||||
diagnostic_text(error_response and error_response.error, 80)
|
||||
)
|
||||
upload_result(src, state.correlation_id, false, error_response and error_response.error or "owner_changed")
|
||||
return
|
||||
end
|
||||
local verified, verify_error, trusted_remote = verify_remote_upload(state, data.remoteId, data.url)
|
||||
local verified, verify_error, trusted_remote = verify_remote_upload(
|
||||
state,
|
||||
data.remoteId,
|
||||
data.url
|
||||
)
|
||||
if not verified then
|
||||
pending_uploads[request_id] = nil
|
||||
Bridge.Debug(
|
||||
"error",
|
||||
"[sky_phone][media-debug] Uploaded file verification failed (source=%s, correlation=%s, error=%s, trusted-remote=%s).",
|
||||
tostring(src),
|
||||
diagnostic_text(state.correlation_id, 80),
|
||||
diagnostic_text(verify_error, 80),
|
||||
tostring(trusted_remote)
|
||||
)
|
||||
if trusted_remote then
|
||||
local deleted, delete_error = delete_remote_file(data.remoteId)
|
||||
if not deleted then
|
||||
@@ -760,9 +831,22 @@ RegisterNetEvent("sky_phone:media:complete-upload", function(data)
|
||||
local media_id = type(result) == "number" and result or (type(result) == "table" and tonumber(result.insertId))
|
||||
if not media_id then
|
||||
delete_remote_file(verified.remote_id)
|
||||
Bridge.Debug(
|
||||
"error",
|
||||
"[sky_phone][media-debug] Database insert did not return a media ID (source=%s, correlation=%s, result-type=%s).",
|
||||
tostring(src),
|
||||
diagnostic_text(state.correlation_id, 80),
|
||||
type(result)
|
||||
)
|
||||
upload_result(src, state.correlation_id, false, "request_failed")
|
||||
return
|
||||
end
|
||||
media_debug(
|
||||
"Media upload completed successfully (source=%s, correlation=%s, media-id=%s).",
|
||||
tostring(src),
|
||||
diagnostic_text(state.correlation_id, 80),
|
||||
tostring(media_id)
|
||||
)
|
||||
upload_result(src, state.correlation_id, true, nil, {
|
||||
id = media_id,
|
||||
url = verified.url,
|
||||
@@ -787,6 +871,15 @@ RegisterNetEvent("sky_phone:media:fail-upload", function(data)
|
||||
local request_id = type(data) == "table" and data.requestId or nil
|
||||
local state = type(request_id) == "string" and pending_uploads[request_id] or nil
|
||||
if not state or state.source ~= src or state.completing then
|
||||
Bridge.Debug(
|
||||
"warn",
|
||||
"[sky_phone][media-debug] Rejected client upload failure report (source=%s, request-id=%s, session=%s, owner-match=%s, completing=%s).",
|
||||
tostring(src),
|
||||
type(request_id) == "string" and "present" or "invalid",
|
||||
state and "present" or "missing",
|
||||
tostring(state and state.source == src),
|
||||
tostring(state and state.completing)
|
||||
)
|
||||
return
|
||||
end
|
||||
local allowed_errors = {
|
||||
@@ -797,6 +890,16 @@ RegisterNetEvent("sky_phone:media:fail-upload", function(data)
|
||||
}
|
||||
pending_uploads[request_id] = nil
|
||||
local error_code = allowed_errors[data.error] and data.error or "upload_failed"
|
||||
Bridge.Debug(
|
||||
"error",
|
||||
"[sky_phone][media-debug] Client-reported upload failure (source=%s, correlation=%s, error=%s, stage=%s, status=%s, detail=%s).",
|
||||
tostring(src),
|
||||
diagnostic_text(state.correlation_id, 80),
|
||||
diagnostic_text(error_code, 80),
|
||||
diagnostic_text(data.debugStage, 40),
|
||||
diagnostic_text(data.debugStatus, 20),
|
||||
diagnostic_text(data.debugMessage, 240)
|
||||
)
|
||||
upload_result(src, state.correlation_id, false, error_code)
|
||||
end)
|
||||
|
||||
@@ -981,7 +1084,7 @@ AddEventHandler("playerDropped", function()
|
||||
end
|
||||
end)
|
||||
|
||||
if not api_configured() then
|
||||
if SkyPhoneMediaProviderConfig.FiveManageApiKey() == "" then
|
||||
Bridge.Debug(
|
||||
"warn",
|
||||
"[sky_phone] FiveManage media integration is disabled because Config.Media.FiveManage.ApiKey is empty in config/media.lua. Camera photo and video uploads, Voice Memo uploads, remote Gallery deletion, and FiveManage imports are unavailable. Add a FiveManage V3 token with Media access and restart sky_phone.",
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
local function api_key(website)
|
||||
local configured_key = website.ApiKey or Config.Media.FiveManage.ApiKey
|
||||
if type(configured_key) ~= "string" then
|
||||
return ""
|
||||
end
|
||||
return configured_key:match("^%s*(.-)%s*$")
|
||||
return SkyPhoneMediaProviderConfig.FiveManageApiKey(website.ApiKey)
|
||||
end
|
||||
|
||||
local function provider_error(response, not_found_error)
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
SkyPhoneMediaProviderConfig = {}
|
||||
|
||||
local function trim_key(value)
|
||||
if type(value) ~= "string" then
|
||||
return ""
|
||||
end
|
||||
return value:match("^%s*(.-)%s*$")
|
||||
end
|
||||
|
||||
function SkyPhoneMediaProviderConfig.FiveManageApiKey(override_key)
|
||||
local api_key = trim_key(override_key)
|
||||
if api_key ~= "" then
|
||||
return api_key
|
||||
end
|
||||
|
||||
return trim_key(Config.Media.FiveManage.ApiKey)
|
||||
end
|
||||
@@ -170,6 +170,7 @@ local function validate_upload(data)
|
||||
local title_length = title and utf8.len(title) or nil
|
||||
local note_length = note and utf8.len(note) or nil
|
||||
local duration_ms = tonumber(data.durationMs)
|
||||
local size_bytes = tonumber(data.sizeBytes)
|
||||
local normalized_mime = allowed_audio_mimes[data.mimeType]
|
||||
local waveform = normalize_waveform(data.waveform)
|
||||
if not title_length or title_length < 1 or title_length > Config.Memos.TitleMaxLength
|
||||
@@ -177,6 +178,8 @@ local function validate_upload(data)
|
||||
or not duration_ms or duration_ms ~= duration_ms
|
||||
or duration_ms == math.huge or duration_ms == -math.huge
|
||||
or duration_ms < 300 or duration_ms > Config.Memos.MaximumDurationMs
|
||||
or not size_bytes or size_bytes ~= math.floor(size_bytes)
|
||||
or size_bytes < 1 or size_bytes > Config.Memos.MaximumBytes
|
||||
or not normalized_mime or not waveform or type(data.pinned) ~= "boolean"
|
||||
then
|
||||
return nil
|
||||
@@ -188,6 +191,7 @@ local function validate_upload(data)
|
||||
mime_type = normalized_mime,
|
||||
note = note,
|
||||
pinned = data.pinned,
|
||||
size_bytes = size_bytes,
|
||||
title = title,
|
||||
waveform = waveform,
|
||||
}
|
||||
@@ -373,20 +377,18 @@ RegisterNetEvent("sky_phone:memos:request-upload", function(data)
|
||||
upload_result(src, memo.correlation_id, false, presigned_error)
|
||||
return
|
||||
end
|
||||
local ids = Bridge.Database.Query("SELECT UUID() AS `request_id`, UUID() AS `capture_token`", {})
|
||||
local ids = Bridge.Database.Query("SELECT UUID() AS `request_id`", {})
|
||||
local request_id = ids[1] and ids[1].request_id
|
||||
local capture_token = ids[1] and ids[1].capture_token
|
||||
if type(request_id) ~= "string" or type(capture_token) ~= "string" then
|
||||
error("[sky_phone] Database did not generate a voice memo upload token.")
|
||||
if type(request_id) ~= "string" then
|
||||
error("[sky_phone] Database did not generate a voice memo upload request ID.")
|
||||
end
|
||||
pending_uploads[request_id] = {
|
||||
capture_token = capture_token,
|
||||
media_type = "audio",
|
||||
mime_type = memo.mime_type,
|
||||
memo = memo,
|
||||
owner = owner,
|
||||
owner_key = pending_owner_key,
|
||||
purpose = "memo",
|
||||
size_bytes = memo.size_bytes,
|
||||
source = src,
|
||||
}
|
||||
SetTimeout(Config.Memos.UploadSessionTimeoutMs, function()
|
||||
@@ -395,7 +397,6 @@ RegisterNetEvent("sky_phone:memos:request-upload", function(data)
|
||||
TriggerClientEvent("sky_phone:memos:upload-ready", src, {
|
||||
requestId = request_id,
|
||||
correlationId = memo.correlation_id,
|
||||
captureToken = capture_token,
|
||||
presignedUrl = presigned_url,
|
||||
uploadTimeoutMs = Config.Media.FiveManage.UploadTimeoutMs,
|
||||
})
|
||||
@@ -412,14 +413,22 @@ RegisterNetEvent("sky_phone:memos:complete-upload", function(data)
|
||||
local owner, error_response = device_owner(src, state.owner.imei)
|
||||
if not owner or owner.imei ~= state.owner.imei or owner.account_id ~= state.owner.account_id then
|
||||
pending_uploads[request_id] = nil
|
||||
local rejected, _, trusted_remote = SkyPhoneMedia.VerifyRemoteUpload(state, data.remoteId, data.url)
|
||||
local rejected, _, trusted_remote = SkyPhoneMedia.VerifyRemoteUpload(
|
||||
state,
|
||||
data.remoteId,
|
||||
data.url
|
||||
)
|
||||
if rejected or trusted_remote then
|
||||
discard_verified_upload(data.remoteId)
|
||||
end
|
||||
upload_result(src, state.memo.correlation_id, false, error_response and error_response.error or "owner_changed")
|
||||
return
|
||||
end
|
||||
local verified, verify_error, trusted_remote = SkyPhoneMedia.VerifyRemoteUpload(state, data.remoteId, data.url)
|
||||
local verified, verify_error, trusted_remote = SkyPhoneMedia.VerifyRemoteUpload(
|
||||
state,
|
||||
data.remoteId,
|
||||
data.url
|
||||
)
|
||||
if not verified then
|
||||
pending_uploads[request_id] = nil
|
||||
if trusted_remote then
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
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:^0"
|
||||
lines[#lines + 1] = "^5 cd frontend^0"
|
||||
lines[#lines + 1] = "^5 pnpm install --frozen-lockfile^0"
|
||||
lines[#lines + 1] = "^5 pnpm build^0"
|
||||
lines[#lines + 1] = "^5 Build guide: https://github.com/sky-systems/sky_phone#frontend-development^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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -360,7 +360,7 @@ CREATE TABLE IF NOT EXISTS `sky_phone_billing_invoices` (
|
||||
`title` VARCHAR(160) NOT NULL,
|
||||
`description` VARCHAR(1000) NOT NULL DEFAULT '',
|
||||
`amount` BIGINT UNSIGNED NOT NULL,
|
||||
`currency` VARCHAR(8) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL,
|
||||
`currency` VARCHAR(8) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
|
||||
`status` ENUM('open', 'processing', 'paid', 'disputed', 'cancelled', 'refunded') NOT NULL DEFAULT 'open',
|
||||
`read_at` DATETIME NULL,
|
||||
`issued_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
@@ -944,7 +944,7 @@ CREATE TABLE IF NOT EXISTS `sky_phone_skyride_rides` (
|
||||
`duration_seconds` INT UNSIGNED NOT NULL,
|
||||
`price` INT UNSIGNED NOT NULL,
|
||||
`payout_amount` INT UNSIGNED NOT NULL,
|
||||
`currency` VARCHAR(8) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL,
|
||||
`currency` VARCHAR(8) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
|
||||
`driver_vehicle_model` VARCHAR(64) NULL,
|
||||
`driver_vehicle_color` VARCHAR(64) NULL,
|
||||
`driver_vehicle_plate` VARCHAR(16) NULL,
|
||||
|
||||
+31
-6
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
@@ -0,0 +1,68 @@
|
||||
local source_path = "sky_phone/source/server/nui_build_check.lua"
|
||||
local original_print = print
|
||||
|
||||
local function run_check(files)
|
||||
local output = {}
|
||||
|
||||
print = function(message)
|
||||
output[#output + 1] = tostring(message)
|
||||
end
|
||||
GetCurrentResourceName = function()
|
||||
return "sky_phone"
|
||||
end
|
||||
LoadResourceFile = function(resource_name, path)
|
||||
assert(resource_name == "sky_phone", "NUI build check must inspect its own resource")
|
||||
return files[path]
|
||||
end
|
||||
|
||||
dofile(source_path)
|
||||
return table.concat(output, "\n")
|
||||
end
|
||||
|
||||
local valid_files = {
|
||||
["source/html/index.html"] = [[
|
||||
<link rel="stylesheet" href="./assets/sky-index.css">
|
||||
<script type="module" src="./assets/sky-index.js"></script>
|
||||
]],
|
||||
["source/html/assets/sky-index.css"] = "body{}",
|
||||
["source/html/assets/sky-index.js"] = "console.log('ready')",
|
||||
["source/html/img/custom-app.svg"] = "<svg></svg>",
|
||||
["source/html/sounds/button.mp3"] = "audio",
|
||||
}
|
||||
|
||||
assert(run_check(valid_files) == "", "a complete NUI build must not print a warning")
|
||||
|
||||
local missing_build_output = run_check({})
|
||||
assert(missing_build_output:find("SKY PHONE UI BUILD IS MISSING OR INCOMPLETE", 1, true))
|
||||
assert(missing_build_output:find("source/html/index.html", 1, true))
|
||||
assert(missing_build_output:find("source/html/assets/*", 1, true))
|
||||
assert(missing_build_output:find("source/html/img/custom-app.svg", 1, true))
|
||||
assert(missing_build_output:find("source/html/sounds/button.mp3", 1, true))
|
||||
assert(missing_build_output:find("not GitHub's automatic source archive", 1, true))
|
||||
assert(missing_build_output:find("cd frontend", 1, true))
|
||||
assert(missing_build_output:find("pnpm install --frozen-lockfile", 1, true))
|
||||
assert(missing_build_output:find("pnpm build", 1, true))
|
||||
assert(missing_build_output:find("https://github.com/sky-systems/sky_phone#frontend-development", 1, true))
|
||||
assert(not missing_build_output:find("build_frontend.bat", 1, true))
|
||||
|
||||
local missing_asset_files = {}
|
||||
for path, content in pairs(valid_files) do
|
||||
missing_asset_files[path] = content
|
||||
end
|
||||
missing_asset_files["source/html/assets/sky-index.js"] = nil
|
||||
|
||||
local missing_asset_output = run_check(missing_asset_files)
|
||||
assert(missing_asset_output:find("source/html/assets/sky-index.js", 1, true))
|
||||
assert(not missing_asset_output:find("source/html/assets/sky-index.css", 1, true))
|
||||
|
||||
local query_asset_files = {}
|
||||
for path, content in pairs(valid_files) do
|
||||
query_asset_files[path] = content
|
||||
end
|
||||
query_asset_files["source/html/index.html"] = [[
|
||||
<script type="module" src="./assets/sky-index.js?v=1#entry"></script>
|
||||
]]
|
||||
assert(run_check(query_asset_files) == "", "asset query strings and fragments must be ignored")
|
||||
|
||||
print = original_print
|
||||
io.write("Sky Phone NUI build check tests passed\n")
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user