mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-30 09:48:57 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b361922e2a |
-750
@@ -1,750 +0,0 @@
|
||||
# Sky Phone Creator API
|
||||
|
||||
This is the first-party integration contract for resources that integrate directly with `sky_phone`. It covers the shared, client, and server exports, native custom apps, and the Sky iframe bridge protocol.
|
||||
|
||||
The current native API version is `1.0.0`. The current custom-app schema and iframe protocol version is `1`.
|
||||
|
||||
> Provider compatibility is a separate surface. New integrations should call `exports["sky_phone"]` directly, not the provided `lb-phone`, `17mov_Phone`, `high-phone`, `qs-smartphone`, or `yseries` aliases.
|
||||
|
||||
## Quick start
|
||||
|
||||
Declare Sky Phone as a dependency so FiveM starts it before your resource. A custom app normally has both client and server code and exposes its web files:
|
||||
|
||||
~~~lua
|
||||
fx_version "cerulean"
|
||||
game "gta5"
|
||||
|
||||
dependency "sky_phone"
|
||||
|
||||
client_script "client.lua"
|
||||
server_script "server.lua"
|
||||
|
||||
files {
|
||||
"web/index.html",
|
||||
"web/**/*",
|
||||
}
|
||||
~~~
|
||||
|
||||
The server must have both `Config.CustomApps.Enabled` and `Config.CustomApps.ExternalApps` enabled. Treat capability discovery as authoritative because a server can disable or tighten parts of this contract.
|
||||
|
||||
### Export context and ownership
|
||||
|
||||
Client and server exports are different runtimes even when they share a name. Call each export from the matching client or server script.
|
||||
|
||||
Native custom-app exports derive the owner from FiveM's `GetInvokingResource()`. Therefore:
|
||||
|
||||
- Call them directly as `exports["sky_phone"]:ExportName(...)`.
|
||||
- Do not proxy creator calls through another resource; the proxy becomes the owner.
|
||||
- Register the client app and server policy from the same resource name.
|
||||
- Only the owner can update, remove, open, close, message, or notify its app.
|
||||
- Owned client registrations and server policies are removed automatically when the owner resource stops.
|
||||
|
||||
All input from an iframe or game client remains untrusted. Perform permission, identity, money, inventory, and other consequential validation on the server.
|
||||
|
||||
## Readiness and capabilities
|
||||
|
||||
The client API is ready when its exports load. The server API becomes ready after Sky Phone's database migration and domain services are available.
|
||||
|
||||
| Side | Poll | Local event |
|
||||
| --- | --- | --- |
|
||||
| Client | `exports["sky_phone"]:IsApiReady()` | `sky_phone:client:apiReady` with `apiVersion` |
|
||||
| Server | `exports["sky_phone"]:IsApiReady()` | `sky_phone:server:apiReady` with `apiVersion` |
|
||||
|
||||
The ready events are local events, not network events. Check first and subscribe second so your resource also works when the event has already fired:
|
||||
|
||||
~~~lua
|
||||
local function on_sky_phone_ready()
|
||||
if not exports["sky_phone"]:IsApiReady() then
|
||||
return
|
||||
end
|
||||
|
||||
local capabilities = exports["sky_phone"]:GetApiCapabilities()
|
||||
print(("[my_resource] Sky Phone API %s is ready on %s."):format(
|
||||
capabilities.apiVersion,
|
||||
capabilities.side
|
||||
))
|
||||
end
|
||||
|
||||
CreateThread(on_sky_phone_ready)
|
||||
AddEventHandler("sky_phone:client:apiReady", on_sky_phone_ready) -- client.lua
|
||||
-- Use sky_phone:server:apiReady in server.lua.
|
||||
~~~
|
||||
|
||||
`GetApiCapabilities()` returns a fresh snapshot with `apiVersion`, `side`, `ready`, and `features`. The current client feature groups include calls, camera, custom apps, equipped phone number, navigation, custom-app notifications, phone game input, and phone state. The current server feature groups include calls, custom apps, device directory, custom-app notifications, and phone-number lookup.
|
||||
|
||||
`features.customApps` is an object with `enabled` and `external`. `features.notifications` is an object with `customApps = true` and `system = false`. There is intentionally no generic first-party system-notification export for creators.
|
||||
|
||||
For custom-app-specific export names, bridge methods, protocol version, and configured size limits, call `GetCustomAppCapabilities()` on the relevant side. Do not infer a feature from the API version alone.
|
||||
|
||||
## Return and error conventions
|
||||
|
||||
The API uses these conventions:
|
||||
|
||||
- Mutations normally return `true` on success or `false, errorCode` on rejection.
|
||||
- Lookups normally return a value or `nil, errorCode`. State queries such as `GetActiveCall()` and identity shortcuts can return plain `nil` when no value exists.
|
||||
- Predicates return a boolean and generally collapse invalid input into `false`.
|
||||
- `TogglePhone()` returns only a boolean.
|
||||
- Server `SendCustomAppNotification()` returns `true, { delivered = 1 }` on delivery or `false, errorCode`.
|
||||
- Returned tables are snapshots. Mutating them does not update Sky Phone.
|
||||
- Error codes are machine-readable strings. Handle unknown future codes as well as the documented common codes.
|
||||
|
||||
A missing resource or export is a FiveM invocation error rather than one of these return values. The manifest dependency avoids the normal load-order case.
|
||||
|
||||
## Shared exports
|
||||
|
||||
These exports exist in both client and server scripts.
|
||||
|
||||
| Export | Signature | Result |
|
||||
| --- | --- | --- |
|
||||
| `GetApiVersion` | `()` | `"1.0.0"` |
|
||||
| `NormalizePhoneNumber` | `(value)` | Configured normalized number, or `nil, "invalid_phone_number"` |
|
||||
| `FormatPhoneNumber` | `(value)` | Configured display format, or `nil, "invalid_phone_number"` |
|
||||
| `IsValidImei` | `(value)` | Boolean |
|
||||
|
||||
Normalization uses the server's configured SIM prefix and length; formatting uses its configured number groups.
|
||||
|
||||
## Client exports
|
||||
|
||||
### Phone state and focus
|
||||
|
||||
| Export | Signature | Result |
|
||||
| --- | --- | --- |
|
||||
| `TogglePhone` | `(open?, noFocus?)` | Boolean |
|
||||
| `GetPhoneState` | `()` | Phone-state snapshot |
|
||||
| `GetEquippedPhoneNumber` | `()` | Authoritative normalized number or `nil` |
|
||||
| `SetPhoneGameInputEnabled` | `(enabled)` | `true` or `false, errorCode` |
|
||||
|
||||
`TogglePhone(nil)` toggles. `TogglePhone(true, true)` requests an open phone without cursor focus. A successful open request is asynchronous; observe `sky_phone:client:phoneToggled` or read `GetPhoneState()` for the confirmed UI state.
|
||||
|
||||
The phone-state snapshot is:
|
||||
|
||||
~~~lua
|
||||
{
|
||||
inCall = boolean,
|
||||
onScreen = boolean, -- compatibility alias of open
|
||||
open = boolean,
|
||||
phoneNumber = string | nil,
|
||||
}
|
||||
~~~
|
||||
|
||||
`SetPhoneGameInputEnabled(true)` allows game input while the phone is open; `false` applies the external override in the other direction. The claim is associated with the invoking resource and is cleared when that resource stops or the phone closes. It can return `resource_required`, `invalid_focus_claim`, or `phone_closed`.
|
||||
|
||||
### Navigation
|
||||
|
||||
| Export | Signature | Result |
|
||||
| --- | --- | --- |
|
||||
| `OpenApp` | `(appId)` | `true` or `false, errorCode` |
|
||||
| `CloseApp` | `(appId?)` | `true` or `false, errorCode` |
|
||||
| `GetNavigationState` | `()` | Navigation snapshot |
|
||||
| `GetCurrentApp` | `()` | Active app ID, `"home"`, or `nil` if the phone is closed |
|
||||
| `GetCurrentApp` | `(appId)` | Boolean |
|
||||
| `IsAppDataLoaded` | `()` | Boolean |
|
||||
| `IsAppInstalled` | `(appId)` | Boolean |
|
||||
|
||||
`OpenApp` requires an open phone and an installed app. `CloseApp` optionally verifies the expected active app before closing it.
|
||||
|
||||
~~~lua
|
||||
local state = exports["sky_phone"]:GetNavigationState()
|
||||
-- {
|
||||
-- currentApp = string | nil,
|
||||
-- dataLoaded = boolean,
|
||||
-- installedApps = { [appId] = true },
|
||||
-- }
|
||||
~~~
|
||||
|
||||
API readiness and app-data readiness are separate. Wait for `IsAppDataLoaded()` before assuming the installed-app catalog is populated.
|
||||
|
||||
Common navigation errors are `invalid_app_id`, `phone_closed`, `app_not_installed`, and `app_not_active`.
|
||||
|
||||
### Calls
|
||||
|
||||
| Export | Signature | Result |
|
||||
| --- | --- | --- |
|
||||
| `Dial` | `(phoneNumber?, companyId?)` | `true` or `false, errorCode` |
|
||||
| `AnswerCall` | `()` | `true` or `false, errorCode` |
|
||||
| `DeclineCall` | `()` | `true` or `false, errorCode` |
|
||||
| `HangupCall` | `()` | `true` or `false, errorCode` |
|
||||
| `TerminateCall` | `()` | `true` or `false, errorCode` |
|
||||
| `GetActiveCall` | `()` | Call snapshot or `nil` |
|
||||
| `IsInCall` | `()` | Boolean |
|
||||
|
||||
At least one non-empty dial target is required. Use `Dial("5550100")` for a number or `Dial(nil, "police")` for a company service line. If a valid company ID is supplied, its service line takes precedence.
|
||||
|
||||
`AnswerCall` and `DeclineCall` require an incoming ringing call. `HangupCall` and `TerminateCall` accept a ringing or connected call. Normal hangup/decline semantics can reroute an unanswered company call; termination force-finishes it.
|
||||
|
||||
A client call snapshot contains:
|
||||
|
||||
~~~lua
|
||||
{
|
||||
id = string,
|
||||
state = "ringing" | "connected",
|
||||
direction = "incoming" | "outgoing",
|
||||
otherNumber = string,
|
||||
startedAt = number,
|
||||
answeredAt = number | nil,
|
||||
channel = number | nil,
|
||||
speakerEnabled = boolean,
|
||||
speakerSupported = boolean,
|
||||
muted = boolean,
|
||||
muteSupported = boolean,
|
||||
|
||||
-- Outgoing payphone calls can also contain:
|
||||
elapsedSeconds = number | nil,
|
||||
totalCost = number | nil,
|
||||
}
|
||||
~~~
|
||||
|
||||
Listen to the local `sky_phone:client:callChanged` event for state transitions. Its payload is the latest call snapshot, a terminal call-state payload, or `nil` when state is reset.
|
||||
|
||||
### Camera claims
|
||||
|
||||
| Export | Signature | Result |
|
||||
| --- | --- | --- |
|
||||
| `GetCameraState` | `()` | Camera-state snapshot |
|
||||
| `SetFlashlight` | `(enabled)` | `true` or `false, errorCode` |
|
||||
| `SetSelfieCamera` | `(enabled)` | `true` or `false, errorCode` |
|
||||
| `EnableWalkableCamera` | `(selfie?)` | `true` or `false, errorCode` |
|
||||
| `DisableWalkableCamera` | `()` | `true` or `false, errorCode` |
|
||||
| `SetCameraFrozen` | `(frozen)` | `true, state` or `false, errorCode` |
|
||||
| `ToggleCameraFrozen` | `()` | `true, state` or `false, errorCode` |
|
||||
| `ReleaseCamera` | `()` | `true` or `false, errorCode` |
|
||||
|
||||
The first state-changing call that needs camera control claims it for the invoking resource. A second resource receives `camera_claimed`. If the built-in camera is already active before an external claim, the caller receives `camera_in_use`.
|
||||
|
||||
~~~lua
|
||||
local ok, err = exports["sky_phone"]:EnableWalkableCamera(true)
|
||||
if not ok then
|
||||
print(("Could not claim the phone camera: %s"):format(err))
|
||||
return
|
||||
end
|
||||
|
||||
exports["sky_phone"]:SetFlashlight(true)
|
||||
|
||||
-- Always release on the normal completion/cancel path.
|
||||
exports["sky_phone"]:ReleaseCamera()
|
||||
~~~
|
||||
|
||||
`ReleaseCamera` disables walkable mode, flashlight, and selfie mode and releases the claim. `DisableWalkableCamera` performs the same full release for the caller's claimed session. Sky Phone also performs this cleanup automatically if the owning resource stops. `SetCameraFrozen` and `ToggleCameraFrozen` require an active camera and can return `camera_not_active`.
|
||||
|
||||
The state snapshot is:
|
||||
|
||||
~~~lua
|
||||
{
|
||||
active = boolean,
|
||||
flashEnabled = boolean,
|
||||
frozen = boolean,
|
||||
selfie = boolean,
|
||||
walkable = boolean,
|
||||
}
|
||||
~~~
|
||||
|
||||
Other common errors are `resource_required` and `invalid_state`. Observe `sky_phone:client:cameraActiveChanged` for active-state changes.
|
||||
|
||||
## Native custom apps
|
||||
|
||||
A complete server-backed app normally registers a client definition and a matching server policy. The app ID and permission list should match on both sides.
|
||||
|
||||
### Client registration
|
||||
|
||||
~~~lua
|
||||
local APP_ID = "my-resource-app"
|
||||
local PERMISSIONS = {
|
||||
"app.close",
|
||||
"app.open",
|
||||
"device.storage",
|
||||
"locale.read",
|
||||
"notifications",
|
||||
"theme.read",
|
||||
}
|
||||
|
||||
local registered = false
|
||||
|
||||
local function register_app()
|
||||
if registered or not exports["sky_phone"]:IsApiReady() then
|
||||
return
|
||||
end
|
||||
|
||||
local capabilities = exports["sky_phone"]:GetCustomAppCapabilities()
|
||||
if not capabilities.externalApps then
|
||||
print("[my_resource] External Sky Phone apps are disabled.")
|
||||
return
|
||||
end
|
||||
|
||||
local ok, err = exports["sky_phone"]:AddCustomApp({
|
||||
schemaVersion = 1,
|
||||
id = APP_ID,
|
||||
name = { en = "My App", de = "Meine App" },
|
||||
description = {
|
||||
en = "An example Sky Phone app.",
|
||||
de = "Eine Sky-Phone-Beispiel-App.",
|
||||
},
|
||||
developer = "My Studio",
|
||||
category = "utilities",
|
||||
ui = "web/index.html",
|
||||
icon = "web/icon.png",
|
||||
permissions = PERMISSIONS,
|
||||
orientation = "portrait",
|
||||
defaultInstalled = false,
|
||||
removable = true,
|
||||
bridgeMode = "sky",
|
||||
|
||||
onInstall = function(data) end,
|
||||
onDelete = function(data) end,
|
||||
onOpen = function(data) end,
|
||||
onReady = function(data) end,
|
||||
onClose = function(data) end,
|
||||
})
|
||||
|
||||
if not ok then
|
||||
print(("[my_resource] AddCustomApp failed: %s"):format(err))
|
||||
return
|
||||
end
|
||||
registered = true
|
||||
end
|
||||
|
||||
CreateThread(register_app)
|
||||
AddEventHandler("sky_phone:client:apiReady", register_app)
|
||||
~~~
|
||||
|
||||
Set `bridgeMode = "sky"` explicitly for new apps. The default for external definitions is `legacy` for compatibility.
|
||||
|
||||
#### Definition fields
|
||||
|
||||
| Field | Required | Contract |
|
||||
| --- | --- | --- |
|
||||
| `schemaVersion` | Recommended | If present, must be `1` |
|
||||
| `id` | Yes | 2-64 lowercase characters matching `^[a-z0-9][a-z0-9._-]+$`; built-in IDs are reserved |
|
||||
| `name` | Yes | Non-empty string or locale map, maximum 64 UTF-8 bytes per value |
|
||||
| `description` | No | String or locale map, maximum 320 UTF-8 bytes per value |
|
||||
| `developer` | No | Non-empty string, maximum 96 UTF-8 bytes |
|
||||
| `category` | No | `games`, `productivity`, `shopping`, `social`, or `utilities`; default `utilities` |
|
||||
| `ui` | Yes | Relative resource asset or HTTPS URL |
|
||||
| `icon` | No | Relative resource asset or HTTPS URL; omitted uses the default icon |
|
||||
| `permissions` | No | Unique permission array; default empty |
|
||||
| `orientation` | No | `portrait`, `landscape`, or `any`; default `portrait` |
|
||||
| `defaultInstalled` | No | Boolean; default `false` |
|
||||
| `removable` | No | Boolean; default `true` |
|
||||
| `gridOrder` | No | Integer from 0 through 9999 |
|
||||
| `iconBackground` | No | Non-empty string up to 64 UTF-8 bytes; CR/LF, semicolons, and braces are rejected |
|
||||
| `bridgeMode` | No | `sky` or `legacy`; external default `legacy` |
|
||||
| lifecycle hooks | No | Callable `onInstall`, `onDelete`, `onOpen`, `onReady`, and `onClose` |
|
||||
|
||||
Relative `ui` and `icon` values resolve to the calling resource's `https://cfx-nui-RESOURCE/...` origin. Path traversal, unsupported schemes, and cross-resource CFX-NUI ownership are rejected. Add every local asset to the creator resource's `files` list.
|
||||
|
||||
Registration is not the same as installation. `onInstall` and `onDelete` represent the user's App Store lifecycle. `onReady` occurs after the Sky bridge handshake (or frame load in legacy mode). Hook payloads are JSON-safe data or `nil`. Keep authoritative mutations on the server.
|
||||
|
||||
`UpdateCustomApp(definition)` takes a complete definition with the same owned ID. `RemoveCustomApp(appId)` removes the owned registration and closes it if active. Resource-stop cleanup is automatic.
|
||||
|
||||
### Permissions
|
||||
|
||||
| Permission | Current native effect |
|
||||
| --- | --- |
|
||||
| `app.close` | Enables iframe method `app.close` |
|
||||
| `app.open` | Enables iframe method `app.open` |
|
||||
| `device.storage` | Enables iframe methods `device.storage.get` and `device.storage.set` |
|
||||
| `locale.read` | Adds `locale.read` capability and conditionally exposes `language` and `locale` in context |
|
||||
| `notifications` | Enables iframe method `notification.create` and owner-validated Lua notifications |
|
||||
| `notifications.critical` | Enables `critical = true` only for the client Lua notification export |
|
||||
| `theme.read` | Adds `theme.read` capability and conditionally exposes `colorScheme` in context |
|
||||
|
||||
`camera.capture`, `contacts.pick`, `location.read`, `media.pick`, and `nui.fetch` are accepted permission identifiers, but the current v1 iframe bridge exposes no method or context field for them. Do not infer support from the permission list; inspect `GetCustomAppCapabilities().bridgeMethods` and the context `capabilities` array.
|
||||
|
||||
### Client lifecycle exports
|
||||
|
||||
| Export | Signature | Notes |
|
||||
| --- | --- | --- |
|
||||
| `AddCustomApp` | `(definition)` | Register a new app owned by the caller |
|
||||
| `UpdateCustomApp` | `(definition)` | Replace the complete owned definition |
|
||||
| `RemoveCustomApp` | `(appId)` | Remove the owned app |
|
||||
| `OpenCustomApp` | `(appId, payload?)` | Requires an open phone and an owned registered app |
|
||||
| `CloseCustomApp` | `(appId)` | Requires that owned app to be active |
|
||||
| `SendAppMessage` | `(appId, payload)` | Canonical host-to-iframe message export |
|
||||
| `SendCustomAppMessage` | `(appId, payload)` | Exact compatibility alias of `SendAppMessage` |
|
||||
| `SendCustomAppNotification` | `(appId, notification)` | Owner- and permission-validated local notification |
|
||||
| `GetCustomAppCapabilities` | `()` | Custom-app ABI, methods, export names, and configured limits |
|
||||
|
||||
Client message dispatch requires the owned app to be active. If its frame is active but has not completed readiness, up to 64 messages are queued and flushed after readiness. An inactive app returns `app_not_active`. Payloads must be JSON-safe and fit `maximumMessageBytes`.
|
||||
|
||||
~~~lua
|
||||
local ok, err = exports["sky_phone"]:SendAppMessage(APP_ID, {
|
||||
type = "job:update",
|
||||
data = { available = true },
|
||||
})
|
||||
~~~
|
||||
|
||||
The `sky-phone-app:message` iframe envelope is documented below.
|
||||
|
||||
### Client custom-app notifications
|
||||
|
||||
The invoking resource must own the registered client app, and that definition must include `notifications`.
|
||||
|
||||
~~~lua
|
||||
local ok, err = exports["sky_phone"]:SendCustomAppNotification(APP_ID, {
|
||||
title = "My App", -- optional; defaults to the registered app name
|
||||
text = "The job is ready.",
|
||||
subtitle = "Dispatch",
|
||||
sound = "chime", -- chime, signal, or soft
|
||||
persistent = false,
|
||||
critical = false,
|
||||
route = "/apps/" .. APP_ID,
|
||||
})
|
||||
~~~
|
||||
|
||||
`text` and `content` are aliases. Title and subtitle allow up to 160 UTF-8 bytes; text allows up to 2000 UTF-8 bytes. If supplied, `route` must be exactly `/apps/APP_ID`. `critical = true` additionally requires `notifications.critical`.
|
||||
|
||||
### Server policy registration
|
||||
|
||||
Register a policy even if the client definition already lists permissions. Server-backed storage, server messages, and server notifications use the server-owned policy.
|
||||
|
||||
~~~lua
|
||||
local APP_ID = "my-resource-app"
|
||||
local PERMISSIONS = {
|
||||
"app.close",
|
||||
"app.open",
|
||||
"device.storage",
|
||||
"locale.read",
|
||||
"notifications",
|
||||
"theme.read",
|
||||
}
|
||||
|
||||
local registered = false
|
||||
|
||||
local function register_policy()
|
||||
if registered or not exports["sky_phone"]:IsApiReady() then
|
||||
return
|
||||
end
|
||||
|
||||
local ok, err = exports["sky_phone"]:AddCustomAppPolicy({
|
||||
schemaVersion = 1,
|
||||
id = APP_ID,
|
||||
permissions = PERMISSIONS,
|
||||
})
|
||||
if not ok then
|
||||
print(("[my_resource] AddCustomAppPolicy failed: %s"):format(err))
|
||||
return
|
||||
end
|
||||
registered = true
|
||||
end
|
||||
|
||||
CreateThread(register_policy)
|
||||
AddEventHandler("sky_phone:server:apiReady", register_policy)
|
||||
~~~
|
||||
|
||||
| Export | Signature | Result |
|
||||
| --- | --- | --- |
|
||||
| `AddCustomAppPolicy` | `(definition)` | `true` or `false, errorCode` |
|
||||
| `UpdateCustomAppPolicy` | `(definition)` | `true` or `false, errorCode` |
|
||||
| `RemoveCustomAppPolicy` | `(appId)` | `true` or `false, errorCode` |
|
||||
| `GetCustomAppPolicy` | `(appId)` | Policy snapshot or `nil` |
|
||||
| `HasCustomAppPermission` | `(appId, permission)` | Boolean |
|
||||
| `GetCustomAppCapabilities` | `()` | Custom-app server capabilities |
|
||||
|
||||
A policy snapshot is:
|
||||
|
||||
~~~lua
|
||||
{
|
||||
bundled = boolean,
|
||||
id = string,
|
||||
ownerResource = string,
|
||||
permissions = { "sorted", "permission.list" },
|
||||
}
|
||||
~~~
|
||||
|
||||
### Server-to-player messages
|
||||
|
||||
`SendAppMessage(playerSource, appId, payload)` is canonical. `SendCustomAppMessage(playerSource, appId, payload)` is its exact compatibility alias.
|
||||
|
||||
~~~lua
|
||||
local ok, err = exports["sky_phone"]:SendAppMessage(player_source, APP_ID, {
|
||||
type = "server:update",
|
||||
data = { status = "ready" },
|
||||
})
|
||||
~~~
|
||||
|
||||
The export validates the direct policy owner, player source, online player, JSON payload, and configured message-size limit. `true` means the server accepted and dispatched the event. It does not acknowledge iframe delivery. The target client still needs the matching owned app to be registered and active; otherwise it rejects the message locally.
|
||||
|
||||
### Server custom-app notifications
|
||||
|
||||
The server form is `SendCustomAppNotification(playerSource, appId, notification)`. It verifies that the invoking resource owns the policy and that the policy contains `notifications`.
|
||||
|
||||
~~~lua
|
||||
local ok, result_or_error =
|
||||
exports["sky_phone"]:SendCustomAppNotification(player_source, APP_ID, {
|
||||
title = "My App",
|
||||
text = "The job is ready.", -- content is also accepted
|
||||
})
|
||||
|
||||
if ok then
|
||||
print(("Delivered %d notification."):format(result_or_error.delivered))
|
||||
else
|
||||
print(("Notification failed: %s"):format(result_or_error))
|
||||
end
|
||||
~~~
|
||||
|
||||
The server form supports a required title (up to 160 UTF-8 bytes) and required `text` or `content` (up to 2000 UTF-8 bytes). It delivers only to an online player whose equipped device can be revalidated. Success is `true, { delivered = 1 }`. There is no offline queue. Use the client export for subtitle, sound, persistence, critical state, or an explicit app route.
|
||||
|
||||
## Sky iframe protocol v1
|
||||
|
||||
Use `bridgeMode = "sky"`. Sky Phone appends `skyPhoneAppId=APP_ID` to the iframe URL.
|
||||
|
||||
### Secure handshake
|
||||
|
||||
Install the message listener before announcing readiness. In production the parent NUI origin is `https://cfx-nui-sky_phone`. If you use a development host, inject its expected parent origin explicitly rather than accepting every origin.
|
||||
|
||||
~~~js
|
||||
const appId = new URL(window.location.href).searchParams.get("skyPhoneAppId");
|
||||
const protocolVersion = 1;
|
||||
const phoneOrigin = "https://cfx-nui-sky_phone";
|
||||
|
||||
function postToPhone(message) {
|
||||
window.parent.postMessage(
|
||||
{ ...message, appId, protocolVersion },
|
||||
phoneOrigin,
|
||||
);
|
||||
}
|
||||
|
||||
window.addEventListener("message", (event) => {
|
||||
if (event.source !== window.parent || event.origin !== phoneOrigin) return;
|
||||
const message = event.data;
|
||||
if (
|
||||
!message ||
|
||||
message.appId !== appId ||
|
||||
message.protocolVersion !== protocolVersion
|
||||
) return;
|
||||
|
||||
if (message.type === "sky-phone-app:context") {
|
||||
// Read only fields allowed by message.context.capabilities.
|
||||
} else if (message.type === "sky-phone-app:open") {
|
||||
// Handle message.data.
|
||||
} else if (message.type === "sky-phone-app:message") {
|
||||
// Handle message.payload.
|
||||
} else if (message.type === "sky-phone-app:response") {
|
||||
// Resolve the matching message.requestId.
|
||||
}
|
||||
});
|
||||
|
||||
postToPhone({ type: "sky-phone-app:ready" });
|
||||
~~~
|
||||
|
||||
Sky Phone validates the iframe window, exact iframe origin, app ID, and protocol version before accepting a message.
|
||||
|
||||
### Host-to-iframe messages
|
||||
|
||||
| Type | Payload |
|
||||
| --- | --- |
|
||||
| `sky-phone-app:context` | `{ appId, protocolVersion, context }` |
|
||||
| `sky-phone-app:open` | `{ appId, protocolVersion, data }` |
|
||||
| `sky-phone-app:message` | `{ appId, protocolVersion, payload }` |
|
||||
| `sky-phone-app:response` | `{ appId, protocolVersion, requestId, success, data?, error? }` |
|
||||
|
||||
The v1 context is:
|
||||
|
||||
~~~ts
|
||||
type SkyPhoneAppContextV1 = {
|
||||
appId: string;
|
||||
capabilities: string[];
|
||||
phoneScale: number;
|
||||
protocolVersion: 1;
|
||||
safeArea: { top: number; right: number; bottom: number; left: number };
|
||||
|
||||
colorScheme?: "dark" | "light"; // only with theme.read
|
||||
language?: string; // only with locale.read
|
||||
locale?: { name?: string; description?: string }; // only with locale.read
|
||||
};
|
||||
~~~
|
||||
|
||||
`colorScheme` is not present without `theme.read`. `language` and `locale` are not present without `locale.read`. Treat all permission-gated fields as optional and use the returned `capabilities` array as the authority.
|
||||
|
||||
### Iframe requests
|
||||
|
||||
A request has this envelope:
|
||||
|
||||
~~~js
|
||||
postToPhone({
|
||||
type: "sky-phone-app:request",
|
||||
requestId: crypto.randomUUID(),
|
||||
method: "device.storage.get",
|
||||
payload: { key: "preferences" },
|
||||
});
|
||||
~~~
|
||||
|
||||
`requestId` must be unique, non-empty, and at most 128 characters. `method` must be non-empty and at most 64 characters. Duplicate request IDs are ignored without another response.
|
||||
|
||||
| Method | Required permission | Request payload | Success data |
|
||||
| --- | --- | --- | --- |
|
||||
| `app.close` | `app.close` | Omit | None |
|
||||
| `app.open` | `app.open` | `{ appId, data? }` | `{ appId }` |
|
||||
| `device.storage.get` | `device.storage` | `{ key }` | `{ exists, revision, value? }` |
|
||||
| `device.storage.set` | `device.storage` | `{ key, revision, value }` | `{ revision }` |
|
||||
| `notification.create` | `notifications` | `{ title, text, subtitle?, sound? }` | `{ notificationId }` |
|
||||
|
||||
`app.open` accepts an app ID up to 64 characters. Optional `data` must be a JSON object and is supported only when the target is an external app; its absolute v1 limit is 16384 bytes.
|
||||
|
||||
Storage is scoped to the equipped device IMEI and app ID. Keys match `^[A-Za-z0-9._-]{1,64}$`. A get for a missing key returns `{ exists = false, revision = 0 }`. Set requires a non-null JSON value and the revision returned by the latest get/set. New keys use revision `0`; a successful insert returns `1`. A stale write returns:
|
||||
|
||||
~~~js
|
||||
{
|
||||
success: false,
|
||||
error: "storage_conflict",
|
||||
data: { exists: true, revision: 3, value: currentValue },
|
||||
}
|
||||
~~~
|
||||
|
||||
The absolute v1 value ceiling is 65536 bytes, but the server can configure a lower per-value limit, total app quota, key count, key length, and request rate. Read the client custom-app capabilities instead of hardcoding server policy.
|
||||
|
||||
Iframe notifications require non-empty `title` (maximum 80 characters) and `text` (maximum 240). Optional `subtitle` is at most 80. `sound` is `chime`, `signal`, or `soft`. The route is always the source app. This iframe method does not expose critical or persistent notifications.
|
||||
|
||||
Bridge JSON values are bounded to depth 8, 512 nodes, 128 entries per array/object, and 64 characters per object key. Functions, cyclic objects, non-finite numbers, and prototype-sensitive keys are rejected.
|
||||
|
||||
### Legacy mode
|
||||
|
||||
`bridgeMode = "legacy"` exists for provider compatibility. It becomes ready on iframe load, sends host-message payloads without the Sky v1 envelope, and does not provide the native handshake/context/request-response contract above. New first-party apps should always select `sky`.
|
||||
|
||||
## Server phone and device API
|
||||
|
||||
Server exports are for trusted server resources. Do not relay unrestricted device-directory results to clients.
|
||||
|
||||
### Phone identity
|
||||
|
||||
| Export | Signature | Result |
|
||||
| --- | --- | --- |
|
||||
| `GetEquippedPhoneNumber` | `(playerSourceOrIdentifier)` | Normalized number, `nil`, or `nil, "api_not_ready"` |
|
||||
| `GetSourceFromPhoneNumber` | `(phoneNumber)` | Online player source, `nil`, or `nil, "api_not_ready"` |
|
||||
|
||||
`GetEquippedPhoneNumber` accepts a positive numeric player source or a non-empty framework identifier. It revalidates the equipped device. `GetSourceFromPhoneNumber` normalizes the input and resolves only an online source currently equipped with that number.
|
||||
|
||||
### Device directory
|
||||
|
||||
| Export | Input | Scope |
|
||||
| --- | --- | --- |
|
||||
| `GetOnlineDeviceBySource` | player source | Online and currently equipped |
|
||||
| `GetOnlineDeviceByPhoneNumber` | phone number | Online and currently equipped |
|
||||
| `GetOnlineDeviceByIdentifier` | framework identifier | Online and currently equipped |
|
||||
| `GetOnlineDeviceByImei` | IMEI | Online and currently equipped |
|
||||
| `GetStoredDeviceByImei` | IMEI | Persistent device record |
|
||||
| `GetStoredDeviceByPhoneNumber` | phone number | Persistent device record |
|
||||
| `GetStoredDeviceByIdentifier` | framework identifier | Persistent character device in non-unique-phone mode only |
|
||||
| `GetStoredSimByPhoneNumber` | phone number | Persistent SIM record |
|
||||
|
||||
Device lookups return a snapshot or `nil, errorCode`:
|
||||
|
||||
~~~lua
|
||||
{
|
||||
accountId = number | nil,
|
||||
deviceName = string,
|
||||
equipped = boolean,
|
||||
imei = string,
|
||||
mappedIdentifier = string | nil,
|
||||
online = boolean,
|
||||
phoneNumber = string | nil,
|
||||
registeredIdentifier = string | nil,
|
||||
simId = string | nil,
|
||||
simType = string | nil,
|
||||
source = number | nil,
|
||||
|
||||
identifier = string | nil, -- added to applicable identifier/online results
|
||||
}
|
||||
~~~
|
||||
|
||||
Stored device results always report `online = false`, `equipped = false`, and `source = nil`. Online lookups revalidate framework identity and inventory ownership before returning. `GetStoredDeviceByIdentifier` returns `identity_scope_unsupported` when `Config.Phone.Unique` is enabled.
|
||||
|
||||
A SIM snapshot is:
|
||||
|
||||
~~~lua
|
||||
{
|
||||
deviceImei = string | nil,
|
||||
phoneNumber = string,
|
||||
registeredIdentifier = string | nil,
|
||||
simId = string,
|
||||
simType = string | nil,
|
||||
}
|
||||
~~~
|
||||
|
||||
Common directory errors include `invalid_source`, `invalid_identifier`, `invalid_imei`, `invalid_phone_number`, `player_unavailable`, `device_not_found`, `device_not_equipped`, `equipped_device_ambiguous`, `identifier_ambiguous`, `device_holder_ambiguous`, `identity_inconsistent`, `identity_scope_unsupported`, and `sim_not_found`.
|
||||
|
||||
## Server call API
|
||||
|
||||
| Export | Signature | Result |
|
||||
| --- | --- | --- |
|
||||
| `GetActiveCallBySource` | `(playerSource)` | Server call snapshot or `nil, errorCode` |
|
||||
| `GetActiveCallById` | `(callId)` | Server call snapshot or `nil, errorCode` |
|
||||
| `IsPlayerInCall` | `(playerSource)` | Boolean |
|
||||
| `EndCallForSource` | `(playerSource)` | `true` or `false, errorCode` |
|
||||
| `TerminateCallForSource` | `(playerSource)` | `true` or `false, errorCode` |
|
||||
|
||||
The server snapshot contains the client call fields plus:
|
||||
|
||||
~~~lua
|
||||
{
|
||||
anonymous = false,
|
||||
caller = { source = number, number = string },
|
||||
callee = { source = number | nil, number = string },
|
||||
companyId = string | nil,
|
||||
payphone = boolean,
|
||||
video = false,
|
||||
}
|
||||
~~~
|
||||
|
||||
`GetActiveCallBySource` calculates `direction` and `otherNumber` for that participant. `GetActiveCallById` uses the caller perspective. `EndCallForSource` preserves normal company rerouting/decline behavior; `TerminateCallForSource` force-finishes the call. Common errors are `invalid_source`, `invalid_call_id`, and `call_not_found`.
|
||||
|
||||
## Useful client events
|
||||
|
||||
These are local observation events. They are not authorization boundaries.
|
||||
|
||||
| Event | Payload |
|
||||
| --- | --- |
|
||||
| `sky_phone:client:apiReady` | `apiVersion` |
|
||||
| `sky_phone:client:phoneToggled` | `open` boolean |
|
||||
| `sky_phone:client:phoneNumberChanged` | normalized number or `nil` |
|
||||
| `sky_phone:client:callChanged` | call state table or `nil` |
|
||||
| `sky_phone:client:cameraActiveChanged` | `active` boolean |
|
||||
|
||||
The server readiness event is `sky_phone:server:apiReady` with `apiVersion`.
|
||||
|
||||
## Common custom-app errors
|
||||
|
||||
This list is intentionally not closed.
|
||||
|
||||
| Area | Common error codes |
|
||||
| --- | --- |
|
||||
| Readiness/config | `api_not_ready`, `external_apps_disabled` |
|
||||
| Caller/owner | `missing_invoking_resource`, `resource_required`, `owner_resource_not_running`, `app_owner_mismatch` |
|
||||
| Registration | `invalid_definition`, `unsupported_schema_version`, `invalid_app_id`, `reserved_app_id`, `duplicate_app_id`, `app_not_found`, `bundled_app` |
|
||||
| Definition | `invalid_name`, `invalid_description`, `invalid_developer`, `invalid_category`, `invalid_asset_url`, `asset_owner_mismatch`, `invalid_permissions`, `unknown_permission`, `duplicate_permission`, `invalid_orientation`, `invalid_bridge_mode` |
|
||||
| State/message | `phone_closed`, `app_not_active`, `invalid_payload`, `payload_too_large`, `message_queue_full` |
|
||||
| Notification | `permission_denied`, `invalid_notification`, `invalid_notification_title`, `invalid_notification_text`, `invalid_notification_subtitle`, `invalid_notification_sound`, `invalid_notification_route`, `invalid_title`, `invalid_text`, `invalid_app_id`, `device_not_equipped` |
|
||||
| Iframe request | `permission_denied`, `unsupported_method`, `invalid_storage_request`, `invalid_notification`, `invalid_app_open`, `app_not_found`, `open_data_not_supported`, `open_failed`, `request_failed` |
|
||||
| Storage | `storage_not_allowed`, `rate_limited`, `invalid_storage_key`, `invalid_storage_value`, `invalid_storage_revision`, `storage_value_too_large`, `storage_conflict`, `storage_quota_exceeded`, `storage_key_limit` |
|
||||
|
||||
## Trusted adapter API
|
||||
|
||||
This section is not the normal Creator API. It exists only for isolated phone-provider compatibility adapters.
|
||||
|
||||
Only a resource explicitly listed in `Config.CustomApps.TrustedAdapters` can call a `FromAdapter` export. The adapter passes the original owner resource explicitly; Sky Phone validates the adapter, the original owner, resource state, and the existing adapter/owner binding.
|
||||
|
||||
~~~lua
|
||||
Config.CustomApps.TrustedAdapters = {
|
||||
["my_phone_adapter"] = true,
|
||||
}
|
||||
~~~
|
||||
|
||||
Client-only trusted adapter exports:
|
||||
|
||||
- `AddCustomAppFromAdapter(ownerResource, definition)`
|
||||
- `UpdateCustomAppFromAdapter(ownerResource, definition)`
|
||||
- `RemoveCustomAppFromAdapter(ownerResource, appId)`
|
||||
- `OpenCustomAppFromAdapter(ownerResource, appId, payload?)`
|
||||
- `CloseCustomAppFromAdapter(ownerResource, appId)`
|
||||
- `CloseActiveCustomAppFromAdapter(ownerResource)`
|
||||
- `SendCustomAppMessageFromAdapter(ownerResource, appId, payload)`
|
||||
- `SendCustomAppNotificationFromAdapter(ownerResource, appId, notification)`
|
||||
|
||||
Server-only trusted adapter exports:
|
||||
|
||||
- `AddCustomAppPolicyFromAdapter(ownerResource, definition)`
|
||||
- `UpdateCustomAppPolicyFromAdapter(ownerResource, definition)`
|
||||
- `RemoveCustomAppPolicyFromAdapter(ownerResource, appId)`
|
||||
|
||||
There is currently no server `SendAppMessageFromAdapter` or `SendCustomAppNotificationFromAdapter` export. `assetResource` and `compatibility` are adapter mapping fields, not part of the native first-party creator schema.
|
||||
|
||||
Normal app resources must use the direct exports and must never pass an owner resource themselves. Provider aliases and their provider-specific signatures are compatibility contracts, not aliases for every native export documented here.
|
||||
@@ -553,8 +553,6 @@ Unlisted jobs can read news but cannot manage articles.
|
||||
|
||||
Sky Phone is not limited to the apps that ship with it. Other resources can register installable custom apps, publish them through the App Store, exchange messages with their NUI, send notifications, and use server-controlled permissions and storage.
|
||||
|
||||
For the complete first-party export, ownership, readiness, and iframe protocol contract, see the [Creator API](CREATOR_API.md).
|
||||
|
||||
Its native custom app surface includes client and server exports for app registration, lifecycle control, messaging, notifications, capability discovery, and policy management. Sky Phone also normalizes supported custom-app contracts from:
|
||||
|
||||
- LB Phone
|
||||
@@ -567,8 +565,6 @@ The resource provides the compatibility aliases `lb-phone`, `17mov_Phone`, `high
|
||||
|
||||
That means servers can replace LB Phone without giving up supported custom apps, while developers can build directly against Sky Phone for deeper lifecycle, permission, and storage integration.
|
||||
|
||||
Start Sky Phone before the custom app resources and do not start the original phone resource for an alias at the same time. For example, an unchanged app using `exports["lb-phone"]:AddCustomApp(...)` must run with `sky_phone`, not with the original `lb-phone`, as the active provider. Two active providers expose the same FiveM export event and can send registrations to the wrong phone.
|
||||
|
||||
## Frontend development
|
||||
|
||||
Customers installing a release do not need to build the frontend.
|
||||
|
||||
+2
-60
@@ -51,7 +51,7 @@ import { useMarketplaceStore } from '@/stores/marketplace'
|
||||
import { useAppCatalogStore } from '@/stores/app-catalog'
|
||||
import { useAppStoreStore } from '@/stores/app-store'
|
||||
import { useWidgetsStore } from '@/stores/widgets'
|
||||
import { isPhoneAppId, PHONE_APPS } from '@/config/apps'
|
||||
import { isPhoneAppId } from '@/config/apps'
|
||||
import { useNotesStore } from '@/stores/notes'
|
||||
import { useMemosStore } from '@/stores/memos'
|
||||
import { useWeatherStore } from '@/stores/weather'
|
||||
@@ -106,7 +106,6 @@ type AppMessage = {
|
||||
| PhoneOpenPayload
|
||||
| CustomAppCatalogEventData
|
||||
| CustomAppEventData
|
||||
| NavigationEventData
|
||||
}
|
||||
|
||||
type CustomAppCatalogEventData = {
|
||||
@@ -119,10 +118,6 @@ type CustomAppEventData = {
|
||||
payload?: unknown
|
||||
}
|
||||
|
||||
type NavigationEventData = {
|
||||
appId?: unknown
|
||||
}
|
||||
|
||||
type SimPickerPayload = {
|
||||
choices: SimPhoneChoice[]
|
||||
number: string
|
||||
@@ -499,23 +494,6 @@ function hydratePhone(payload: PhoneOpenPayload): void {
|
||||
widgets.hydrate(payload.device?.data.widgets?.payload)
|
||||
}
|
||||
|
||||
function getInstalledNavigationAppIds(): string[] {
|
||||
const installedAppIds: string[] = []
|
||||
for (const app of PHONE_APPS) {
|
||||
if (isPhoneAppId(app.id) && appStore.isInstalled(app.id)) {
|
||||
installedAppIds.push(app.id)
|
||||
}
|
||||
}
|
||||
return installedAppIds
|
||||
}
|
||||
|
||||
function syncNavigationState(): ReturnType<typeof nuiCall> {
|
||||
return nuiCall('navigation:state', {
|
||||
currentApp: activeAppId.value || null,
|
||||
installedApps: getInstalledNavigationAppIds(),
|
||||
})
|
||||
}
|
||||
|
||||
function cancelUnlockedPhoneDataLoad(): void {
|
||||
if (unlockedServicesIdle === undefined) return
|
||||
if (typeof window.cancelIdleCallback === 'function') {
|
||||
@@ -733,33 +711,9 @@ function onMessage(event: MessageEvent<AppMessage>): void {
|
||||
if (typeof data?.appId === 'string' && route.params.appId === data.appId) {
|
||||
void router.push('/')
|
||||
}
|
||||
} else if (event.data?.type === 'navigation:open-app') {
|
||||
const data = event.data.data as NavigationEventData | undefined
|
||||
if (
|
||||
typeof data?.appId === 'string' &&
|
||||
isPhoneAppId(data.appId) &&
|
||||
appStore.isInstalled(data.appId)
|
||||
) {
|
||||
void router.push(`/apps/${data.appId}`)
|
||||
} else {
|
||||
console.error('[Navigation] Ignored an unavailable app target.')
|
||||
}
|
||||
} else if (event.data?.type === 'navigation:close-app') {
|
||||
const data = event.data.data as NavigationEventData | undefined
|
||||
const currentApp = route.params.appId
|
||||
if (data?.appId === undefined || currentApp === data.appId) {
|
||||
void router.push('/')
|
||||
}
|
||||
} else if (event.data?.type === 'compat:open-messages') {
|
||||
const data = event.data.data as MessagesEventData | undefined
|
||||
if (typeof data?.phoneNumber === 'string') {
|
||||
void messages.openThread(data.phoneNumber).then((opened) => {
|
||||
if (opened) void router.push('/apps/messages')
|
||||
})
|
||||
}
|
||||
} else if (event.data?.type === 'app:open') {
|
||||
hydratePhone(event.data.data as PhoneOpenPayload)
|
||||
void syncNavigationState().then(() => nuiCall('ui:opened'))
|
||||
void nuiCall('ui:opened')
|
||||
} else if (event.data?.type === 'device:updated') {
|
||||
hydratePhone(event.data.data as PhoneOpenPayload)
|
||||
} else if (event.data?.type === 'app:close') {
|
||||
@@ -1568,18 +1522,6 @@ watch(
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => ({
|
||||
appIds: getInstalledNavigationAppIds(),
|
||||
currentApp: activeAppId.value,
|
||||
open: phone.isOpen,
|
||||
}),
|
||||
() => {
|
||||
if (phone.isOpen && appStore.hydrated) void syncNavigationState()
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => notifications.requiresAttention,
|
||||
(requiresAttention) => {
|
||||
|
||||
@@ -4178,8 +4178,8 @@ button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
min-height: 206px;
|
||||
padding: 1px 0 10px;
|
||||
min-height: 218px;
|
||||
padding: 1px 0 16px;
|
||||
text-align: center;
|
||||
}
|
||||
.weather-location {
|
||||
@@ -4231,7 +4231,7 @@ button {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--sky-space-2);
|
||||
margin-bottom: var(--sky-space-3);
|
||||
margin-bottom: var(--sky-space-4);
|
||||
}
|
||||
.weather-detail-card,
|
||||
.weather-panel {
|
||||
@@ -4242,21 +4242,18 @@ button {
|
||||
0 8px 22px rgba(5, 16, 32, 0.14);
|
||||
color: #fff;
|
||||
}
|
||||
.weather-details > .weather-detail-card {
|
||||
.weather-detail-card {
|
||||
display: grid;
|
||||
grid-template-columns: 21px 1fr;
|
||||
grid-template-rows: auto auto;
|
||||
align-content: center;
|
||||
gap: 7px;
|
||||
min-height: 76px;
|
||||
gap: 4px 7px;
|
||||
min-height: 94px;
|
||||
margin: 0;
|
||||
padding: 11px 12px;
|
||||
padding: 14px;
|
||||
border-radius: calc(var(--sky-radius-card) - 6px);
|
||||
}
|
||||
.weather-detail-card svg {
|
||||
grid-row: span 2;
|
||||
align-self: center;
|
||||
margin-top: 0;
|
||||
margin-top: 2px;
|
||||
color: #b7d9ec;
|
||||
}
|
||||
.weather-detail-card:nth-child(1) svg {
|
||||
@@ -4276,7 +4273,7 @@ button {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.weather-detail-card strong {
|
||||
align-self: auto;
|
||||
align-self: end;
|
||||
font-size: 18px;
|
||||
font-weight: 680;
|
||||
line-height: 1.1;
|
||||
@@ -4284,7 +4281,7 @@ button {
|
||||
.weather-detail-card:nth-child(4) strong {
|
||||
color: var(--weather-accent-cyan);
|
||||
}
|
||||
.weather-scroll > .weather-panel {
|
||||
.weather-panel {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
border-radius: var(--sky-radius-card);
|
||||
@@ -4318,11 +4315,10 @@ button {
|
||||
min-height: 108px;
|
||||
padding: 7px 2px 4px;
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 0;
|
||||
border-radius: var(--sky-radius-control);
|
||||
}
|
||||
.weather-hour:first-child {
|
||||
border-left: 0;
|
||||
border-radius: var(--sky-radius-control);
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
}
|
||||
.weather-hour span {
|
||||
@@ -6615,47 +6611,9 @@ button {
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
}
|
||||
.messages-media-picker__gifs--masonry {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 10px;
|
||||
}
|
||||
.messages-gif-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
align-items: start;
|
||||
gap: 8px;
|
||||
}
|
||||
.messages-gif-column {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 8px;
|
||||
}
|
||||
.messages-media-picker__gifs--masonry .messages-gif-result {
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgb(60 60 67 / 10%);
|
||||
border-radius: 12px;
|
||||
background: var(--sky-surface-muted);
|
||||
box-shadow: none;
|
||||
}
|
||||
.messages-media-picker__gifs--masonry .messages-gif-result img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
}
|
||||
.messages-media-picker__gifs .messages-gif-more {
|
||||
width: 100%;
|
||||
min-height: 36px;
|
||||
flex: 0 0 auto;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
grid-column: 1 / -1;
|
||||
color: var(--ios-blue);
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
@@ -6977,12 +6935,6 @@ button {
|
||||
.phone-app.dark .messages-media-picker__gifs button {
|
||||
background: #2c2c2e;
|
||||
}
|
||||
.phone-app.dark
|
||||
.messages-media-picker__gifs--masonry
|
||||
.messages-gif-result {
|
||||
border-color: rgb(255 255 255 / 10%);
|
||||
background: var(--sky-surface-muted);
|
||||
}
|
||||
.phone-app.dark .messages-media-picker__gifs .messages-gif-more,
|
||||
.phone-app.dark .messages-media-picker__gifs .messages-gif-error button {
|
||||
background: rgb(10 132 255 / 20%);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const client = readFileSync(
|
||||
new URL('../../sky_phone/source/client/nui_server_bridge.lua', import.meta.url),
|
||||
new URL('../../sky_phone/source/client/main.lua', import.meta.url),
|
||||
'utf8',
|
||||
).replace(/\r\n/g, '\n')
|
||||
const companiesServer = readFileSync(
|
||||
@@ -32,7 +32,7 @@ function sourceBlock(source: string, startMarker: string, endMarker: string) {
|
||||
|
||||
describe('Companies outbound service-line call contract', () => {
|
||||
it('exposes the dedicated callback through the NUI client bridge', () => {
|
||||
expect(client).toMatch(/companies\s*=\s*\[\[[^\]]*dial-service-line/)
|
||||
expect(client).toContain(`${quote}companies:dial-service-line${quote}`)
|
||||
})
|
||||
|
||||
it('accepts only a target number and derives the company from the live server member', () => {
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const frame = readFileSync(
|
||||
new URL('./CustomAppFrame.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const appTypes = readFileSync(
|
||||
new URL('../types/apps.ts', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
describe('Custom app frame context permissions', () => {
|
||||
it('only exposes locale and theme context when their capabilities are granted', () => {
|
||||
expect(frame).toContain("capabilities.includes('theme.read')")
|
||||
expect(frame).toContain("capabilities.includes('locale.read')")
|
||||
expect(frame).toMatch(
|
||||
/capabilities\.includes\('theme\.read'\)[\s\S]*colorScheme/,
|
||||
)
|
||||
expect(frame).toMatch(
|
||||
/capabilities\.includes\('locale\.read'\)[\s\S]*language:[\s\S]*locale:/,
|
||||
)
|
||||
expect(appTypes).toContain("colorScheme?: 'dark' | 'light'")
|
||||
expect(appTypes).toContain('language?: string')
|
||||
expect(appTypes).toContain('locale?: Record<string, unknown>')
|
||||
})
|
||||
})
|
||||
@@ -10,8 +10,6 @@ import { useRouter } from 'vue-router'
|
||||
|
||||
import { getPhoneApp, isExternalPhoneApp } from '@/config/apps'
|
||||
import { useAppCatalogStore } from '@/stores/app-catalog'
|
||||
import { useCallsStore } from '@/stores/calls'
|
||||
import { useMessagesStore } from '@/stores/messages'
|
||||
import { useNotificationsStore } from '@/stores/notifications'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import type {
|
||||
@@ -35,18 +33,13 @@ import {
|
||||
getCustomAppSafeArea,
|
||||
} from '@/utils/customAppLifecycle'
|
||||
import {
|
||||
LB_PHONE_STORAGE_MESSAGE_TYPE,
|
||||
LB_PHONE_ACTION_MESSAGE_TYPE,
|
||||
createLbPhoneFrameDocument,
|
||||
createLbPhoneHostSettings,
|
||||
getLbPhoneCallbackResource,
|
||||
readLbPhoneStorage,
|
||||
usesLbPhoneHostRuntime,
|
||||
writeLbPhoneStorage,
|
||||
} from '@/utils/lbPhoneAppBridge'
|
||||
import { cloneJsonData } from '@/utils/clone'
|
||||
import { nuiCall } from '@/utils/nui'
|
||||
import type { PhoneCall } from '@/types/phone'
|
||||
|
||||
const props = defineProps<{
|
||||
app: ExternalPhoneAppDefinition
|
||||
@@ -55,8 +48,6 @@ const props = defineProps<{
|
||||
const PROTOCOL_VERSION = 1
|
||||
|
||||
const catalog = useAppCatalogStore()
|
||||
const calls = useCallsStore()
|
||||
const messages = useMessagesStore()
|
||||
const notifications = useNotificationsStore()
|
||||
const phone = usePhoneStore()
|
||||
const router = useRouter()
|
||||
@@ -137,28 +128,19 @@ const frameReady = computed(
|
||||
frameLoaded.value &&
|
||||
(props.app.bridgeMode === 'legacy' || skyBridgeReady.value),
|
||||
)
|
||||
const context = computed<SkyPhoneAppContextV1>(() => {
|
||||
const capabilities = getSkyPhoneAppCapabilities(props.app.capabilities)
|
||||
return {
|
||||
appId: props.app.id,
|
||||
capabilities,
|
||||
...(capabilities.includes('theme.read')
|
||||
? { colorScheme: phone.isDarkMode ? ('dark' as const) : ('light' as const) }
|
||||
: {}),
|
||||
...(capabilities.includes('locale.read')
|
||||
? {
|
||||
language: phone.lang,
|
||||
locale: {
|
||||
description: props.app.description,
|
||||
name: props.app.name,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
phoneScale: phone.preferences.settings.phoneScale / 100,
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
safeArea: getCustomAppSafeArea(props.app.orientation),
|
||||
}
|
||||
})
|
||||
const context = computed<SkyPhoneAppContextV1>(() => ({
|
||||
appId: props.app.id,
|
||||
capabilities: getSkyPhoneAppCapabilities(props.app.capabilities),
|
||||
colorScheme: phone.isDarkMode ? 'dark' : 'light',
|
||||
language: phone.lang,
|
||||
locale: {
|
||||
description: props.app.description,
|
||||
name: props.app.name,
|
||||
},
|
||||
phoneScale: phone.preferences.settings.phoneScale / 100,
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
safeArea: getCustomAppSafeArea(props.app.orientation),
|
||||
}))
|
||||
const lbSettings = computed(() =>
|
||||
createLbPhoneHostSettings({
|
||||
deviceName: phone.device?.name ?? '',
|
||||
@@ -209,16 +191,6 @@ async function prepareLbFrameDocument(): Promise<void> {
|
||||
const controller = new AbortController()
|
||||
frameDocumentController = controller
|
||||
try {
|
||||
let appStorage = {}
|
||||
try {
|
||||
appStorage = readLbPhoneStorage(window.localStorage, props.app.id)
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[Custom apps] Could not read LB Phone storage for ${props.app.id}.`,
|
||||
error,
|
||||
)
|
||||
}
|
||||
|
||||
const response = await fetch(frameUrl.value, {
|
||||
credentials: 'omit',
|
||||
signal: controller.signal,
|
||||
@@ -229,7 +201,6 @@ async function prepareLbFrameDocument(): Promise<void> {
|
||||
const html = await response.text()
|
||||
lbFrameDocument.value = createLbPhoneFrameDocument(html, {
|
||||
appName: props.app.id,
|
||||
localStorage: appStorage,
|
||||
resourceName: getLbPhoneCallbackResource(props.app),
|
||||
settings: lbSettings.value,
|
||||
ui: props.app.ui,
|
||||
@@ -311,55 +282,6 @@ async function handleBridgeRequest(
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLbPhoneAction(message: Record<string, unknown>) {
|
||||
if (message.action === 'createCall') {
|
||||
const options = message.options
|
||||
if (!options || typeof options !== 'object' || Array.isArray(options)) {
|
||||
console.error(
|
||||
`[Custom apps] Rejected invalid LB call action from ${props.app.id}.`,
|
||||
)
|
||||
return
|
||||
}
|
||||
const target = options as Record<string, unknown>
|
||||
if (
|
||||
typeof target.number !== 'string' &&
|
||||
typeof target.company !== 'string'
|
||||
) {
|
||||
console.error(
|
||||
`[Custom apps] Rejected invalid LB call target from ${props.app.id}.`,
|
||||
)
|
||||
return
|
||||
}
|
||||
const response = await nuiCall<PhoneCall>('calls:dial', {
|
||||
company: target.company,
|
||||
phoneNumber: target.number,
|
||||
})
|
||||
if (response.success && response.data) calls.applyCallState(response.data)
|
||||
return
|
||||
}
|
||||
|
||||
if (message.action === 'createSMS') {
|
||||
const options = message.options
|
||||
const phoneNumber =
|
||||
typeof options === 'string'
|
||||
? options
|
||||
: options && typeof options === 'object' && !Array.isArray(options)
|
||||
? ((options as Record<string, unknown>).number ??
|
||||
(options as Record<string, unknown>).phoneNumber)
|
||||
: undefined
|
||||
if (
|
||||
typeof phoneNumber !== 'string' ||
|
||||
!(await messages.openThread(phoneNumber))
|
||||
) {
|
||||
console.error(
|
||||
`[Custom apps] Rejected invalid LB SMS target from ${props.app.id}.`,
|
||||
)
|
||||
return
|
||||
}
|
||||
void router.push('/apps/messages')
|
||||
}
|
||||
}
|
||||
|
||||
function isTrustedFrameMessage(event: MessageEvent): boolean {
|
||||
if (event.source !== frame.value?.contentWindow) return false
|
||||
if (props.app.bundled || lbHostRuntime.value) {
|
||||
@@ -386,29 +308,6 @@ function onFrameMessage(event: MessageEvent): void {
|
||||
return
|
||||
}
|
||||
|
||||
if (message.type === LB_PHONE_STORAGE_MESSAGE_TYPE) {
|
||||
try {
|
||||
if (
|
||||
!writeLbPhoneStorage(window.localStorage, props.app.id, message.storage)
|
||||
) {
|
||||
console.error(
|
||||
`[Custom apps] Rejected invalid LB Phone storage for ${props.app.id}.`,
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[Custom apps] Could not persist LB Phone storage for ${props.app.id}.`,
|
||||
error,
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (message.type === LB_PHONE_ACTION_MESSAGE_TYPE) {
|
||||
void handleLbPhoneAction(message)
|
||||
return
|
||||
}
|
||||
|
||||
if (message.type === 'sky-phone-app:ready') {
|
||||
if (!skyBridgeReady.value) {
|
||||
if (loadTimeout !== undefined) clearTimeout(loadTimeout)
|
||||
|
||||
@@ -34,15 +34,6 @@ describe('SpringboardWidget UI contract', () => {
|
||||
expect(source).toContain('music.progress.value')
|
||||
})
|
||||
|
||||
it('fades empty music artwork downward while keeping its message above the fade', () => {
|
||||
expect(source).toContain('.home-widget--music-empty::after')
|
||||
expect(source).toContain(
|
||||
'.home-widget--music-empty .widget-music-placeholder',
|
||||
)
|
||||
expect(source).toContain('-webkit-mask-image: linear-gradient(')
|
||||
expect(source).toMatch(/\.widget-music-empty\s*{[\s\S]*?z-index:\s*2;/)
|
||||
})
|
||||
|
||||
it.each([
|
||||
'sunny',
|
||||
'clear',
|
||||
|
||||
@@ -1076,39 +1076,6 @@ onBeforeUnmount(() => {
|
||||
background: rgb(39 39 42 / 95%);
|
||||
}
|
||||
|
||||
.home-widget--music-empty::after {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
inset: 0;
|
||||
content: '';
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
transparent 34%,
|
||||
rgb(20 20 22 / 18%) 62%,
|
||||
rgb(16 16 18 / 82%) 100%
|
||||
);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.home-widget--music-empty .widget-album,
|
||||
.home-widget--music-empty .widget-music-placeholder {
|
||||
-webkit-mask-image: linear-gradient(
|
||||
to bottom,
|
||||
#000 0%,
|
||||
#000 42%,
|
||||
rgb(0 0 0 / 42%) 72%,
|
||||
transparent 100%
|
||||
);
|
||||
mask-image: linear-gradient(
|
||||
to bottom,
|
||||
#000 0%,
|
||||
#000 42%,
|
||||
rgb(0 0 0 / 42%) 72%,
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
|
||||
.home-widget-shell--large .home-widget--music {
|
||||
align-content: start;
|
||||
grid-template-columns: 1fr;
|
||||
@@ -1201,12 +1168,12 @@ onBeforeUnmount(() => {
|
||||
.widget-music-empty {
|
||||
position: absolute;
|
||||
right: 15px;
|
||||
bottom: 14px;
|
||||
bottom: 11px;
|
||||
left: 15px;
|
||||
z-index: 2;
|
||||
z-index: 0;
|
||||
margin: 0;
|
||||
color: rgb(255 255 255 / 72%);
|
||||
font-size: 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
line-height: 17px;
|
||||
text-align: center;
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const readResourceFile = (path: string) =>
|
||||
readFileSync(new URL(`../../sky_phone/${path}`, import.meta.url), 'utf8')
|
||||
|
||||
describe('LB runtime compatibility contracts', () => {
|
||||
it('routes legacy call and SMS actions through Sky Phone', () => {
|
||||
const callsClient = readResourceFile('source/client/calls.lua')
|
||||
const phoneBridge = readResourceFile('source/bridge/phones/client/lb.lua')
|
||||
const callsServer = readResourceFile('source/server/calls.lua')
|
||||
const phoneUi = readFileSync(new URL('./App.vue', import.meta.url), 'utf8')
|
||||
const customAppFrame = readFileSync(
|
||||
new URL('./components/CustomAppFrame.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
expect(phoneBridge).toContain(
|
||||
'SkyPhoneCompatibility.RegisterExportAlias("lb-phone", "CreateCall", create_call)',
|
||||
)
|
||||
expect(callsClient).toContain(
|
||||
'Bridge.Callbacks.Trigger("sky_phone:calls:dial"',
|
||||
)
|
||||
expect(callsServer).toContain(
|
||||
'SkyPhoneCompanies.GetServiceLineForCompany(data.company)',
|
||||
)
|
||||
expect(phoneBridge).toContain(
|
||||
'SkyPhoneCompatibility.RegisterExportAlias("lb-phone", "CreateSMS", create_sms)',
|
||||
)
|
||||
expect(phoneBridge).toContain('type = "compat:open-messages"')
|
||||
expect(phoneUi).toContain("event.data?.type === 'compat:open-messages'")
|
||||
expect(phoneUi).toContain('messages.openThread(data.phoneNumber)')
|
||||
expect(customAppFrame).toContain("message.action === 'createCall'")
|
||||
expect(customAppFrame).toContain("message.action === 'createSMS'")
|
||||
})
|
||||
|
||||
it('redirects PicChat phone identity to the Sky SIM table without deleting orphaned data', () => {
|
||||
const manifest = readResourceFile('fxmanifest.lua')
|
||||
const migration = readResourceFile(
|
||||
'source/server/lb_app_compat_migration.lua',
|
||||
)
|
||||
|
||||
expect(manifest).toContain("'source/server/lb_app_compat_migration.lua'")
|
||||
expect(
|
||||
manifest.indexOf("'source/server/lb_app_compat_migration.lua'"),
|
||||
).toBeGreaterThan(manifest.indexOf("'source/server/testdata.lua'"))
|
||||
expect(migration).toContain('"lbpicchat_logged_in"')
|
||||
expect(migration).toContain('"phone_phones"')
|
||||
expect(migration).toContain('"sky_phone_sims"')
|
||||
expect(migration).toContain('DROP FOREIGN KEY')
|
||||
expect(migration).toContain('ON DELETE CASCADE ON UPDATE CASCADE')
|
||||
expect(migration).toContain('Legacy data was preserved')
|
||||
expect(migration).toContain('FROM `INFORMATION_SCHEMA`.`KEY_COLUMN_USAGE`')
|
||||
expect(migration).not.toMatch(/KEY_COLUMN_USAGE`\s+keys/i)
|
||||
expect(migration).toContain(
|
||||
'xpcall(migrate_picchat_phone_reference, debug.traceback)',
|
||||
)
|
||||
expect(migration).toContain('Sky Phone startup will continue')
|
||||
expect(migration).not.toMatch(/DELETE\s+FROM\s+/i)
|
||||
})
|
||||
})
|
||||
@@ -4,8 +4,6 @@ import { describe, expect, it } from 'vitest'
|
||||
|
||||
const readResourceFile = (path: string) =>
|
||||
readFileSync(new URL(`../../sky_phone/${path}`, import.meta.url), 'utf8')
|
||||
const readFrontendFile = (path: string) =>
|
||||
readFileSync(new URL(path, import.meta.url), 'utf8')
|
||||
|
||||
const inventoryAdapters = [
|
||||
['ox', 'source/bridge/server/inventory/ox.lua'],
|
||||
@@ -54,165 +52,11 @@ describe('phone inventory contracts', () => {
|
||||
|
||||
it('provides the LB IsOpen export alias from the authoritative client state', () => {
|
||||
const phoneClient = readResourceFile('source/client/main.lua')
|
||||
const phoneBridge = readResourceFile('source/bridge/phones/client/lb.lua')
|
||||
|
||||
expect(phoneBridge).toContain(
|
||||
expect(phoneClient).toContain(
|
||||
'SkyPhoneCompatibility.RegisterExportAlias("lb-phone", "IsOpen"',
|
||||
)
|
||||
expect(phoneClient).toContain('open = is_open')
|
||||
expect(phoneBridge).toContain('return get_phone_state_value("open")')
|
||||
})
|
||||
|
||||
it('provides the LB equipped phone number exports from authoritative device state', () => {
|
||||
const phoneClient = readResourceFile('source/client/main.lua')
|
||||
const phoneServer = readResourceFile('source/server/phone.lua')
|
||||
const clientBridge = readResourceFile('source/bridge/phones/client/lb.lua')
|
||||
const serverBridge = readResourceFile('source/bridge/phones/server/lb.lua')
|
||||
const serverLifecycle = readResourceFile(
|
||||
'source/bridge/phones/server/lifecycle.lua',
|
||||
)
|
||||
|
||||
expect(clientBridge).toMatch(
|
||||
/"GetEquippedPhoneNumber",\s+phone\.GetEquippedPhoneNumber/,
|
||||
)
|
||||
expect(phoneClient).toContain('return device_payload.device.sim.number')
|
||||
expect(phoneClient).toContain(
|
||||
'Bridge.Callbacks.Trigger("sky_phone:device:equipped-number", {})',
|
||||
)
|
||||
expect(phoneServer).toContain(
|
||||
'Bridge.Callbacks.Register("sky_phone:device:equipped-number", function(source)',
|
||||
)
|
||||
expect(phoneServer).toContain(
|
||||
'function SkyPhone.GetEquippedPhoneNumber(player)',
|
||||
)
|
||||
expect(phoneServer).toContain(
|
||||
'cache_equipped_phone_number(source, identifier, device.phone_number)',
|
||||
)
|
||||
expect(phoneServer).toContain(
|
||||
'equipped_phone_sources[phone_number] = source',
|
||||
)
|
||||
const equippedNumberExport = phoneServer.match(
|
||||
/function SkyPhone\.GetEquippedPhoneNumber\(player\)([\s\S]*?)\nfunction SkyPhone\.GetSourceFromNumber/,
|
||||
)?.[1]
|
||||
const equippedNumberResolver = phoneServer.match(
|
||||
/local function resolve_equipped_phone_number\(source\)([\s\S]*?)\nfunction SkyPhone\.GetEquippedPhoneNumber/,
|
||||
)?.[1]
|
||||
expect(equippedNumberExport).toBeDefined()
|
||||
expect(equippedNumberResolver).toBeDefined()
|
||||
expect(equippedNumberExport).toContain('type(player) == "number"')
|
||||
expect(equippedNumberExport).toContain('online_source_for_identifier(player)')
|
||||
expect(phoneServer).toContain('Bridge.Inventory.GetSlotsWithItem(source, Config.Phone.Item)')
|
||||
expect(phoneServer).toContain('return resolve_equipped_phone_number(player)')
|
||||
expect(equippedNumberExport).not.toContain('tonumber(player)')
|
||||
expect(equippedNumberExport).not.toContain('equipped_phone_numbers[player]')
|
||||
expect(equippedNumberResolver).not.toContain('return cached_number')
|
||||
expect(phoneServer).toContain(
|
||||
'player_source and resolve_equipped_phone_number(player_source) == normalized',
|
||||
)
|
||||
expect(serverBridge).toMatch(
|
||||
/"GetEquippedPhoneNumber",\s+phone\.GetEquippedPhoneNumber/,
|
||||
)
|
||||
expect(serverBridge).toMatch(
|
||||
/"GetSourceFromNumber",\s+phone\.GetSourceFromNumber/,
|
||||
)
|
||||
expect(phoneServer).toContain(
|
||||
'TriggerEvent("sky_phone:server:phoneNumberChanged", source, phone_number)',
|
||||
)
|
||||
expect(serverLifecycle).toContain(
|
||||
'SkyPhoneCompatibility.EmitServerProviderStop(LB_PROVIDER_NAME)',
|
||||
)
|
||||
expect(serverLifecycle).toContain(
|
||||
'SkyPhoneCompatibility.EmitServerProviderStart(LB_PROVIDER_NAME)',
|
||||
)
|
||||
})
|
||||
|
||||
it('maps LB client lifecycle and state contracts', () => {
|
||||
const phoneClient = readResourceFile('source/client/main.lua')
|
||||
const phoneBridge = readResourceFile('source/bridge/phones/client/lb.lua')
|
||||
|
||||
expect(phoneBridge).toContain(
|
||||
'SkyPhoneCompatibility.RegisterExportAlias("lb-phone", "ToggleOpen"',
|
||||
)
|
||||
expect(phoneClient).toContain(
|
||||
'local result = Bridge.Callbacks.Trigger("sky_phone:device:open-request", {})',
|
||||
)
|
||||
expect(phoneClient).toMatch(
|
||||
/result\.success ~= true[\s\S]*open_without_focus = false[\s\S]*return false/,
|
||||
)
|
||||
expect(phoneBridge).toContain(
|
||||
'SkyPhoneCompatibility.RegisterExportAlias("lb-phone", "IsPhoneOnScreen"',
|
||||
)
|
||||
expect(phoneBridge).toContain(
|
||||
'SkyPhoneCompatibility.RegisterExportAlias("lb-phone", "IsInCall"',
|
||||
)
|
||||
expect(phoneBridge).toContain(
|
||||
'SkyPhoneCompatibility.RegisterExportAlias("lb-phone", "FormatNumber", client_bridge.FormatNumber)',
|
||||
)
|
||||
expect(phoneClient).toContain(
|
||||
'TriggerEvent("sky_phone:client:phoneNumberChanged", next_number)',
|
||||
)
|
||||
expect(phoneClient).toContain(
|
||||
'TriggerEvent("sky_phone:client:phoneToggled", true)',
|
||||
)
|
||||
expect(phoneClient).toContain(
|
||||
'TriggerEvent("sky_phone:client:phoneToggled", false)',
|
||||
)
|
||||
expect(phoneBridge).toContain('TriggerEvent("lb-phone:numberChanged", phone_number)')
|
||||
expect(phoneBridge).toContain('TriggerEvent("lb-phone:phoneToggled", open)')
|
||||
})
|
||||
|
||||
it('keeps vendor contracts outside the phone business core', () => {
|
||||
const corePaths = [
|
||||
'source/client/main.lua',
|
||||
'source/client/camera.lua',
|
||||
'source/client/custom_apps.lua',
|
||||
'source/server/phone.lua',
|
||||
'source/server/sim.lua',
|
||||
'source/server/media.lua',
|
||||
]
|
||||
|
||||
for (const path of corePaths) {
|
||||
expect(readResourceFile(path)).not.toMatch(
|
||||
/lb-phone|17mov|high-phone|qs-smartphone|yseries|SkyPhoneCompatibility/,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('maps the LB custom-app delete lifecycle from the App Store to Lua', () => {
|
||||
const appStore = readFrontendFile('stores/app-store.ts')
|
||||
const customApps = readResourceFile('source/client/custom_apps.lua')
|
||||
const compatibility = readResourceFile('source/bridge/phones/shared/lb.lua')
|
||||
|
||||
expect(appStore).toContain("event: 'delete'")
|
||||
expect(customApps).toContain('and lifecycle_event ~= "delete"')
|
||||
expect(customApps).toContain(
|
||||
'invoke_or_defer_hook(app, "onDelete", lifecycle_payload, deferred_hooks)',
|
||||
)
|
||||
expect(compatibility).toContain('onDelete = app_data.onDelete')
|
||||
})
|
||||
|
||||
it('emits exact LB observer events after authoritative state changes', () => {
|
||||
const phonePersistence = readResourceFile(
|
||||
'source/server/phone_persistence.lua',
|
||||
)
|
||||
const simServer = readResourceFile('source/server/sim.lua')
|
||||
const mediaServer = readResourceFile('source/server/media.lua')
|
||||
const phoneBridge = readResourceFile(
|
||||
'source/bridge/phones/server/lifecycle.lua',
|
||||
)
|
||||
|
||||
expect(simServer).toContain(
|
||||
'TriggerEvent("sky_phone:server:phoneNumberGenerated", source, sim.phone_number)',
|
||||
)
|
||||
expect(phonePersistence).toContain(
|
||||
'TriggerEvent("sky_phone:server:factoryReset", source, phone_number)',
|
||||
)
|
||||
expect(mediaServer).toContain(
|
||||
'TriggerEvent("sky_phone:server:galleryMediaDeleted", src, phone_number, deleted_link)',
|
||||
)
|
||||
expect(phoneBridge).toContain('TriggerEvent("lb-phone:phoneNumberGenerated"')
|
||||
expect(phoneBridge).toContain('TriggerEvent("lb-phone:factoryReset"')
|
||||
expect(phoneBridge).toContain('TriggerEvent("lb-phone:deletedFromGallery"')
|
||||
expect(phoneClient).toContain('return is_open')
|
||||
})
|
||||
|
||||
it('opens from a configurable F1 mapping without client-provided device identity', () => {
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const rootDirectory = join(import.meta.dirname, '..')
|
||||
const appSource = readFileSync(join(rootDirectory, 'src/App.vue'), 'utf8')
|
||||
const navigationSource = readFileSync(
|
||||
join(rootDirectory, '../sky_phone/source/client/navigation.lua'),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
describe('neutral phone navigation contract', () => {
|
||||
it('synchronizes installed renderer apps before acknowledging an opened phone', () => {
|
||||
expect(appSource).toContain("return nuiCall('navigation:state'")
|
||||
expect(appSource).toContain(
|
||||
"void syncNavigationState().then(() => nuiCall('ui:opened'))",
|
||||
)
|
||||
expect(navigationSource).toContain(
|
||||
'RegisterNUICallback("navigation:state"',
|
||||
)
|
||||
})
|
||||
|
||||
it('routes only installed apps and closes only the requested current app', () => {
|
||||
expect(appSource).toContain("event.data?.type === 'navigation:open-app'")
|
||||
expect(appSource).toContain('appStore.isInstalled(data.appId)')
|
||||
expect(appSource).toContain("event.data?.type === 'navigation:close-app'")
|
||||
expect(appSource).toContain('currentApp === data.appId')
|
||||
expect(navigationSource).toContain('if not installed_apps[normalized_app_id] then')
|
||||
expect(navigationSource).toContain('if current_app_id ~= normalized_app_id then')
|
||||
})
|
||||
})
|
||||
@@ -494,18 +494,6 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
}
|
||||
this.homeLayout = removeHomeApp(this.homeLayout, appId)
|
||||
this.persist()
|
||||
if (isExternalPhoneApp(app)) {
|
||||
void nuiCall('custom-app:lifecycle', {
|
||||
appId,
|
||||
event: 'delete',
|
||||
}).then((response) => {
|
||||
if (!response.success) {
|
||||
console.error(
|
||||
`[Custom apps] Delete lifecycle failed for ${appId}: ${response.error ?? 'request_failed'}`,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
|
||||
@@ -159,9 +159,9 @@ export type SkyPhoneAppBridgeResponse = {
|
||||
export type SkyPhoneAppContextV1 = {
|
||||
appId: string
|
||||
capabilities: SkyPhoneAppCapability[]
|
||||
colorScheme?: 'dark' | 'light'
|
||||
language?: string
|
||||
locale?: Record<string, unknown>
|
||||
colorScheme: 'dark' | 'light'
|
||||
language: string
|
||||
locale: Record<string, unknown>
|
||||
phoneScale: number
|
||||
protocolVersion: 1
|
||||
safeArea: {
|
||||
|
||||
@@ -111,18 +111,6 @@
|
||||
color: var(--sky-text, #111827);
|
||||
}
|
||||
|
||||
.sky-glass.sky-button--glass {
|
||||
border-color: var(--sky-hairline, rgba(0, 0, 0, 0.2));
|
||||
background: var(--sky-glass, rgba(255, 255, 255, 0.75));
|
||||
color: var(--sky-text, #000000);
|
||||
box-shadow: var(--sky-shadow-glass);
|
||||
}
|
||||
|
||||
.sky-glass.sky-button--glass:active:not(:disabled) {
|
||||
background: var(--sky-glass, rgba(255, 255, 255, 0.75));
|
||||
filter: brightness(0.94);
|
||||
}
|
||||
|
||||
.sky-button--danger {
|
||||
background: var(--sky-danger, #dc2626);
|
||||
}
|
||||
@@ -2745,9 +2733,6 @@ label.sky-list-item__row {
|
||||
|
||||
.sky-fab--icon-only {
|
||||
width: var(--sky-touch-target, 44px);
|
||||
height: var(--sky-touch-target, 44px);
|
||||
flex: none;
|
||||
align-self: center;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@@ -2756,16 +2741,6 @@ label.sky-list-item__row {
|
||||
color: var(--sky-text, #000000);
|
||||
}
|
||||
|
||||
.sky-glass.sky-fab--glass {
|
||||
border: 1px solid var(--sky-hairline, rgba(0, 0, 0, 0.2));
|
||||
background: var(--sky-glass, rgba(255, 255, 255, 0.75));
|
||||
color: var(--sky-text, #000000);
|
||||
box-shadow: var(--sky-shadow-glass);
|
||||
}
|
||||
|
||||
.sky-fab--glass .sky-fab__accent-layer,
|
||||
.sky-fab--glass .sky-fab__dark-accent-layer,
|
||||
.sky-fab--glass .sky-fab__surface-layer,
|
||||
.sky-fab--neutral .sky-fab__accent-layer,
|
||||
.sky-fab--neutral .sky-fab__dark-accent-layer,
|
||||
.sky-fab--neutral .sky-fab__surface-layer {
|
||||
@@ -2780,10 +2755,6 @@ label.sky-list-item__row {
|
||||
background: var(--sky-glass-solid, rgba(247, 247, 248, 0.96));
|
||||
}
|
||||
|
||||
.sky-glass.sky-fab--glass:active:not(:disabled) {
|
||||
background: var(--sky-glass, rgba(255, 255, 255, 0.75));
|
||||
}
|
||||
|
||||
.sky-fab--disabled {
|
||||
cursor: default;
|
||||
opacity: 0.42;
|
||||
@@ -2854,8 +2825,6 @@ label.sky-list-item__row {
|
||||
.sky-glass--interactive {
|
||||
min-width: var(--sky-touch-target, 44px);
|
||||
min-height: var(--sky-touch-target, 44px);
|
||||
-webkit-backdrop-filter: blur(18px) saturate(145%);
|
||||
backdrop-filter: blur(18px) saturate(145%);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
|
||||
@@ -28,30 +28,6 @@ describe('SkyButton', () => {
|
||||
expect(html).toContain('Continue')
|
||||
})
|
||||
|
||||
it('renders interactive liquid glass buttons through the shared glass surface', async () => {
|
||||
const html = await renderToString(
|
||||
createSSRApp({
|
||||
render: () =>
|
||||
h(SkyButton, { glass: true, rounded: true }, () => 'Edit'),
|
||||
}),
|
||||
)
|
||||
|
||||
expect(html).toContain('sky-button--glass')
|
||||
expect(html).toContain('sky-glass')
|
||||
expect(html).toContain('sky-glass--interactive')
|
||||
|
||||
const controls = readFileSync(
|
||||
fileURLToPath(new URL('../controls.css', import.meta.url)),
|
||||
'utf8',
|
||||
)
|
||||
expect(controls).toMatch(
|
||||
/\.sky-glass\.sky-button--glass\s*\{[^}]*background:\s*var\(--sky-glass[^}]*box-shadow:\s*var\(--sky-shadow-glass\)/s,
|
||||
)
|
||||
expect(controls).toMatch(
|
||||
/\.sky-glass--interactive\s*\{[^}]*-webkit-backdrop-filter:\s*blur\(18px\) saturate\(145%\);[^}]*backdrop-filter:\s*blur\(18px\) saturate\(145%\);/s,
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps focus and pressed feedback on the contextual accent', () => {
|
||||
const uiDirectory = fileURLToPath(new URL('..', import.meta.url))
|
||||
const controls = readFileSync(`${uiDirectory}/controls.css`, 'utf8')
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import SkyGlass from './SkyGlass.vue'
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
const props = withDefaults(
|
||||
@@ -11,7 +9,6 @@ const props = withDefaults(
|
||||
clear?: boolean
|
||||
component?: 'a' | 'button'
|
||||
disabled?: boolean
|
||||
glass?: boolean
|
||||
href?: string
|
||||
iconOnly?: boolean
|
||||
inline?: boolean
|
||||
@@ -29,7 +26,6 @@ const props = withDefaults(
|
||||
clear: false,
|
||||
component: 'button',
|
||||
disabled: false,
|
||||
glass: false,
|
||||
href: undefined,
|
||||
iconOnly: false,
|
||||
inline: false,
|
||||
@@ -48,23 +44,6 @@ const emit = defineEmits<{
|
||||
click: [event: MouseEvent]
|
||||
}>()
|
||||
|
||||
const buttonClasses = computed(() => [
|
||||
`sky-button--${props.variant}`,
|
||||
{
|
||||
'sky-button--block': props.block,
|
||||
'sky-button--clear': props.clear,
|
||||
'sky-button--glass': props.glass,
|
||||
'sky-button--icon-only': props.iconOnly,
|
||||
'sky-button--inline': props.inline,
|
||||
'sky-button--large': props.large,
|
||||
'sky-button--outline': props.outline,
|
||||
'sky-button--raised': props.raised,
|
||||
'sky-button--rounded': props.rounded,
|
||||
'sky-button--small': props.small && !props.large,
|
||||
'sky-button--tonal': props.tonal,
|
||||
},
|
||||
])
|
||||
|
||||
const elementProps = computed<Record<string, unknown>>(() => {
|
||||
if (props.component === 'a') {
|
||||
return {
|
||||
@@ -92,25 +71,25 @@ function handleClick(event: MouseEvent): void {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SkyGlass
|
||||
v-if="glass"
|
||||
:component="component"
|
||||
v-bind="{ ...$attrs, ...elementProps }"
|
||||
class="sky-button"
|
||||
:class="buttonClasses"
|
||||
:disabled="disabled"
|
||||
:href="href"
|
||||
:type="type"
|
||||
@click="handleClick"
|
||||
>
|
||||
<slot />
|
||||
</SkyGlass>
|
||||
<component
|
||||
v-else
|
||||
:is="component"
|
||||
v-bind="{ ...$attrs, ...elementProps }"
|
||||
class="sky-button"
|
||||
:class="buttonClasses"
|
||||
:class="[
|
||||
`sky-button--${variant}`,
|
||||
{
|
||||
'sky-button--block': block,
|
||||
'sky-button--clear': clear,
|
||||
'sky-button--icon-only': iconOnly,
|
||||
'sky-button--inline': inline,
|
||||
'sky-button--large': large,
|
||||
'sky-button--outline': outline,
|
||||
'sky-button--raised': raised,
|
||||
'sky-button--rounded': rounded,
|
||||
'sky-button--small': small && !large,
|
||||
'sky-button--tonal': tonal,
|
||||
},
|
||||
]"
|
||||
@click="handleClick"
|
||||
>
|
||||
<slot />
|
||||
|
||||
@@ -73,33 +73,4 @@ describe('SkyFab', () => {
|
||||
/\.sky-glass\.sky-fab--neutral\s*\{[^}]*background:\s*var\(--sky-glass-solid/s,
|
||||
)
|
||||
})
|
||||
|
||||
it('offers a translucent glass variant for adjacent floating controls', async () => {
|
||||
const html = await renderToString(
|
||||
createSSRApp({
|
||||
render: () => h(SkyFab, { ariaLabel: 'Create', variant: 'glass' }),
|
||||
}),
|
||||
)
|
||||
|
||||
expect(html).toContain('sky-fab--glass')
|
||||
|
||||
const controls = readFileSync(
|
||||
fileURLToPath(new URL('../controls.css', import.meta.url)),
|
||||
'utf8',
|
||||
)
|
||||
expect(controls).toMatch(
|
||||
/\.sky-glass\.sky-fab--glass\s*\{[^}]*border:\s*1px solid var\(--sky-hairline[^}]*background:\s*var\(--sky-glass[^}]*box-shadow:\s*var\(--sky-shadow-glass\)/s,
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps icon-only fabs perfectly square inside stretching toolbars', () => {
|
||||
const controls = readFileSync(
|
||||
fileURLToPath(new URL('../controls.css', import.meta.url)),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
expect(controls).toMatch(
|
||||
/\.sky-fab--icon-only\s*\{[^}]*width:\s*var\(--sky-touch-target, 44px\);[^}]*height:\s*var\(--sky-touch-target, 44px\);[^}]*flex:\s*none;[^}]*align-self:\s*center;/s,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,7 +14,7 @@ const props = withDefaults(
|
||||
text?: string
|
||||
textPosition?: 'after' | 'before'
|
||||
type?: 'button' | 'reset' | 'submit'
|
||||
variant?: 'glass' | 'neutral' | 'primary'
|
||||
variant?: 'neutral' | 'primary'
|
||||
}>(),
|
||||
{
|
||||
ariaLabel: '',
|
||||
@@ -72,7 +72,6 @@ function handleClick(event: MouseEvent): void {
|
||||
class="sky-fab"
|
||||
:class="{
|
||||
'sky-fab--disabled': disabled,
|
||||
'sky-fab--glass': variant === 'glass',
|
||||
'sky-fab--icon-only': !hasText,
|
||||
'sky-fab--neutral': variant === 'neutral',
|
||||
'sky-fab--with-text': hasText,
|
||||
|
||||
@@ -10,10 +10,6 @@ import {
|
||||
PREVIEWABLE_BUILTIN_APP_IDS,
|
||||
} from '@/utils/appStorePreviews'
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
|
||||
describe('App Store preview catalog', () => {
|
||||
it('contains a real captured screenshot for every built-in store app', () => {
|
||||
const storeAppIds = PHONE_APPS.filter(
|
||||
@@ -60,11 +56,10 @@ describe('App Store preview catalog', () => {
|
||||
]
|
||||
|
||||
for (const appId of PREVIEWABLE_BUILTIN_APP_IDS) {
|
||||
const escapedAppId = escapeRegExp(appId)
|
||||
for (const source of localeSources) {
|
||||
expect(source).toMatch(
|
||||
new RegExp(
|
||||
`(?:["']${escapedAppId}["']\\]?|${escapedAppId})\\s*[:=]\\s*\\{\\s*first\\s*[:=]`,
|
||||
`(?:["']${appId}["']\\]?|${appId.replace(/-/g, '\\-')})\\s*[:=]\\s*\\{\\s*first\\s*[:=]`,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,10 +5,7 @@ import {
|
||||
createLbPhoneFrameDocument,
|
||||
createLbPhoneHostSettings,
|
||||
getLbPhoneCallbackResource,
|
||||
getLbPhoneStorageKey,
|
||||
readLbPhoneStorage,
|
||||
usesLbPhoneHostRuntime,
|
||||
writeLbPhoneStorage,
|
||||
} from '@/utils/lbPhoneAppBridge'
|
||||
import { DEFAULT_PHONE_PREFERENCES } from '@/utils/preferences'
|
||||
|
||||
@@ -93,10 +90,9 @@ describe('LB Phone app bridge', () => {
|
||||
|
||||
it('injects the LB runtime and asset base before the vendor bundle', () => {
|
||||
const html =
|
||||
'<!doctype html><html><head><script>globalThis.previewMode = !window.invokeNative</script><script type="module" src="/ui/dist/assets/index.js"></script></head><body></body></html>'
|
||||
'<!doctype html><html><head><script type="module" src="/ui/dist/assets/index.js"></script></head><body></body></html>'
|
||||
const document = createLbPhoneFrameDocument(html, {
|
||||
appName: 'snake-game',
|
||||
localStorage: { theme: 'dark' },
|
||||
resourceName: 'snake_app',
|
||||
settings: createLbPhoneHostSettings({
|
||||
deviceName: '</script><script>window.injected=true</script>',
|
||||
@@ -113,52 +109,11 @@ describe('LB Phone app bridge', () => {
|
||||
)
|
||||
expect(document).toContain('globalThis.fetchNui = async')
|
||||
expect(document).toContain('globalThis.onNuiEvent = globalThis.useNuiEvent')
|
||||
expect(document).toContain('globalThis.createCall = globalThis.CreateCall')
|
||||
expect(document).toContain('globalThis.createSMS = globalThis.CreateSMS')
|
||||
expect(document).toContain('globalThis.invokeNative = () => undefined')
|
||||
expect(document).toContain(
|
||||
"Object.defineProperty(globalThis, 'localStorage'",
|
||||
)
|
||||
expect(document).toContain('"localStorage":{"theme":"dark"}')
|
||||
expect(document).toContain('https://cfx-nui-snake_app/ui/dist/')
|
||||
expect(document).not.toContain('</script><script>window.injected=true')
|
||||
expect(document.indexOf('globalThis.invokeNative')).toBeLessThan(
|
||||
document.indexOf('globalThis.previewMode'),
|
||||
)
|
||||
|
||||
const openingTag = '<script>'
|
||||
const runtimeStart = document.indexOf(openingTag)
|
||||
const runtimeEnd = document.indexOf(
|
||||
'</script>',
|
||||
runtimeStart + openingTag.length,
|
||||
)
|
||||
expect(runtimeStart).toBeGreaterThanOrEqual(0)
|
||||
expect(runtimeEnd).toBeGreaterThan(runtimeStart)
|
||||
|
||||
const runtime = document.slice(runtimeStart + openingTag.length, runtimeEnd)
|
||||
expect(() => new Function(runtime)).not.toThrow()
|
||||
})
|
||||
|
||||
it('persists isolated LB localStorage snapshots without app changes', () => {
|
||||
const values = new Map<string, string>()
|
||||
const storage = {
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => values.set(key, value),
|
||||
}
|
||||
|
||||
expect(
|
||||
writeLbPhoneStorage(storage, 'snake-game', {
|
||||
language: 'de',
|
||||
volume: '0.8',
|
||||
}),
|
||||
).toBe(true)
|
||||
expect(values.has(getLbPhoneStorageKey('snake-game'))).toBe(true)
|
||||
expect(readLbPhoneStorage(storage, 'snake-game')).toEqual({
|
||||
language: 'de',
|
||||
volume: '0.8',
|
||||
})
|
||||
expect(writeLbPhoneStorage(storage, 'snake-game', { invalid: 5 })).toBe(
|
||||
false,
|
||||
)
|
||||
const runtime = /<script>([\s\S]*?)<\/script>/.exec(document)?.[1]
|
||||
expect(runtime).toBeTruthy()
|
||||
expect(() => new Function(runtime ?? '')).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,15 +4,6 @@ import type { PhonePreferencesV1 } from '@/utils/preferences'
|
||||
const LB_PHONE_PROVIDER = 'lb_phone'
|
||||
const RESOURCE_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/
|
||||
const MAX_FRAME_DOCUMENT_BYTES = 1_048_576
|
||||
const MAX_STORAGE_BYTES = 65_536
|
||||
const MAX_STORAGE_ENTRIES = 128
|
||||
const MAX_STORAGE_KEY_LENGTH = 512
|
||||
const STORAGE_KEY_PREFIX = 'sky_phone:lb-app-storage:v1:'
|
||||
|
||||
export const LB_PHONE_STORAGE_MESSAGE_TYPE = 'sky-phone:lb-storage'
|
||||
export const LB_PHONE_ACTION_MESSAGE_TYPE = 'sky-phone:lb-action'
|
||||
|
||||
export type LbPhoneStorageSnapshot = Record<string, string>
|
||||
|
||||
export type LbPhoneHostSettings = {
|
||||
airplaneMode: boolean
|
||||
@@ -50,7 +41,6 @@ export type LbPhoneHostSettings = {
|
||||
|
||||
type LbPhoneFrameDocumentOptions = {
|
||||
appName: string
|
||||
localStorage: LbPhoneStorageSnapshot
|
||||
resourceName: string
|
||||
settings: LbPhoneHostSettings
|
||||
ui: string
|
||||
@@ -70,59 +60,6 @@ const settingsListeners = new Set();
|
||||
const resourcePattern = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/;
|
||||
const eventPattern = /^[A-Za-z0-9][A-Za-z0-9:._/-]{0,127}$/;
|
||||
|
||||
function createStorage(initialValues, onChange) {
|
||||
const values = new Map(Object.entries(initialValues ?? {}));
|
||||
const snapshot = () => Object.fromEntries(values);
|
||||
const storage = {
|
||||
clear() {
|
||||
if (values.size === 0) return;
|
||||
values.clear();
|
||||
onChange(snapshot());
|
||||
},
|
||||
getItem(key) {
|
||||
const normalizedKey = String(key);
|
||||
return values.has(normalizedKey) ? values.get(normalizedKey) : null;
|
||||
},
|
||||
key(index) {
|
||||
const normalizedIndex = Number(index);
|
||||
if (!Number.isInteger(normalizedIndex) || normalizedIndex < 0) return null;
|
||||
return Array.from(values.keys())[normalizedIndex] ?? null;
|
||||
},
|
||||
removeItem(key) {
|
||||
if (!values.delete(String(key))) return;
|
||||
onChange(snapshot());
|
||||
},
|
||||
setItem(key, value) {
|
||||
values.set(String(key), String(value));
|
||||
onChange(snapshot());
|
||||
}
|
||||
};
|
||||
Object.defineProperty(storage, 'length', {
|
||||
enumerable: true,
|
||||
get: () => values.size
|
||||
});
|
||||
return storage;
|
||||
}
|
||||
|
||||
const localStorageBridge = createStorage(config.localStorage, (storage) => {
|
||||
globalThis.parent.postMessage({
|
||||
appId: config.appName,
|
||||
protocolVersion: 1,
|
||||
storage,
|
||||
type: '${LB_PHONE_STORAGE_MESSAGE_TYPE}'
|
||||
}, '*');
|
||||
});
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
value: localStorageBridge
|
||||
});
|
||||
Object.defineProperty(globalThis, 'sessionStorage', {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
value: createStorage({}, () => undefined)
|
||||
});
|
||||
|
||||
function applySettings(nextSettings) {
|
||||
globalThis.settings = nextSettings;
|
||||
const theme = nextSettings?.display?.theme === 'dark' ? 'dark' : 'light';
|
||||
@@ -133,22 +70,7 @@ function applySettings(nextSettings) {
|
||||
globalThis.resourceName = config.resourceName;
|
||||
globalThis.appName = config.appName;
|
||||
globalThis.components = globalThis.components ?? {};
|
||||
// Official LB app templates use this binding to distinguish live NUI from browser preview mode.
|
||||
if (typeof globalThis.invokeNative !== 'function') {
|
||||
globalThis.invokeNative = () => undefined;
|
||||
}
|
||||
globalThis.GetParentResourceName = () => config.resourceName;
|
||||
function requestPhoneAction(action, options) {
|
||||
globalThis.parent.postMessage({
|
||||
action,
|
||||
appId: config.appName,
|
||||
options,
|
||||
protocolVersion: 1,
|
||||
type: '${LB_PHONE_ACTION_MESSAGE_TYPE}'
|
||||
}, '*');
|
||||
}
|
||||
globalThis.createCall = globalThis.CreateCall = (options) => requestPhoneAction('createCall', options);
|
||||
globalThis.createSMS = globalThis.CreateSMS = (options) => requestPhoneAction('createSMS', options);
|
||||
globalThis.fetchNui = async (eventName, data, requestedResource) => {
|
||||
if (typeof eventName !== 'string' || !eventPattern.test(eventName) || eventName.includes('..')) {
|
||||
throw new TypeError('Invalid NUI callback name');
|
||||
@@ -228,67 +150,6 @@ function serializeForInlineScript(value: unknown): string {
|
||||
.replace(/\u2029/g, '\\u2029')
|
||||
}
|
||||
|
||||
function normalizeStorageSnapshot(
|
||||
value: unknown,
|
||||
): LbPhoneStorageSnapshot | null {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const entries = Object.entries(value)
|
||||
if (entries.length > MAX_STORAGE_ENTRIES) return null
|
||||
|
||||
const normalized: LbPhoneStorageSnapshot = {}
|
||||
for (const [key, item] of entries) {
|
||||
if (
|
||||
key.length > MAX_STORAGE_KEY_LENGTH ||
|
||||
typeof item !== 'string' ||
|
||||
key === '__proto__' ||
|
||||
key === 'constructor' ||
|
||||
key === 'prototype'
|
||||
) {
|
||||
return null
|
||||
}
|
||||
normalized[key] = item
|
||||
}
|
||||
|
||||
return new TextEncoder().encode(JSON.stringify(normalized)).byteLength <=
|
||||
MAX_STORAGE_BYTES
|
||||
? normalized
|
||||
: null
|
||||
}
|
||||
|
||||
export function getLbPhoneStorageKey(appName: string): string {
|
||||
if (!RESOURCE_NAME_PATTERN.test(appName)) {
|
||||
throw new Error('invalid_lb_phone_storage_app')
|
||||
}
|
||||
return `${STORAGE_KEY_PREFIX}${appName}`
|
||||
}
|
||||
|
||||
export function readLbPhoneStorage(
|
||||
storage: Pick<Storage, 'getItem'>,
|
||||
appName: string,
|
||||
): LbPhoneStorageSnapshot {
|
||||
const serialized = storage.getItem(getLbPhoneStorageKey(appName))
|
||||
if (serialized === null) return {}
|
||||
|
||||
const normalized = normalizeStorageSnapshot(JSON.parse(serialized))
|
||||
if (!normalized) throw new Error('invalid_lb_phone_storage')
|
||||
return normalized
|
||||
}
|
||||
|
||||
export function writeLbPhoneStorage(
|
||||
storage: Pick<Storage, 'setItem'>,
|
||||
appName: string,
|
||||
value: unknown,
|
||||
): boolean {
|
||||
const normalized = normalizeStorageSnapshot(value)
|
||||
if (!normalized) return false
|
||||
|
||||
storage.setItem(getLbPhoneStorageKey(appName), JSON.stringify(normalized))
|
||||
return true
|
||||
}
|
||||
|
||||
export function usesLbPhoneHostRuntime(
|
||||
app: ExternalPhoneAppDefinition,
|
||||
): boolean {
|
||||
@@ -367,7 +228,6 @@ export function createLbPhoneFrameDocument(
|
||||
const baseUrl = new URL('.', options.ui).href
|
||||
const config = serializeForInlineScript({
|
||||
appName: options.appName,
|
||||
localStorage: options.localStorage,
|
||||
resourceName: options.resourceName,
|
||||
settings: options.settings,
|
||||
})
|
||||
|
||||
@@ -47,12 +47,4 @@ describe('notes rich text', () => {
|
||||
'Briefing\nMeet outside.\n• Radio\n• Vest',
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps encoded markup as literal preview text', () => {
|
||||
const body = serializeRichNoteBody(
|
||||
'<p><script>literal</script></p>',
|
||||
)
|
||||
|
||||
expect(noteBodyToPlainText(body)).toBe('<script>literal</script>')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -48,11 +48,13 @@ describe('AppStoreDetail contract', () => {
|
||||
expect(previewSource).toContain('v-if="previewImage && screen < 3"')
|
||||
expect(previewSource).toContain(':src="previewImage"')
|
||||
expect(previewSource).toContain('store-detail-preview__screenshot')
|
||||
expect(previewSource).not.toContain('visual.scene ===')
|
||||
expect(previewSource).not.toContain("visual.scene ===")
|
||||
expect(source).toContain('getAppStorePreviewVisual')
|
||||
expect(source).toContain('Apps.appStore.previews.${props.app.id}')
|
||||
expect(previewSource).toContain(':src="iconImage"')
|
||||
expect(source).toMatch(/previewScreens\s*=\s*\[0, 1, 2, 3, 4\] as const/)
|
||||
expect(source).toMatch(
|
||||
/previewScreens\s*=\s*\[0, 1, 2, 3, 4\] as const/,
|
||||
)
|
||||
expect(previewSource).toContain('screen === 3')
|
||||
expect(previewSource).toContain('screen === 4')
|
||||
expect(previewSource).toContain('store-detail-preview__details')
|
||||
@@ -69,16 +71,6 @@ describe('AppStoreDetail contract', () => {
|
||||
expect(source).toContain('scrollToPreview(activePreviewIndex + 1)')
|
||||
expect(source).toContain('details.previousPreview')
|
||||
expect(source).toContain('details.nextPreview')
|
||||
expect(source).toContain('.store-detail__toolbar-button:hover')
|
||||
})
|
||||
|
||||
it('uses shared liquid glass for toolbar and preview controls', () => {
|
||||
expect(source).toContain("import { SkyButton } from '@/ui'")
|
||||
expect(source.match(/<SkyButton\s+glass/g)).toHaveLength(4)
|
||||
expect(source).toContain('class="store-detail__toolbar-button"')
|
||||
expect(source).toContain('class="store-detail__preview-control"')
|
||||
expect(source).toMatch(
|
||||
/\.store-detail__preview-control\s*\{[^}]*width:\s*var\(--sky-touch-target\);[^}]*height:\s*var\(--sky-touch-target\);[^}]*border-radius:\s*50%;/s,
|
||||
)
|
||||
expect(source).toContain('.store-detail__toolbar button:hover')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { ChevronLeft, ChevronRight, Share2, Star } from 'lucide-vue-next'
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Share2,
|
||||
Star,
|
||||
} from 'lucide-vue-next'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { getPhoneAppLabel, isExternalPhoneApp } from '@/config/apps'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import type { LaunchablePhoneAppDefinition } from '@/types/apps'
|
||||
import { SkyButton } from '@/ui'
|
||||
import { getAppStorePreviewImage } from '@/utils/appStorePreviewImages'
|
||||
import { getAppStorePreviewVisual } from '@/utils/appStorePreviews'
|
||||
|
||||
@@ -122,28 +126,20 @@ function updateActivePreview(): void {
|
||||
<template>
|
||||
<section class="store-detail" :style="detailStyle">
|
||||
<header class="store-detail__toolbar">
|
||||
<SkyButton
|
||||
glass
|
||||
icon-only
|
||||
rounded
|
||||
class="store-detail__toolbar-button"
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="phone.t('Common.back')"
|
||||
@click="emit('back')"
|
||||
>
|
||||
<ChevronLeft :size="26" :stroke-width="2.2" aria-hidden="true" />
|
||||
</SkyButton>
|
||||
<SkyButton
|
||||
glass
|
||||
icon-only
|
||||
rounded
|
||||
class="store-detail__toolbar-button"
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="phone.t('Apps.appStore.details.share')"
|
||||
@click="emit('share')"
|
||||
>
|
||||
<Share2 :size="21" :stroke-width="2" aria-hidden="true" />
|
||||
</SkyButton>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<section class="store-detail__hero">
|
||||
@@ -216,30 +212,22 @@ function updateActivePreview(): void {
|
||||
<h2>{{ phone.t('Apps.appStore.details.preview') }}</h2>
|
||||
<div class="store-detail__preview-navigation">
|
||||
<span>{{ activePreviewIndex + 1 }} / {{ previewCount }}</span>
|
||||
<SkyButton
|
||||
glass
|
||||
icon-only
|
||||
rounded
|
||||
class="store-detail__preview-control"
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="phone.t('Apps.appStore.details.previousPreview')"
|
||||
:disabled="activePreviewIndex === 0"
|
||||
@click="scrollToPreview(activePreviewIndex - 1)"
|
||||
>
|
||||
<ChevronLeft :size="17" :stroke-width="2.4" aria-hidden="true" />
|
||||
</SkyButton>
|
||||
<SkyButton
|
||||
glass
|
||||
icon-only
|
||||
rounded
|
||||
class="store-detail__preview-control"
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="phone.t('Apps.appStore.details.nextPreview')"
|
||||
:disabled="activePreviewIndex === previewCount - 1"
|
||||
@click="scrollToPreview(activePreviewIndex + 1)"
|
||||
>
|
||||
<ChevronRight :size="17" :stroke-width="2.4" aria-hidden="true" />
|
||||
</SkyButton>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div
|
||||
@@ -298,7 +286,7 @@ function updateActivePreview(): void {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.store-detail__toolbar-button {
|
||||
.store-detail__toolbar button {
|
||||
width: var(--sky-touch-target);
|
||||
height: var(--sky-touch-target);
|
||||
display: grid;
|
||||
@@ -306,6 +294,7 @@ function updateActivePreview(): void {
|
||||
border: 1px solid var(--sky-hairline);
|
||||
border-radius: 50%;
|
||||
color: var(--sky-text);
|
||||
background: var(--sky-surface-variant);
|
||||
transition:
|
||||
background-color 100ms ease,
|
||||
border-color 100ms ease,
|
||||
@@ -313,7 +302,7 @@ function updateActivePreview(): void {
|
||||
transform 100ms ease;
|
||||
}
|
||||
|
||||
.store-detail__toolbar-button:active {
|
||||
.store-detail__toolbar button:active {
|
||||
transform: scale(0.94);
|
||||
}
|
||||
|
||||
@@ -492,28 +481,27 @@ function updateActivePreview(): void {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.store-detail__preview-control {
|
||||
width: var(--sky-touch-target);
|
||||
height: var(--sky-touch-target);
|
||||
min-width: var(--sky-touch-target);
|
||||
min-height: var(--sky-touch-target);
|
||||
.store-detail__preview-navigation button {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid var(--sky-hairline);
|
||||
border-radius: 50%;
|
||||
padding: 0;
|
||||
color: var(--sky-text);
|
||||
background: var(--sky-surface-variant);
|
||||
transition:
|
||||
background-color 100ms ease,
|
||||
color 100ms ease,
|
||||
transform 100ms ease;
|
||||
}
|
||||
|
||||
.store-detail__preview-control:disabled {
|
||||
.store-detail__preview-navigation button:disabled {
|
||||
opacity: 0.34;
|
||||
}
|
||||
|
||||
.store-detail__preview-control:active:not(:disabled) {
|
||||
.store-detail__preview-navigation button:active:not(:disabled) {
|
||||
transform: scale(0.92);
|
||||
}
|
||||
|
||||
@@ -532,14 +520,16 @@ function updateActivePreview(): void {
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.store-detail__toolbar-button:hover {
|
||||
.store-detail__toolbar button:hover {
|
||||
border-color: rgba(255, 255, 255, 0.16);
|
||||
background: var(--sky-surface-tint);
|
||||
box-shadow: 0 7px 16px rgba(0, 0, 0, 0.18);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.store-detail__preview-control:hover:not(:disabled) {
|
||||
.store-detail__preview-navigation button:hover:not(:disabled) {
|
||||
color: var(--sky-app-accent);
|
||||
background: var(--sky-surface-tint);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ const appSource = readFileSync(
|
||||
'utf8',
|
||||
)
|
||||
const clientSource = readFileSync(
|
||||
new URL('../../../../sky_phone/source/client/nui_events.lua', import.meta.url),
|
||||
new URL('../../../../sky_phone/source/client/main.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const serverSource = readFileSync(
|
||||
@@ -69,10 +69,7 @@ describe('Banking app Sky UI migration', () => {
|
||||
expect(serverSource).toContain('kind = "transfer_in"')
|
||||
expect(serverSource).toContain('currency = Config.Banking.Currency')
|
||||
expect(clientSource).toContain(
|
||||
'["sky_phone:banking:changed"] = "banking:changed"',
|
||||
)
|
||||
expect(clientSource).toContain(
|
||||
'SendNUIMessage({ type = nui_type, data = data })',
|
||||
'SendNUIMessage({ type = "banking:changed", data = data })',
|
||||
)
|
||||
expect(appSource).toContain("data?.kind === 'transfer_in'")
|
||||
expect(appSource).toContain("appId: 'banking'")
|
||||
|
||||
@@ -52,8 +52,7 @@ describe('CalculatorApp layout contract', () => {
|
||||
)
|
||||
expect(source).toContain('calculator-history__nav-button--edit')
|
||||
expect(source).toContain('calculator-history__nav-button--close')
|
||||
expect(source.match(/<SkyButton\s+glass/g)).toHaveLength(2)
|
||||
expect(source).toContain('--sky-glass: rgb(44 44 46 / 62%);')
|
||||
expect(source).toContain('background: rgb(255 255 255 / 9%);')
|
||||
expect(source).toContain('-webkit-backdrop-filter: none;')
|
||||
expect(source).toContain('backdrop-filter: none;')
|
||||
expect(source).toContain('min-width: 106px;')
|
||||
@@ -63,7 +62,7 @@ describe('CalculatorApp layout contract', () => {
|
||||
expect(source).toContain('place-items: center;')
|
||||
expect(source).toContain('padding: 0;')
|
||||
expect(source).toMatch(
|
||||
/\.calculator-history__nav-button:hover:not\(:disabled\)[^}]*transform: none;[^}]*filter: brightness\(1\.08\);/s,
|
||||
/\.calculator-history__nav-button:hover:not\(:disabled\)[^}]*background: rgb\(255 255 255 \/ 15%\);[^}]*transform: none;[^}]*filter: none;/s,
|
||||
)
|
||||
expect(source).not.toContain('class="calculator-history__edit"')
|
||||
expect(source).not.toContain('class="calculator-history__close"')
|
||||
|
||||
@@ -378,7 +378,6 @@ watch([() => calculator.display, expression], async () => {
|
||||
>
|
||||
<template #left>
|
||||
<SkyButton
|
||||
glass
|
||||
class="calculator-history__nav-button calculator-history__nav-button--edit"
|
||||
inline
|
||||
rounded
|
||||
@@ -398,7 +397,6 @@ watch([() => calculator.display, expression], async () => {
|
||||
</template>
|
||||
<template #right>
|
||||
<SkyButton
|
||||
glass
|
||||
class="calculator-history__nav-button calculator-history__nav-button--close"
|
||||
icon-only
|
||||
rounded
|
||||
@@ -723,15 +721,16 @@ watch([() => calculator.display, expression], async () => {
|
||||
}
|
||||
|
||||
.calculator-history__navbar :deep(.calculator-history__nav-button) {
|
||||
--sky-glass: rgb(44 44 46 / 62%);
|
||||
--sky-hairline: rgb(255 255 255 / 14%);
|
||||
height: 44px;
|
||||
min-height: 44px;
|
||||
background: rgb(255 255 255 / 9%);
|
||||
color: #fff;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.calculator-history__navbar :deep(.calculator-history__nav-button:active) {
|
||||
filter: brightness(0.94);
|
||||
background: rgb(255 255 255 / 14%);
|
||||
filter: none;
|
||||
}
|
||||
|
||||
.calculator-history__navbar :deep(.calculator-history__nav-button--edit) {
|
||||
@@ -758,8 +757,9 @@ watch([() => calculator.display, expression], async () => {
|
||||
@media (hover: hover) {
|
||||
.calculator-history__navbar
|
||||
:deep(.calculator-history__nav-button:hover:not(:disabled)) {
|
||||
background: rgb(255 255 255 / 15%);
|
||||
transform: none;
|
||||
filter: brightness(1.08);
|
||||
filter: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,14 +31,6 @@ const cameraAnimations = readFileSync(
|
||||
)
|
||||
|
||||
describe('Camera app controls', () => {
|
||||
it('uses shared liquid glass for camera interaction buttons', () => {
|
||||
expect(cameraView.match(/variant="glass"/g)).toHaveLength(5)
|
||||
expect(cameraView).toMatch(
|
||||
/<sky-glass\s+component="button"\s+class="camera-latest"/,
|
||||
)
|
||||
expect(cameraView).not.toContain('variant="neutral"')
|
||||
})
|
||||
|
||||
it('uses the Sky UI moving segment for photo and video modes', () => {
|
||||
expect(cameraView).toContain('SkySegmented')
|
||||
expect(cameraView).toContain(':active-index="mode === \'photo\' ? 0 : 1"')
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { SkyAppPage, SkyButton, SkyFab, SkyGlass } from '@/ui'
|
||||
import { SkyFab, SkyAppPage } from '@/ui'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Images,
|
||||
@@ -421,11 +421,13 @@ onMounted(() => {
|
||||
window.addEventListener('keyup', onKeyup)
|
||||
window.addEventListener('message', onMessage)
|
||||
void nuiCall('camera:setActive', { active: true })
|
||||
void nuiCall<MediaConfig>('media:config').then((response) => {
|
||||
if (response.success && response.data?.videoBitrateKbps) {
|
||||
videoBitrateKbps.value = response.data.videoBitrateKbps
|
||||
}
|
||||
})
|
||||
void nuiCall<MediaConfig>('media:config').then(
|
||||
(response) => {
|
||||
if (response.success && response.data?.videoBitrateKbps) {
|
||||
videoBitrateKbps.value = response.data.videoBitrateKbps
|
||||
}
|
||||
},
|
||||
)
|
||||
void loadLatest()
|
||||
startGameView()
|
||||
})
|
||||
@@ -484,24 +486,22 @@ onBeforeUnmount(() => {
|
||||
|
||||
<header class="camera-topbar">
|
||||
<div class="camera-topbar-actions">
|
||||
<sky-fab
|
||||
<button
|
||||
v-if="requestedMessageMedia"
|
||||
component="button"
|
||||
class="camera-control camera-picker-back"
|
||||
class="camera-picker-back"
|
||||
type="button"
|
||||
variant="glass"
|
||||
:aria-label="phone.t('Common.back')"
|
||||
@click="cancelMediaSelection"
|
||||
>
|
||||
<template #icon><ArrowLeft :size="20" /></template>
|
||||
</sky-fab>
|
||||
<ArrowLeft :size="20" />
|
||||
</button>
|
||||
<sky-fab
|
||||
v-else
|
||||
component="button"
|
||||
type="button"
|
||||
class="camera-control"
|
||||
:class="{ 'camera-control--flash-active': flashEnabled }"
|
||||
variant="glass"
|
||||
variant="neutral"
|
||||
:aria-label="phone.t('Apps.camera.flash')"
|
||||
@click="toggleFlash"
|
||||
>
|
||||
@@ -516,7 +516,7 @@ onBeforeUnmount(() => {
|
||||
type="button"
|
||||
class="camera-control"
|
||||
:class="{ 'camera-control--danger': !microphoneEnabled }"
|
||||
variant="glass"
|
||||
variant="neutral"
|
||||
:disabled="recording || savingVideo"
|
||||
:aria-label="
|
||||
phone.t(
|
||||
@@ -543,10 +543,8 @@ onBeforeUnmount(() => {
|
||||
<span v-else-if="pendingCount" class="camera-upload-pill">
|
||||
{{ phone.t('Apps.camera.uploading', { count: String(pendingCount) }) }}
|
||||
</span>
|
||||
<SkyButton
|
||||
<button
|
||||
v-else
|
||||
glass
|
||||
rounded
|
||||
class="camera-focus-pill camera-lock-control"
|
||||
:class="{ 'camera-lock-control--active': cameraLocked }"
|
||||
type="button"
|
||||
@@ -563,12 +561,12 @@ onBeforeUnmount(() => {
|
||||
<LockKeyhole v-if="cameraLocked" :size="12" />
|
||||
<LockOpen v-else :size="12" />
|
||||
<kbd>{{ phone.t('Apps.camera.spaceKey') }}</kbd>
|
||||
</SkyButton>
|
||||
</button>
|
||||
<sky-fab
|
||||
component="button"
|
||||
type="button"
|
||||
class="camera-control"
|
||||
variant="glass"
|
||||
variant="neutral"
|
||||
:disabled="recording || savingVideo"
|
||||
:aria-label="
|
||||
phone.t(
|
||||
@@ -590,11 +588,9 @@ onBeforeUnmount(() => {
|
||||
|
||||
<div class="camera-zoom-control">
|
||||
<div class="camera-zoom-row">
|
||||
<SkyButton
|
||||
<button
|
||||
v-for="zoom in zoomLevels"
|
||||
:key="zoom"
|
||||
glass
|
||||
rounded
|
||||
class="camera-zoom-pill"
|
||||
:class="{ active: Math.abs(selectedZoom - zoom) < 0.03 }"
|
||||
type="button"
|
||||
@@ -603,14 +599,13 @@ onBeforeUnmount(() => {
|
||||
@click="setZoom(zoom)"
|
||||
>
|
||||
{{ zoom }}x
|
||||
</SkyButton>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="camera-controls">
|
||||
<div class="camera-capture-row">
|
||||
<sky-glass
|
||||
component="button"
|
||||
<button
|
||||
class="camera-latest"
|
||||
type="button"
|
||||
:aria-label="phone.t('Apps.camera.openGallery')"
|
||||
@@ -630,7 +625,7 @@ onBeforeUnmount(() => {
|
||||
/>
|
||||
<Video v-else-if="latestMedia" :size="22" />
|
||||
<Images v-else :size="22" />
|
||||
</sky-glass>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="camera-shutter"
|
||||
@@ -655,7 +650,7 @@ onBeforeUnmount(() => {
|
||||
component="button"
|
||||
type="button"
|
||||
class="camera-control camera-selfie"
|
||||
variant="glass"
|
||||
variant="neutral"
|
||||
:aria-label="phone.t('Apps.camera.flip')"
|
||||
@click="toggleFacing"
|
||||
>
|
||||
@@ -804,9 +799,8 @@ onBeforeUnmount(() => {
|
||||
height: 44px;
|
||||
}
|
||||
.camera-control {
|
||||
--sky-glass: rgb(28 28 30 / 58%);
|
||||
--sky-hairline: rgb(255 255 255 / 16%);
|
||||
color: rgb(255 255 255 / 92%) !important;
|
||||
--sky-glass-solid: rgb(28 28 30 / 80%);
|
||||
color: rgb(255 255 255 / 86%);
|
||||
}
|
||||
.camera-control--flash-active {
|
||||
color: #ffd60a !important;
|
||||
@@ -817,6 +811,13 @@ onBeforeUnmount(() => {
|
||||
.camera-picker-back {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: #1c1c1ecc;
|
||||
color: #fff;
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
.camera-control svg {
|
||||
width: 21px;
|
||||
@@ -830,7 +831,7 @@ onBeforeUnmount(() => {
|
||||
.camera-page--landscape .camera-latest svg {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
.camera-focus-pill:not(.sky-button--glass),
|
||||
.camera-focus-pill,
|
||||
.camera-upload-pill {
|
||||
min-width: 0;
|
||||
padding: 7px 10px;
|
||||
@@ -852,7 +853,7 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
.camera-lock-control {
|
||||
min-height: 44px;
|
||||
padding: 7px 10px;
|
||||
border: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -901,20 +902,27 @@ onBeforeUnmount(() => {
|
||||
z-index: 4;
|
||||
bottom: 196px;
|
||||
left: 50%;
|
||||
width: auto;
|
||||
width: 140px;
|
||||
padding: 4px 6px;
|
||||
border-radius: 999px;
|
||||
background: rgb(18 18 20 / 72%);
|
||||
box-shadow: 0 8px 24px rgb(0 0 0 / 24%);
|
||||
backdrop-filter: blur(18px);
|
||||
-webkit-backdrop-filter: blur(18px);
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
.camera-zoom-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
gap: 6px;
|
||||
}
|
||||
.camera-zoom-pill {
|
||||
width: 44px;
|
||||
min-width: 44px;
|
||||
height: 44px;
|
||||
min-height: 44px;
|
||||
width: 30px;
|
||||
height: 26px;
|
||||
padding: 0;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
text-align: center;
|
||||
@@ -925,6 +933,9 @@ onBeforeUnmount(() => {
|
||||
box-shadow 0.2s ease;
|
||||
}
|
||||
.camera-zoom-pill.active {
|
||||
border-color: transparent;
|
||||
background: rgb(44 44 46 / 88%);
|
||||
box-shadow: 0 8px 16px rgb(0 0 0 / 30%);
|
||||
color: #ffd60a;
|
||||
}
|
||||
.camera-controls {
|
||||
@@ -945,17 +956,15 @@ onBeforeUnmount(() => {
|
||||
padding: 0 24px 32px;
|
||||
}
|
||||
.camera-latest {
|
||||
--sky-glass: rgb(28 28 30 / 58%);
|
||||
--sky-hairline: rgb(255 255 255 / 16%);
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
overflow: hidden;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: var(--sky-glass);
|
||||
background: #111b;
|
||||
color: #fff;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
box-shadow: var(--sky-shadow-glass);
|
||||
}
|
||||
.camera-selfie {
|
||||
justify-self: end;
|
||||
|
||||
@@ -26,12 +26,6 @@ describe('FeatherApp Sky UI contract', () => {
|
||||
expect(source).toContain('with-tabbar')
|
||||
})
|
||||
|
||||
it('uses shared liquid glass for the floating compose action', () => {
|
||||
expect(source).toMatch(
|
||||
/<SkyFab[\s\S]*?class="feather-compose-fab"[\s\S]*?variant="glass"/,
|
||||
)
|
||||
})
|
||||
|
||||
it('gives likes and bookmarks a reduced-motion-safe pulse animation', () => {
|
||||
expect(postCard).toContain(
|
||||
"const reactionPulse = ref<'like' | 'bookmark' | null>(null)",
|
||||
@@ -149,9 +143,7 @@ describe('FeatherApp Sky UI contract', () => {
|
||||
})
|
||||
|
||||
it('keeps profile suggestion content styles off the follow button', () => {
|
||||
expect(source).toContain(
|
||||
'class="feather-profile-suggestion__profile"',
|
||||
)
|
||||
expect(source).toContain('class="feather-profile-suggestion__profile"')
|
||||
expect(source).toMatch(
|
||||
/\.feather-app\.feather-app--active \.feather-profile-suggestion__profile\s*\{[^}]*width:\s*100%/s,
|
||||
)
|
||||
@@ -201,7 +193,9 @@ describe('FeatherApp Sky UI contract', () => {
|
||||
expect(source).toContain('<SkyScrollRail')
|
||||
expect(source).toContain('class="feather-profile-suggestions__rail"')
|
||||
expect(source).toContain(':label="t(\'people\')"')
|
||||
expect(source).toContain('class="feather-profile-suggestion__profile"')
|
||||
expect(source).toContain(
|
||||
'class="feather-profile-suggestion__profile"',
|
||||
)
|
||||
expect(source).toMatch(
|
||||
/\.feather-app\.feather-app--active \.feather-profile-suggestion__profile\s*\{[^}]*width:\s*100%/s,
|
||||
)
|
||||
|
||||
@@ -2174,7 +2174,6 @@ onMounted(async () => {
|
||||
component="button"
|
||||
type="button"
|
||||
class="feather-compose-fab"
|
||||
variant="glass"
|
||||
:aria-label="t('newPost')"
|
||||
@click="openComposer()"
|
||||
>
|
||||
@@ -2332,8 +2331,7 @@ onMounted(async () => {
|
||||
/>
|
||||
<template v-if="mediaPreview.items.length > 1">
|
||||
<SkyButton
|
||||
glass
|
||||
icon-only
|
||||
clear
|
||||
rounded
|
||||
class="feather-media-preview__arrow feather-media-preview__arrow--left"
|
||||
:aria-label="t('previousImage')"
|
||||
@@ -2342,8 +2340,7 @@ onMounted(async () => {
|
||||
<ChevronLeft :size="20" :stroke-width="2.8" />
|
||||
</SkyButton>
|
||||
<SkyButton
|
||||
glass
|
||||
icon-only
|
||||
clear
|
||||
rounded
|
||||
class="feather-media-preview__arrow feather-media-preview__arrow--right"
|
||||
:aria-label="t('nextImage')"
|
||||
@@ -2392,15 +2389,22 @@ onMounted(async () => {
|
||||
cursor: default;
|
||||
}
|
||||
.feather-media-preview__arrow {
|
||||
--sky-app-accent: rgb(10 14 20 / 88%);
|
||||
--sky-button-text: #fff;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 44px !important;
|
||||
min-width: 44px;
|
||||
height: 44px;
|
||||
min-height: 44px;
|
||||
width: 34px !important;
|
||||
min-width: 34px;
|
||||
height: 34px;
|
||||
min-height: 34px;
|
||||
border: 1.5px solid rgb(255 255 255 / 58%);
|
||||
padding: 0;
|
||||
color: #fff !important;
|
||||
background: rgb(10 14 20 / 88%) !important;
|
||||
box-shadow:
|
||||
0 5px 16px rgb(0 0 0 / 58%),
|
||||
inset 0 0 0 1px rgb(255 255 255 / 8%);
|
||||
backdrop-filter: blur(10px);
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
.feather-media-preview__arrow--left {
|
||||
@@ -4482,6 +4486,7 @@ onMounted(async () => {
|
||||
place-items: center;
|
||||
}
|
||||
.feather-compose-fab {
|
||||
--sky-app-accent: #58a6ff;
|
||||
position: absolute;
|
||||
z-index: 12;
|
||||
right: 14px;
|
||||
@@ -4489,14 +4494,23 @@ onMounted(async () => {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
min-width: 46px;
|
||||
color: var(--feather-blue);
|
||||
border: 1px solid color-mix(in srgb, var(--feather-blue) 55%, #fff);
|
||||
color: #fff;
|
||||
box-shadow:
|
||||
0 9px 24px rgb(29 155 240 / 38%),
|
||||
0 3px 8px rgb(0 0 0 / 22%),
|
||||
inset 0 1px 0 rgb(255 255 255 / 32%);
|
||||
transition:
|
||||
transform 150ms ease,
|
||||
box-shadow 150ms ease,
|
||||
filter 150ms ease;
|
||||
}
|
||||
.feather-compose-fab:active {
|
||||
filter: brightness(0.94);
|
||||
transform: scale(0.94);
|
||||
box-shadow:
|
||||
0 4px 12px rgb(29 155 240 / 28%),
|
||||
inset 0 1px 0 rgb(255 255 255 / 22%);
|
||||
}
|
||||
.feather-navigation__badge-anchor b {
|
||||
position: absolute;
|
||||
@@ -5401,6 +5415,7 @@ onMounted(async () => {
|
||||
.feather-edit__photo-actions :deep(.sky-button) {
|
||||
border-color: var(--feather-blue);
|
||||
}
|
||||
.feather-compose-fab,
|
||||
.feather-edit__avatar {
|
||||
border-color: #70c5fa;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { describe, expect, it } from 'vitest'
|
||||
|
||||
const source = readFileSync(new URL('./FlareApp.vue', import.meta.url), 'utf8')
|
||||
const clientSource = readFileSync(
|
||||
new URL('../../../../sky_phone/source/client/nui_server_bridge.lua', import.meta.url),
|
||||
new URL('../../../../sky_phone/source/client/main.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const serverSource = readFileSync(
|
||||
@@ -210,7 +210,7 @@ describe('FlareApp profile editing contract', () => {
|
||||
})
|
||||
|
||||
it('deletes all account-owned Flare data in one server transaction', () => {
|
||||
expect(clientSource).toMatch(/flare\s*=\s*\[\[[^\]]*delete-profile/)
|
||||
expect(clientSource).toContain('"flare:delete-profile"')
|
||||
expect(serverSource).toContain(
|
||||
'Bridge.Callbacks.Register("sky_phone:flare:delete-profile"',
|
||||
)
|
||||
|
||||
@@ -2037,17 +2037,14 @@ onBeforeUnmount(() => {
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<SkyButton
|
||||
glass
|
||||
icon-only
|
||||
rounded
|
||||
<button
|
||||
type="button"
|
||||
class="flare-match-reveal__close"
|
||||
:aria-label="phone.t('Common.close')"
|
||||
@click="matchReveal = null"
|
||||
>
|
||||
<X />
|
||||
</SkyButton>
|
||||
</button>
|
||||
<Flame class="flare-match-reveal__flame" fill="currentColor" />
|
||||
<h2>{{ phone.t('Apps.flare.itsAMatch') }}</h2>
|
||||
<p>
|
||||
@@ -3422,13 +3419,14 @@ onBeforeUnmount(() => {
|
||||
position: absolute;
|
||||
top: 24px;
|
||||
right: 18px;
|
||||
width: 44px;
|
||||
min-width: 44px;
|
||||
height: 44px;
|
||||
min-height: 44px;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
background: rgb(0 0 0 / 18%);
|
||||
}
|
||||
.flare-match-reveal__flame {
|
||||
width: 58px;
|
||||
|
||||
@@ -2408,10 +2408,7 @@ onBeforeUnmount(() => {
|
||||
alt=""
|
||||
/>
|
||||
<template v-if="selectedMediaItems.length > 1">
|
||||
<SkyButton
|
||||
glass
|
||||
icon-only
|
||||
rounded
|
||||
<button
|
||||
type="button"
|
||||
class="compose-photo-preview__arrow compose-photo-preview__arrow--previous"
|
||||
:disabled="composerPhotoIndex === 0"
|
||||
@@ -2419,11 +2416,8 @@ onBeforeUnmount(() => {
|
||||
@click="moveComposerPhoto(-1)"
|
||||
>
|
||||
<ChevronLeft />
|
||||
</SkyButton>
|
||||
<SkyButton
|
||||
glass
|
||||
icon-only
|
||||
rounded
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="compose-photo-preview__arrow compose-photo-preview__arrow--next"
|
||||
:disabled="composerPhotoIndex === selectedMediaItems.length - 1"
|
||||
@@ -2431,7 +2425,7 @@ onBeforeUnmount(() => {
|
||||
@click="moveComposerPhoto(1)"
|
||||
>
|
||||
<ChevronRight />
|
||||
</SkyButton>
|
||||
</button>
|
||||
<span class="compose-photo-preview__count">
|
||||
{{ composerPhotoIndex + 1 }} / {{ selectedMediaItems.length }}
|
||||
</span>
|
||||
@@ -5878,7 +5872,12 @@ onBeforeUnmount(() => {
|
||||
padding: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid rgb(255 255 255 / 22%);
|
||||
border-radius: 50%;
|
||||
background: rgb(14 14 16 / 58%);
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 14px rgb(0 0 0 / 24%);
|
||||
backdrop-filter: blur(10px);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
|
||||
@@ -57,21 +57,6 @@ describe('GalleryApp import action', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the shared app tab bar for gallery filters', () => {
|
||||
expect(source).toContain('<SkyTabBar')
|
||||
expect(source).toContain('class="gallery-filter-tabbar"')
|
||||
expect(source).toContain('icons')
|
||||
expect(source).toContain('<Images :size="21" />')
|
||||
expect(source).toContain('<Image :size="21" />')
|
||||
expect(source).toContain('<Video :size="21" />')
|
||||
expect(source).toContain('<SkyTabButton')
|
||||
expect(source).toContain(':active="filter === \'all\'"')
|
||||
expect(source).toContain(':active="filter === \'photo\'"')
|
||||
expect(source).toContain(':active="filter === \'video\'"')
|
||||
expect(source).not.toContain('gallery-filter-navbar')
|
||||
expect(source).not.toContain('<sky-segmented')
|
||||
})
|
||||
|
||||
it('opens an accessible sort menu from the large header', () => {
|
||||
expect(headerActions).toContain('<ListFilter')
|
||||
expect(headerActions).toContain('aria-haspopup="menu"')
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
SkyNavbarBackLink,
|
||||
SkyAppPage,
|
||||
SkySpinner,
|
||||
SkySegmented,
|
||||
SkySegmentedButton,
|
||||
SkyNotification,
|
||||
} from '@/ui'
|
||||
import {
|
||||
@@ -17,14 +19,11 @@ import {
|
||||
Download,
|
||||
Globe2,
|
||||
Heart,
|
||||
Image,
|
||||
Images,
|
||||
Link2,
|
||||
ListFilter,
|
||||
Play,
|
||||
Share2,
|
||||
Trash2,
|
||||
Video,
|
||||
} from 'lucide-vue-next'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
@@ -36,8 +35,6 @@ import {
|
||||
SkyButton,
|
||||
SkyDropdown,
|
||||
SkyNavbar,
|
||||
SkyTabBar,
|
||||
SkyTabButton,
|
||||
SkyToolbar,
|
||||
SkyToolbarPane,
|
||||
} from '@/ui'
|
||||
@@ -71,6 +68,11 @@ const developmentParameters = isDevelopment
|
||||
const developmentApiEnabled = Boolean(developmentParameters?.has('apiPort'))
|
||||
const developmentGalleryState =
|
||||
developmentParameters?.get('galleryMock') ?? null
|
||||
const filterItems = [
|
||||
{ id: 'all', label: 'all' },
|
||||
{ id: 'photo', label: 'photos' },
|
||||
{ id: 'video', label: 'videos' },
|
||||
] as const
|
||||
const phone = usePhoneStore()
|
||||
const easyShare = useEasyShareStore()
|
||||
const messageMedia = useMessageMediaStore()
|
||||
@@ -1376,35 +1378,28 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SkyTabBar
|
||||
<sky-navbar
|
||||
v-if="!requestedMessageMedia && !selectionMode"
|
||||
icons
|
||||
labels
|
||||
class="gallery-filter-tabbar"
|
||||
:label="phone.t('Apps.photos.name')"
|
||||
component="nav"
|
||||
class="gallery-filter-navbar"
|
||||
:aria-label="phone.t('Apps.photos.name')"
|
||||
>
|
||||
<SkyTabButton
|
||||
:active="filter === 'all'"
|
||||
:label="phone.t('Apps.photos.filters.all')"
|
||||
@click="filter = 'all'"
|
||||
>
|
||||
<template #icon><Images :size="21" /></template>
|
||||
</SkyTabButton>
|
||||
<SkyTabButton
|
||||
:active="filter === 'photo'"
|
||||
:label="phone.t('Apps.photos.filters.photos')"
|
||||
@click="filter = 'photo'"
|
||||
>
|
||||
<template #icon><Image :size="21" /></template>
|
||||
</SkyTabButton>
|
||||
<SkyTabButton
|
||||
:active="filter === 'video'"
|
||||
:label="phone.t('Apps.photos.filters.videos')"
|
||||
@click="filter = 'video'"
|
||||
>
|
||||
<template #icon><Video :size="21" /></template>
|
||||
</SkyTabButton>
|
||||
</SkyTabBar>
|
||||
<template #subnavbar>
|
||||
<sky-segmented strong rounded :data-active-filter="filter">
|
||||
<sky-segmented-button
|
||||
v-for="item in filterItems"
|
||||
:key="item.id"
|
||||
large
|
||||
:active="filter === item.id"
|
||||
:class="filter === item.id ? 'text-white' : 'text-[#8e8e93]'"
|
||||
:aria-pressed="filter === item.id"
|
||||
@click="filter = item.id"
|
||||
>
|
||||
{{ phone.t(`Apps.photos.filters.${item.label}`) }}
|
||||
</sky-segmented-button>
|
||||
</sky-segmented>
|
||||
</template>
|
||||
</sky-navbar>
|
||||
|
||||
<SkyToolbar
|
||||
v-if="selectionMode"
|
||||
@@ -1699,6 +1694,14 @@ onBeforeUnmount(() => {
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.gallery-filter-navbar {
|
||||
position: absolute !important;
|
||||
top: auto !important;
|
||||
bottom: 24px;
|
||||
}
|
||||
.gallery-filter-navbar :deep(> div:nth-child(-n + 2)) {
|
||||
display: none;
|
||||
}
|
||||
.gallery-grid {
|
||||
position: relative;
|
||||
display: grid;
|
||||
|
||||
@@ -19,7 +19,7 @@ const migrationSource = readFileSync(
|
||||
'utf8',
|
||||
)
|
||||
const clientSource = readFileSync(
|
||||
new URL('../../../../sky_phone/source/client/nui_server_bridge.lua', import.meta.url),
|
||||
new URL('../../../../sky_phone/source/client/main.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
@@ -112,7 +112,7 @@ describe('MailApp Sky UI contract', () => {
|
||||
expect(toolbar).toContain('{{ activeFilterSummary }}')
|
||||
expect(toolbar).toContain('<sky-searchbar')
|
||||
expect(toolbar).toContain('@update:model-value="updateSearch"')
|
||||
expect(toolbar).toContain('variant="glass"')
|
||||
expect(toolbar).toContain('variant="neutral"')
|
||||
expect(toolbar).toContain('@click="beginCompose()"')
|
||||
expect(source).not.toContain('class="mail-search"')
|
||||
expect(source).not.toContain('class="mail-compose-fab"')
|
||||
@@ -121,11 +121,6 @@ describe('MailApp Sky UI contract', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('uses liquid glass for mailbox, filter and compose floating actions', () => {
|
||||
expect(source.match(/variant="glass"/g)).toHaveLength(5)
|
||||
expect(source).not.toContain('variant="neutral"')
|
||||
})
|
||||
|
||||
it('offers multiple filter criteria in one scrolling Sky UI modal', () => {
|
||||
const filterStart = source.indexOf('class="mail-modal mail-filter-modal"')
|
||||
const filterEnd = source.indexOf('</sky-sheet>', filterStart)
|
||||
@@ -300,10 +295,7 @@ describe('Mail custom mailbox server contract', () => {
|
||||
'mail:delete-mailbox',
|
||||
'mail:move',
|
||||
]) {
|
||||
const callback = endpoint.slice('mail:'.length)
|
||||
expect(clientSource).toMatch(
|
||||
new RegExp(`mail\\s*=\\s*\\[\\[[^\\]]*(?:^|\\s)${callback}(?:\\s|\\]\\])`),
|
||||
)
|
||||
expect(clientSource).toContain(`"${endpoint}"`)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1246,7 +1246,7 @@ onBeforeUnmount(() => {
|
||||
v-if="editingMailboxes"
|
||||
component="button"
|
||||
type="button"
|
||||
variant="glass"
|
||||
variant="neutral"
|
||||
:text="phone.t('Apps.mail.newMailbox')"
|
||||
:aria-label="phone.t('Apps.mail.newMailbox')"
|
||||
@click="beginMailboxCreate"
|
||||
@@ -1257,7 +1257,7 @@ onBeforeUnmount(() => {
|
||||
v-else
|
||||
component="button"
|
||||
type="button"
|
||||
variant="glass"
|
||||
variant="neutral"
|
||||
:aria-label="phone.t('Apps.mail.compose')"
|
||||
@click="beginCompose()"
|
||||
>
|
||||
@@ -1426,7 +1426,7 @@ onBeforeUnmount(() => {
|
||||
component="button"
|
||||
type="button"
|
||||
class="mail-filter-fab mail-filter-fab--active"
|
||||
variant="glass"
|
||||
variant="neutral"
|
||||
:disabled="!canFilterFolder"
|
||||
:aria-label="filterButtonLabel"
|
||||
:aria-pressed="true"
|
||||
@@ -1448,7 +1448,7 @@ onBeforeUnmount(() => {
|
||||
component="button"
|
||||
type="button"
|
||||
class="mail-filter-fab"
|
||||
variant="glass"
|
||||
variant="neutral"
|
||||
:disabled="!canFilterFolder"
|
||||
:aria-label="filterButtonLabel"
|
||||
:aria-pressed="false"
|
||||
@@ -1467,7 +1467,7 @@ onBeforeUnmount(() => {
|
||||
<sky-fab
|
||||
component="button"
|
||||
type="button"
|
||||
variant="glass"
|
||||
variant="neutral"
|
||||
:aria-label="phone.t('Apps.mail.compose')"
|
||||
@click="beginCompose()"
|
||||
>
|
||||
|
||||
@@ -15,17 +15,6 @@ describe('MapApp interaction contract', () => {
|
||||
expect(source).not.toContain('<EasyShareSheet')
|
||||
})
|
||||
|
||||
it('uses translucent liquid glass for every floating map control', () => {
|
||||
const controlsStart = source.indexOf('class="map-controls"')
|
||||
const controlsEnd = source.indexOf('</nav>', controlsStart)
|
||||
const controls = source.slice(controlsStart, controlsEnd)
|
||||
|
||||
expect(controls.match(/variant="glass"/g)).toHaveLength(4)
|
||||
expect(controls).not.toContain('variant="neutral"')
|
||||
expect(controls).not.toContain('variant="primary"')
|
||||
expect(source).toContain('--sky-glass: rgb(247 247 248 / 72%);')
|
||||
})
|
||||
|
||||
it('keeps zoom and panning inside scale-aware map bounds', () => {
|
||||
expect(source).toContain('minimumCoverZoom(metrics, baseMinZoom)')
|
||||
expect(source).toContain('clampMapPan(nextPan, nextZoom, metrics)')
|
||||
|
||||
@@ -667,7 +667,7 @@ onBeforeUnmount(() => {
|
||||
component="button"
|
||||
type="button"
|
||||
class="map-control map-control--share"
|
||||
variant="glass"
|
||||
variant="primary"
|
||||
:aria-label="phone.t('Apps.easyShare.name')"
|
||||
@click="shareCurrentLocation"
|
||||
>
|
||||
@@ -679,7 +679,7 @@ onBeforeUnmount(() => {
|
||||
component="button"
|
||||
type="button"
|
||||
class="map-control"
|
||||
variant="glass"
|
||||
variant="neutral"
|
||||
:aria-label="`${phone.t('Apps.map.switchStyle')}: ${phone.t(`Apps.map.styles.${mapStyle}`)}`"
|
||||
@click="cycleMapStyle"
|
||||
>
|
||||
@@ -691,7 +691,7 @@ onBeforeUnmount(() => {
|
||||
component="button"
|
||||
type="button"
|
||||
class="map-control map-control--marker"
|
||||
variant="glass"
|
||||
variant="neutral"
|
||||
:disabled="placingMarker"
|
||||
:aria-label="phone.t('Apps.map.addMarker')"
|
||||
@click="startMarkerPlacement"
|
||||
@@ -704,7 +704,7 @@ onBeforeUnmount(() => {
|
||||
component="button"
|
||||
type="button"
|
||||
class="map-control map-control--location"
|
||||
variant="glass"
|
||||
variant="neutral"
|
||||
:disabled="locating"
|
||||
:aria-label="phone.t('Apps.map.currentLocation')"
|
||||
@click="loadCurrentLocation(true)"
|
||||
@@ -1015,16 +1015,14 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.map-control {
|
||||
--sky-glass: rgb(247 247 248 / 72%);
|
||||
--sky-hairline: rgb(0 0 0 / 16%);
|
||||
--sky-glass-solid: rgb(247 247 248 / 92%);
|
||||
color: #151515;
|
||||
}
|
||||
.map-control--share {
|
||||
color: #007aff;
|
||||
color: #fff;
|
||||
}
|
||||
.sky-app-page--dark .map-control {
|
||||
--sky-glass: rgb(44 44 46 / 62%);
|
||||
--sky-hairline: rgb(255 255 255 / 16%);
|
||||
--sky-glass-solid: rgb(44 44 46 / 88%);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,6 @@ import type {
|
||||
MemoryDifficulty,
|
||||
} from '@/features/games/memory/types'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import { SkyButton } from '@/ui'
|
||||
|
||||
const phone = usePhoneStore()
|
||||
const memory = useMemoryStore()
|
||||
@@ -136,7 +135,7 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
<div class="memory-header__actions">
|
||||
<Sparkles :size="23" aria-hidden="true" />
|
||||
<SkyButton glass icon-only rounded
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="phone.t(memory.soundEnabled ? 'Apps.memory.mute' : 'Apps.memory.unmute')"
|
||||
:title="phone.t(memory.soundEnabled ? 'Apps.memory.mute' : 'Apps.memory.unmute')"
|
||||
@@ -144,7 +143,7 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
<Volume2 v-if="memory.soundEnabled" :size="18" aria-hidden="true" />
|
||||
<VolumeX v-else :size="18" aria-hidden="true" />
|
||||
</SkyButton>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -183,7 +182,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
<section v-else class="memory-game">
|
||||
<div class="memory-stats">
|
||||
<SkyButton glass icon-only rounded
|
||||
<button
|
||||
type="button"
|
||||
class="memory-menu-button"
|
||||
:aria-label="phone.t('Apps.memory.backToMenu')"
|
||||
@@ -191,7 +190,7 @@ onBeforeUnmount(() => {
|
||||
@click="returnToMenu"
|
||||
>
|
||||
<ChevronLeft :size="18" :stroke-width="2.6" aria-hidden="true" />
|
||||
</SkyButton>
|
||||
</button>
|
||||
<div>
|
||||
<span>{{ phone.t('Apps.memory.time') }}</span>
|
||||
<strong>{{ formatTime(memory.elapsedMs) }}</strong>
|
||||
@@ -317,7 +316,7 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.memory-header__actions > svg,
|
||||
.memory-header__actions button:not(.sky-button--glass) {
|
||||
.memory-header__actions button {
|
||||
box-sizing: content-box;
|
||||
padding: 9px;
|
||||
border: 0;
|
||||
@@ -326,8 +325,6 @@ onBeforeUnmount(() => {
|
||||
background: rgb(255 255 255 / 54%);
|
||||
}
|
||||
|
||||
.memory-header__actions .sky-button--glass { color: #7658c7; }
|
||||
|
||||
.memory-header__actions button {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
@@ -444,16 +441,17 @@ onBeforeUnmount(() => {
|
||||
.memory-stats div { height: 32px; display: grid; grid-template-rows: 10px 20px; align-content: center; justify-items: center; }
|
||||
.memory-stats span { color: #74698f; font-size: 10.5px; font-weight: 800; line-height: 10px; text-transform: uppercase; }
|
||||
.memory-stats strong { display: block; font-size: 19px; line-height: 20px; }
|
||||
.memory-stats button:not(.sky-button--glass) { justify-self: end; border: 0; color: #7052bf; background: transparent; font-size: 13px; font-weight: 800; }
|
||||
.memory-stats button { justify-self: end; border: 0; color: #7052bf; background: transparent; font-size: 13px; font-weight: 800; }
|
||||
.memory-stats .memory-menu-button {
|
||||
--sky-touch-target: 32px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
justify-self: start;
|
||||
padding: 0;
|
||||
color: #7052bf;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: rgb(255 255 255 / 48%);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const source = readFileSync(new URL('./MemosApp.vue', import.meta.url), 'utf8')
|
||||
|
||||
describe('MemosApp layout', () => {
|
||||
it('uses the shared horizontal page gutter in list and detail views', () => {
|
||||
expect(source).toContain(
|
||||
'<SkyScrollArea padded class="memos-page__content">',
|
||||
)
|
||||
expect(source).toContain(
|
||||
'<SkyScrollArea padded class="memo-detail-scroll">',
|
||||
)
|
||||
})
|
||||
|
||||
it('optically centers playback speed labels without moving the controls', () => {
|
||||
expect(source).toContain('<span class="memo-speed-label">')
|
||||
expect(source).toContain('.memo-speed-label {')
|
||||
expect(source).toContain('transform: translateY(1px)')
|
||||
})
|
||||
|
||||
it('centers the skip duration inside a dedicated icon frame', () => {
|
||||
expect(source).toContain('<span class="memo-skip-icon" aria-hidden="true">')
|
||||
expect(source).toContain('<RotateCcw :size="24" />')
|
||||
expect(source).toContain('<RotateCw :size="24" />')
|
||||
expect(source).toContain('.memo-skip-icon small {')
|
||||
expect(source).toContain('place-items: center')
|
||||
})
|
||||
})
|
||||
@@ -457,7 +457,7 @@ onBeforeUnmount(() => {
|
||||
:title="phone.t('Apps.memos.name')"
|
||||
/>
|
||||
|
||||
<SkyScrollArea padded class="memos-page__content">
|
||||
<SkyScrollArea class="memos-page__content">
|
||||
<div v-if="memos.loading" class="memos-loading">
|
||||
<SkySpinner :label="phone.t('Common.loading')" :size="24" />
|
||||
</div>
|
||||
@@ -581,7 +581,7 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
</SkyNavbar>
|
||||
|
||||
<SkyScrollArea padded class="memo-detail-scroll">
|
||||
<SkyScrollArea class="memo-detail-scroll">
|
||||
<SkyGlass class="memo-fields-glass" :highlight="false">
|
||||
<SkyList nested :dividers="false">
|
||||
<SkyField
|
||||
@@ -648,10 +648,8 @@ onBeforeUnmount(() => {
|
||||
:aria-label="phone.t('Apps.memos.skipBack')"
|
||||
@click="skipPlayback(-15)"
|
||||
>
|
||||
<span class="memo-skip-icon" aria-hidden="true">
|
||||
<RotateCcw :size="24" />
|
||||
<small>15</small>
|
||||
</span>
|
||||
<RotateCcw :size="22" aria-hidden="true" />
|
||||
<small>15</small>
|
||||
</SkyButton>
|
||||
<SkyButton
|
||||
rounded
|
||||
@@ -680,10 +678,8 @@ onBeforeUnmount(() => {
|
||||
:aria-label="phone.t('Apps.memos.skipForward')"
|
||||
@click="skipPlayback(15)"
|
||||
>
|
||||
<span class="memo-skip-icon" aria-hidden="true">
|
||||
<RotateCw :size="24" />
|
||||
<small>15</small>
|
||||
</span>
|
||||
<RotateCw :size="22" aria-hidden="true" />
|
||||
<small>15</small>
|
||||
</SkyButton>
|
||||
</SkyGlass>
|
||||
|
||||
@@ -703,7 +699,7 @@ onBeforeUnmount(() => {
|
||||
:active="playbackRate === rate"
|
||||
@click="setPlaybackRate(rate)"
|
||||
>
|
||||
<span class="memo-speed-label">{{ rate }}×</span>
|
||||
{{ rate }}×
|
||||
</SkySegmentedButton>
|
||||
</SkySegmented>
|
||||
</SkyGlass>
|
||||
@@ -1066,25 +1062,10 @@ onBeforeUnmount(() => {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.memo-skip-icon {
|
||||
position: relative;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: grid;
|
||||
flex: none;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.memo-skip-icon small {
|
||||
.memo-player-control small {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
padding-top: 1px;
|
||||
font-size: 7px;
|
||||
font-size: 8px;
|
||||
font-weight: 800;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.memo-player-control--main {
|
||||
@@ -1109,10 +1090,6 @@ onBeforeUnmount(() => {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.memo-speed-label {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.memo-delete-action {
|
||||
min-height: 48px;
|
||||
gap: 8px;
|
||||
|
||||
@@ -90,7 +90,7 @@ describe('MessagesApp Sky UI contract', () => {
|
||||
expect(searchbarStart).toBeGreaterThan(-1)
|
||||
expect(fabStart).toBeGreaterThan(searchbarStart)
|
||||
expect(toolbar).toContain('v-model="search"')
|
||||
expect(toolbar).toContain('variant="glass"')
|
||||
expect(toolbar).toContain('variant="neutral"')
|
||||
expect(toolbar).toContain('@click="beginCompose"')
|
||||
expect(toolbar).toContain('<SquarePen :size="21" />')
|
||||
expect(inbox).not.toContain('messages-sky-compose-navigation')
|
||||
@@ -186,13 +186,6 @@ describe('MessagesApp Sky UI contract', () => {
|
||||
expect(source).toContain(
|
||||
'aspectRatio: `${Math.max(1, gif.width)} / ${Math.max(1, gif.height)}`',
|
||||
)
|
||||
expect(source).toContain('const gifColumns = computed')
|
||||
expect(source).toContain('class="messages-gif-grid"')
|
||||
expect(source).toContain('class="messages-gif-column"')
|
||||
expect(source).toContain('class="messages-gif-result"')
|
||||
expect(styles).toMatch(
|
||||
/\.messages-media-picker__gifs--masonry \.messages-gif-result img\s*\{[^}]*object-fit:\s*cover/s,
|
||||
)
|
||||
})
|
||||
|
||||
it('opens contact sharing in a draggable Sky UI bottom sheet', () => {
|
||||
|
||||
@@ -126,19 +126,6 @@ const gifLoading = ref(false)
|
||||
const gifError = ref<string | null>(null)
|
||||
const gifHasMore = ref(true)
|
||||
const gifNextOffset = ref(0)
|
||||
const gifColumns = computed<[GifSearchResult[], GifSearchResult[]]>(() => {
|
||||
const columns: [GifSearchResult[], GifSearchResult[]] = [[], []]
|
||||
const columnHeights = [0, 0]
|
||||
|
||||
for (const gif of gifResults.value) {
|
||||
const columnIndex = columnHeights[0] <= columnHeights[1] ? 0 : 1
|
||||
columns[columnIndex].push(gif)
|
||||
columnHeights[columnIndex] +=
|
||||
Math.max(1, gif.height) / Math.max(1, gif.width)
|
||||
}
|
||||
|
||||
return columns
|
||||
})
|
||||
const recording = ref(false)
|
||||
const recordingStarting = ref(false)
|
||||
const recordingElapsedMs = ref(0)
|
||||
@@ -1248,7 +1235,7 @@ onBeforeUnmount(() => {
|
||||
:clear-label="phone.t('Common.clear')"
|
||||
/>
|
||||
<SkyFab
|
||||
variant="glass"
|
||||
variant="neutral"
|
||||
:aria-label="phone.t('Apps.messages.compose')"
|
||||
@click="beginCompose"
|
||||
>
|
||||
@@ -1727,10 +1714,7 @@ onBeforeUnmount(() => {
|
||||
{{ phone.t('Apps.messages.noContactsToShare') }}
|
||||
</p>
|
||||
</SkyList>
|
||||
<div
|
||||
v-else
|
||||
class="messages-media-picker__gifs messages-media-picker__gifs--masonry"
|
||||
>
|
||||
<div v-else class="messages-media-picker__gifs">
|
||||
<SkySearchbar
|
||||
v-model="gifQuery"
|
||||
class="messages-gif-search"
|
||||
@@ -1740,27 +1724,18 @@ onBeforeUnmount(() => {
|
||||
@input="queueGifSearch"
|
||||
@clear="queueGifSearch"
|
||||
/>
|
||||
<div v-if="gifResults.length" class="messages-gif-grid">
|
||||
<div
|
||||
v-for="(column, columnIndex) in gifColumns"
|
||||
:key="columnIndex"
|
||||
class="messages-gif-column"
|
||||
>
|
||||
<button
|
||||
v-for="gif in column"
|
||||
:key="gif.id"
|
||||
type="button"
|
||||
class="messages-gif-result"
|
||||
:aria-label="gif.title"
|
||||
:style="{
|
||||
aspectRatio: `${Math.max(1, gif.width)} / ${Math.max(1, gif.height)}`,
|
||||
}"
|
||||
@click="sendAttachment('gif', gif.url)"
|
||||
>
|
||||
<img :src="gif.previewUrl" :alt="gif.title" loading="lazy" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
v-for="gif in gifResults"
|
||||
:key="gif.id"
|
||||
type="button"
|
||||
:aria-label="gif.title"
|
||||
:style="{
|
||||
aspectRatio: `${Math.max(1, gif.width)} / ${Math.max(1, gif.height)}`,
|
||||
}"
|
||||
@click="sendAttachment('gif', gif.url)"
|
||||
>
|
||||
<img :src="gif.previewUrl" :alt="gif.title" loading="lazy" />
|
||||
</button>
|
||||
<button
|
||||
v-if="gifResults.length && gifHasMore && !gifLoading"
|
||||
type="button"
|
||||
|
||||
@@ -23,7 +23,6 @@ import type {
|
||||
MinesweeperDifficulty,
|
||||
} from '@/features/games/minesweeper/types'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import { SkyButton } from '@/ui'
|
||||
|
||||
const phone = usePhoneStore()
|
||||
const minesweeper = useMinesweeperStore()
|
||||
@@ -209,7 +208,7 @@ onBeforeUnmount(() => {
|
||||
<span>{{ phone.t('Apps.minesweeper.eyebrow') }}</span>
|
||||
<h1>{{ phone.t('Apps.minesweeper.name') }}</h1>
|
||||
</div>
|
||||
<SkyButton glass icon-only rounded
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="
|
||||
phone.t(
|
||||
@@ -222,7 +221,7 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
<Volume2 v-if="minesweeper.soundEnabled" :size="18" aria-hidden="true" />
|
||||
<VolumeX v-else :size="18" aria-hidden="true" />
|
||||
</SkyButton>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<section v-if="minesweeper.menuOpen" class="minesweeper-menu">
|
||||
@@ -276,7 +275,7 @@ onBeforeUnmount(() => {
|
||||
}"
|
||||
>
|
||||
<div class="minesweeper-toolbar">
|
||||
<SkyButton glass icon-only rounded
|
||||
<button
|
||||
type="button"
|
||||
class="minesweeper-toolbar__icon"
|
||||
:aria-label="phone.t('Apps.minesweeper.backToMenu')"
|
||||
@@ -284,7 +283,7 @@ onBeforeUnmount(() => {
|
||||
@click.stop="minesweeper.showMenu()"
|
||||
>
|
||||
<ChevronLeft :size="19" :stroke-width="2.7" aria-hidden="true" />
|
||||
</SkyButton>
|
||||
</button>
|
||||
<div>
|
||||
<span>{{ phone.t('Apps.minesweeper.mines') }}</span>
|
||||
<strong>{{ minesRemaining }}</strong>
|
||||
@@ -293,14 +292,14 @@ onBeforeUnmount(() => {
|
||||
<span>{{ phone.t('Apps.minesweeper.time') }}</span>
|
||||
<strong>{{ formatTime(minesweeper.elapsedMs) }}</strong>
|
||||
</div>
|
||||
<SkyButton glass icon-only rounded
|
||||
<button
|
||||
type="button"
|
||||
class="minesweeper-toolbar__icon"
|
||||
:aria-label="phone.t('Apps.minesweeper.restart')"
|
||||
@click="restart"
|
||||
>
|
||||
<RotateCcw :size="17" :stroke-width="2.5" aria-hidden="true" />
|
||||
</SkyButton>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -439,8 +438,8 @@ onBeforeUnmount(() => {
|
||||
.minesweeper-header span { display: block; color: #59878a; font-size: 9px; font-weight: 800; letter-spacing: 1.1px; text-transform: uppercase; }
|
||||
.minesweeper-header h1 { margin: 0; font-size: 24px; line-height: 1; letter-spacing: -0.7px; }
|
||||
|
||||
.minesweeper-header button:not(.sky-button--glass),
|
||||
.minesweeper-toolbar__icon:not(.sky-button--glass) {
|
||||
.minesweeper-header button,
|
||||
.minesweeper-toolbar__icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: grid;
|
||||
@@ -453,9 +452,6 @@ onBeforeUnmount(() => {
|
||||
box-shadow: 0 4px 10px rgb(23 73 75 / 8%);
|
||||
}
|
||||
|
||||
.minesweeper-header .sky-button--glass { color: #246871; }
|
||||
.minesweeper-toolbar .sky-button--glass { --sky-touch-target: 32px; width: 32px; height: 32px; color: #246871; }
|
||||
|
||||
.minesweeper-menu {
|
||||
height: calc(100% - 55px);
|
||||
display: flex;
|
||||
|
||||
@@ -25,7 +25,6 @@ import type {
|
||||
NeonDropPieceKind,
|
||||
} from '@/features/games/neon-drop/types'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import { SkyButton } from '@/ui'
|
||||
|
||||
type RenderCell = {
|
||||
active: boolean
|
||||
@@ -207,10 +206,7 @@ onBeforeUnmount(() => {
|
||||
<span>{{ phone.t('Apps.neonDrop.eyebrow') }}</span>
|
||||
<h1>{{ phone.t('Apps.neonDrop.name') }}</h1>
|
||||
</div>
|
||||
<SkyButton
|
||||
glass
|
||||
icon-only
|
||||
rounded
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="
|
||||
phone.t(
|
||||
@@ -223,7 +219,7 @@ onBeforeUnmount(() => {
|
||||
v-else
|
||||
:size="18"
|
||||
/>
|
||||
</SkyButton>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<section v-if="neon.menuOpen" class="neon-menu">
|
||||
@@ -278,16 +274,13 @@ onBeforeUnmount(() => {
|
||||
|
||||
<section v-else-if="game" class="neon-game">
|
||||
<div class="neon-toolbar">
|
||||
<SkyButton
|
||||
glass
|
||||
icon-only
|
||||
rounded
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="phone.t('Apps.neonDrop.backToMenu')"
|
||||
@click="neon.showMenu()"
|
||||
>
|
||||
<ChevronLeft :size="19" />
|
||||
</SkyButton>
|
||||
</button>
|
||||
<div>
|
||||
<span>{{ phone.t('Apps.neonDrop.score') }}</span
|
||||
><strong>{{ game.score }}</strong>
|
||||
@@ -296,10 +289,7 @@ onBeforeUnmount(() => {
|
||||
<span>{{ phone.t('Apps.neonDrop.lines') }}</span
|
||||
><strong>{{ game.lines }}</strong>
|
||||
</div>
|
||||
<SkyButton
|
||||
glass
|
||||
icon-only
|
||||
rounded
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="phone.t('Apps.neonDrop.pause')"
|
||||
@click="togglePause"
|
||||
@@ -309,7 +299,7 @@ onBeforeUnmount(() => {
|
||||
:size="16"
|
||||
fill="currentColor"
|
||||
/><Play v-else :size="16" fill="currentColor" />
|
||||
</SkyButton>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="neon-play-area">
|
||||
@@ -413,8 +403,8 @@ onBeforeUnmount(() => {
|
||||
font-size: 32px;
|
||||
line-height: 1;
|
||||
}
|
||||
.neon-header button:not(.sky-button--glass),
|
||||
.neon-toolbar button:not(.sky-button--glass) {
|
||||
.neon-header button,
|
||||
.neon-toolbar button {
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
display: grid;
|
||||
@@ -425,10 +415,6 @@ onBeforeUnmount(() => {
|
||||
color: #fff;
|
||||
background: #ffffff0d;
|
||||
}
|
||||
.neon-header .sky-button--glass,
|
||||
.neon-toolbar .sky-button--glass {
|
||||
color: #fff;
|
||||
}
|
||||
.neon-menu {
|
||||
height: calc(100% - 54px);
|
||||
display: flex;
|
||||
@@ -628,9 +614,11 @@ onBeforeUnmount(() => {
|
||||
line-height: 20px;
|
||||
}
|
||||
.neon-toolbar button {
|
||||
--sky-touch-target: 32px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
box-shadow: none;
|
||||
}
|
||||
.neon-play-area {
|
||||
position: absolute;
|
||||
|
||||
@@ -10,11 +10,10 @@ const menuSource = source.slice(
|
||||
source.indexOf('<SkyActionSheet'),
|
||||
source.indexOf('</SkyActionSheet>') + '</SkyActionSheet>'.length,
|
||||
)
|
||||
const listStart = source.search(/<sky-app-page\r?\n\s+v-if="!editorOpened"/)
|
||||
const listSource = source.slice(
|
||||
listStart,
|
||||
source.indexOf('<sky-app-page v-else'),
|
||||
const listStart = source.search(
|
||||
/<sky-app-page\r?\n\s+v-if="!editorOpened"/,
|
||||
)
|
||||
const listSource = source.slice(listStart, source.indexOf('<sky-app-page v-else'))
|
||||
|
||||
describe('NotesApp list controls', () => {
|
||||
it('places the Sky searchbar and create action together at the bottom', () => {
|
||||
@@ -28,7 +27,7 @@ describe('NotesApp list controls', () => {
|
||||
expect(composerSource).toContain('<SkySearchbar')
|
||||
expect(composerSource).toContain('v-model="searchQuery"')
|
||||
expect(composerSource).toContain('<SkyFab')
|
||||
expect(composerSource).toContain('variant="glass"')
|
||||
expect(composerSource).toContain('variant="neutral"')
|
||||
expect(composerSource).toContain('@click="createNote"')
|
||||
expect(composerSource).not.toContain('notes-search')
|
||||
expect(composerSource).not.toContain('notes-create-fab')
|
||||
@@ -38,20 +37,6 @@ describe('NotesApp list controls', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('NotesApp headers', () => {
|
||||
it('keeps list and editor headers at the shared app height', () => {
|
||||
expect(source).toContain('class="notes-list-navbar"')
|
||||
expect(source).toContain(
|
||||
'.notes-list-navbar.sky-navbar--large.sky-navbar--no-navigation',
|
||||
)
|
||||
expect(source).toContain(
|
||||
'padding-top: calc(var(--sky-navbar-safe-area-top) + var(--sky-space-3))',
|
||||
)
|
||||
expect(source).toContain('class="notes-editor-page !pb-0"')
|
||||
expect(source).not.toContain('notes-editor-page !pt-[44px]')
|
||||
})
|
||||
})
|
||||
|
||||
describe('NotesApp more menu', () => {
|
||||
it('uses the shared Feather-style action sheet', () => {
|
||||
expect(menuSource).toContain(
|
||||
|
||||
@@ -206,7 +206,6 @@ function shareNote(): void {
|
||||
:aria-label="phone.t('Apps.notes.name')"
|
||||
>
|
||||
<sky-navbar
|
||||
class="notes-list-navbar"
|
||||
variant="large"
|
||||
transparent
|
||||
:title="phone.t('Apps.notes.name')"
|
||||
@@ -274,7 +273,7 @@ function shareNote(): void {
|
||||
/>
|
||||
<SkyFab
|
||||
:aria-label="phone.t('Apps.notes.newNote')"
|
||||
variant="glass"
|
||||
variant="neutral"
|
||||
@click="createNote"
|
||||
>
|
||||
<template #icon>
|
||||
@@ -306,7 +305,7 @@ function shareNote(): void {
|
||||
</SkyDialog>
|
||||
</sky-app-page>
|
||||
|
||||
<sky-app-page v-else class="notes-editor-page !pb-0">
|
||||
<sky-app-page v-else class="notes-editor-page !pt-[44px] !pb-0">
|
||||
<sky-navbar :title="phone.t('Apps.notes.note')">
|
||||
<template #left>
|
||||
<sky-navbar-back-link
|
||||
@@ -387,16 +386,6 @@ function shareNote(): void {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:deep(.notes-list-navbar.sky-navbar--large) {
|
||||
min-height: calc(
|
||||
var(--sky-navbar-safe-area-top) + var(--sky-navbar-large-title-height)
|
||||
);
|
||||
}
|
||||
|
||||
:deep(.notes-list-navbar.sky-navbar--large.sky-navbar--no-navigation) {
|
||||
padding-top: calc(var(--sky-navbar-safe-area-top) + var(--sky-space-3));
|
||||
}
|
||||
|
||||
.notes-delete-confirm {
|
||||
color: var(--sky-danger);
|
||||
background: var(--sky-danger-soft);
|
||||
|
||||
@@ -14,7 +14,6 @@ import type {
|
||||
NumberMergeTile,
|
||||
} from '@/features/games/number-merge/types'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import { SkyButton } from '@/ui'
|
||||
|
||||
const phone = usePhoneStore()
|
||||
const numberMerge = useNumberMergeStore()
|
||||
@@ -136,7 +135,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
|
||||
<span>{{ phone.t('Apps.numberMerge.eyebrow') }}</span>
|
||||
<h1>{{ phone.t('Apps.numberMerge.name') }}</h1>
|
||||
</div>
|
||||
<SkyButton glass icon-only rounded
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="
|
||||
phone.t(
|
||||
@@ -156,7 +155,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
|
||||
>
|
||||
<Volume2 v-if="numberMerge.soundEnabled" :size="18" aria-hidden="true" />
|
||||
<VolumeX v-else :size="18" aria-hidden="true" />
|
||||
</SkyButton>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<section v-if="numberMerge.menuOpen" class="number-merge-menu">
|
||||
@@ -211,7 +210,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
|
||||
|
||||
<section v-else-if="game" class="number-merge-game">
|
||||
<div class="number-merge-toolbar">
|
||||
<SkyButton glass icon-only rounded
|
||||
<button
|
||||
type="button"
|
||||
class="number-merge-toolbar__icon"
|
||||
:aria-label="phone.t('Apps.numberMerge.backToMenu')"
|
||||
@@ -219,7 +218,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
|
||||
@click="numberMerge.showMenu"
|
||||
>
|
||||
<ChevronLeft :size="19" :stroke-width="2.7" aria-hidden="true" />
|
||||
</SkyButton>
|
||||
</button>
|
||||
<div>
|
||||
<span>{{ phone.t('Apps.numberMerge.score') }}</span>
|
||||
<strong>{{ formatScore(game.score) }}</strong>
|
||||
@@ -228,7 +227,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
|
||||
<span>{{ phone.t('Apps.numberMerge.best') }}</span>
|
||||
<strong>{{ formatScore(numberMerge.bestScore) }}</strong>
|
||||
</div>
|
||||
<SkyButton glass icon-only rounded
|
||||
<button
|
||||
type="button"
|
||||
class="number-merge-toolbar__icon number-merge-toolbar__restart"
|
||||
:aria-label="phone.t('Apps.numberMerge.newGame')"
|
||||
@@ -236,7 +235,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
|
||||
@click="requestNewGame"
|
||||
>
|
||||
<RotateCcw :size="17" :stroke-width="2.5" aria-hidden="true" />
|
||||
</SkyButton>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -374,8 +373,8 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.number-merge-header button:not(.sky-button--glass),
|
||||
.number-merge-toolbar__icon:not(.sky-button--glass) {
|
||||
.number-merge-header button,
|
||||
.number-merge-toolbar__icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: grid;
|
||||
@@ -389,9 +388,6 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.number-merge-header .sky-button--glass { color: #784c3c; }
|
||||
.number-merge-toolbar .sky-button--glass { --sky-touch-target: 32px; width: 32px; height: 32px; color: #784c3c; }
|
||||
|
||||
.number-merge-menu {
|
||||
height: calc(100% - 55px);
|
||||
min-height: 0;
|
||||
|
||||
@@ -16,45 +16,7 @@ describe('PhoneApp EasyShare contract', () => {
|
||||
it('uses the shared full-width Sky tab bar for phone sections', () => {
|
||||
expect(source).toContain('<sky-tab-bar')
|
||||
expect(source).toContain('<sky-tab-button')
|
||||
})
|
||||
|
||||
it('uses shared interactive liquid glass surfaces for phone controls', () => {
|
||||
expect(source).toMatch(
|
||||
/<sky-button\s+glass\s+rounded\s+class="phone-detail-header-button phone-detail-back"/,
|
||||
)
|
||||
expect(source).toMatch(
|
||||
/<sky-button\s+glass\s+v-for="action in contactProfileActions"[\s\S]*?icon-only[\s\S]*?class="phone-profile-action"/,
|
||||
)
|
||||
expect(source).toContain(
|
||||
'width: var(--phone-profile-action-size) !important;',
|
||||
)
|
||||
expect(source).toContain(
|
||||
'height: var(--phone-profile-action-size) !important;',
|
||||
)
|
||||
expect(source).toMatch(
|
||||
/<sky-glass\s+v-for="key in keypadKeys"[\s\S]*?component="button"[\s\S]*?class="phone-keypad-key"/,
|
||||
)
|
||||
expect(source).toContain('class="phone-contacts-add"')
|
||||
expect(source).toContain('class="phone-recents-search"')
|
||||
|
||||
const recentsFilter = source.slice(
|
||||
source.indexOf('<sky-segmented'),
|
||||
source.indexOf('</sky-segmented>') + '</sky-segmented>'.length,
|
||||
)
|
||||
expect(recentsFilter).toContain('class="phone-recents-filter"')
|
||||
expect(recentsFilter).toContain('navigation')
|
||||
expect(recentsFilter).toContain('strong')
|
||||
expect(recentsFilter.match(/<sky-segmented-button/g)).toHaveLength(2)
|
||||
expect(source).toContain(
|
||||
'background: var(--sky-tabbar-highlight-background);',
|
||||
)
|
||||
expect(source).toContain(
|
||||
'grid-template-columns: 52px minmax(0, 1fr) auto 44px;',
|
||||
)
|
||||
expect(source).not.toMatch(
|
||||
/#(?:007aff|0a84ff|195287|22527d|25458e|2a468f|2f4a98|4b92d1|55aaff|5b91c2|64a8ff|68adff)/i,
|
||||
)
|
||||
expect(source).not.toContain('rgba(10, 132, 255')
|
||||
expect(source).not.toContain('<sky-segmented')
|
||||
})
|
||||
|
||||
it('opens contact deep links only after contacts bootstrap and consumes the query', () => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1062,20 +1062,14 @@ onBeforeUnmount(() => {
|
||||
selectedPost.media.length
|
||||
}}</span
|
||||
>
|
||||
<SkyButton
|
||||
glass
|
||||
icon-only
|
||||
rounded
|
||||
<button
|
||||
class="ps-carousel-button ps-carousel-button--left"
|
||||
:disabled="(carouselIndexes[selectedPost.id] ?? 0) === 0"
|
||||
@click="moveCarousel(selectedPost, -1)"
|
||||
>
|
||||
<ChevronLeft />
|
||||
</SkyButton>
|
||||
<SkyButton
|
||||
glass
|
||||
icon-only
|
||||
rounded
|
||||
</button>
|
||||
<button
|
||||
class="ps-carousel-button ps-carousel-button--right"
|
||||
:disabled="
|
||||
(carouselIndexes[selectedPost.id] ?? 0) ===
|
||||
@@ -1084,7 +1078,7 @@ onBeforeUnmount(() => {
|
||||
@click="moveCarousel(selectedPost, 1)"
|
||||
>
|
||||
<ChevronRight />
|
||||
</SkyButton>
|
||||
</button>
|
||||
<div class="ps-dots">
|
||||
<span
|
||||
v-for="(_, index) in selectedPost.media"
|
||||
@@ -1277,20 +1271,14 @@ onBeforeUnmount(() => {
|
||||
post.media.length
|
||||
}}</span
|
||||
>
|
||||
<SkyButton
|
||||
glass
|
||||
icon-only
|
||||
rounded
|
||||
<button
|
||||
class="ps-carousel-button ps-carousel-button--left"
|
||||
:disabled="(carouselIndexes[post.id] ?? 0) === 0"
|
||||
@click="moveCarousel(post, -1)"
|
||||
>
|
||||
<ChevronLeft />
|
||||
</SkyButton>
|
||||
<SkyButton
|
||||
glass
|
||||
icon-only
|
||||
rounded
|
||||
</button>
|
||||
<button
|
||||
class="ps-carousel-button ps-carousel-button--right"
|
||||
:disabled="
|
||||
(carouselIndexes[post.id] ?? 0) === post.media.length - 1
|
||||
@@ -1298,7 +1286,7 @@ onBeforeUnmount(() => {
|
||||
@click="moveCarousel(post, 1)"
|
||||
>
|
||||
<ChevronRight />
|
||||
</SkyButton>
|
||||
</button>
|
||||
<div class="ps-dots">
|
||||
<span
|
||||
v-for="(_, index) in post.media"
|
||||
@@ -1499,28 +1487,22 @@ onBeforeUnmount(() => {
|
||||
</button>
|
||||
</div>
|
||||
<template v-if="selectedMedia.length > 1">
|
||||
<SkyButton
|
||||
glass
|
||||
icon-only
|
||||
rounded
|
||||
<button
|
||||
class="ps-selection-arrow ps-selection-arrow--left"
|
||||
:aria-label="t('previousPhoto')"
|
||||
:disabled="composePreviewIndex === 0"
|
||||
@click="moveComposePreview(-1)"
|
||||
>
|
||||
<ChevronLeft />
|
||||
</SkyButton>
|
||||
<SkyButton
|
||||
glass
|
||||
icon-only
|
||||
rounded
|
||||
</button>
|
||||
<button
|
||||
class="ps-selection-arrow ps-selection-arrow--right"
|
||||
:aria-label="t('nextPhoto')"
|
||||
:disabled="composePreviewIndex === selectedMedia.length - 1"
|
||||
@click="moveComposePreview(1)"
|
||||
>
|
||||
<ChevronRight />
|
||||
</SkyButton>
|
||||
</button>
|
||||
<span class="ps-selection-counter">
|
||||
{{ composePreviewIndex + 1 }}/{{ selectedMedia.length }}
|
||||
</span>
|
||||
@@ -2876,11 +2858,12 @@ button {
|
||||
top: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 44px;
|
||||
min-width: 44px;
|
||||
height: 44px;
|
||||
min-height: 44px;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
padding: 0;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.58);
|
||||
color: white;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
@@ -3208,11 +3191,12 @@ button {
|
||||
top: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 44px;
|
||||
min-width: 44px;
|
||||
height: 44px;
|
||||
min-height: 44px;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.62);
|
||||
color: white;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import SkyFlappyBird from '@/features/games/sky-flappy/SkyFlappyBird.vue'
|
||||
import { useSkyFlappyStore } from '@/features/games/sky-flappy/store'
|
||||
import type { SkyFlappyDesign, SkyFlappyObstacle } from '@/features/games/sky-flappy/types'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import { SkyButton } from '@/ui'
|
||||
|
||||
const phone = usePhoneStore()
|
||||
const flappy = useSkyFlappyStore()
|
||||
@@ -117,9 +116,9 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
<header v-if="flappy.menuOpen" class="flappy-header">
|
||||
<div><span>{{ phone.t('Apps.skyFlappy.eyebrow') }}</span><h1>{{ phone.t('Apps.skyFlappy.name') }}</h1></div>
|
||||
<SkyButton glass icon-only rounded type="button" :aria-label="phone.t(flappy.soundEnabled ? 'Apps.skyFlappy.mute' : 'Apps.skyFlappy.unmute')" @click="toggleSound">
|
||||
<button type="button" :aria-label="phone.t(flappy.soundEnabled ? 'Apps.skyFlappy.mute' : 'Apps.skyFlappy.unmute')" @click="toggleSound">
|
||||
<Volume2 v-if="flappy.soundEnabled" :size="18" /><VolumeX v-else :size="18" />
|
||||
</SkyButton>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<section v-if="flappy.menuOpen" class="flappy-menu">
|
||||
@@ -142,10 +141,10 @@ onBeforeUnmount(() => {
|
||||
|
||||
<section v-else-if="game" class="flappy-game">
|
||||
<div class="flappy-toolbar">
|
||||
<SkyButton glass icon-only rounded type="button" :aria-label="phone.t('Apps.skyFlappy.backToMenu')" @pointerdown.stop="flappy.showMenu()" @click.stop="flappy.showMenu()"><ChevronLeft :size="19" /></SkyButton>
|
||||
<button type="button" :aria-label="phone.t('Apps.skyFlappy.backToMenu')" @pointerdown.stop="flappy.showMenu()" @click.stop="flappy.showMenu()"><ChevronLeft :size="19" /></button>
|
||||
<div><span>{{ phone.t('Apps.skyFlappy.score') }}</span><strong>{{ game.score }}</strong></div>
|
||||
<div><span>{{ phone.t('Apps.skyFlappy.best') }}</span><strong>{{ flappy.highScore }}</strong></div>
|
||||
<SkyButton glass icon-only rounded type="button" :aria-label="phone.t('Apps.skyFlappy.pause')" @click="togglePause"><Pause v-if="game.status === 'playing'" :size="16" fill="currentColor" /><Play v-else :size="16" fill="currentColor" /></SkyButton>
|
||||
<button type="button" :aria-label="phone.t('Apps.skyFlappy.pause')" @click="togglePause"><Pause v-if="game.status === 'playing'" :size="16" fill="currentColor" /><Play v-else :size="16" fill="currentColor" /></button>
|
||||
</div>
|
||||
|
||||
<button type="button" class="flappy-stage" :class="{ 'flappy-stage--crashed': game.status === 'over' && !gameOverVisible }" :aria-label="phone.t('Apps.skyFlappy.flap')" @pointerdown.stop.prevent="flap">
|
||||
@@ -170,7 +169,7 @@ onBeforeUnmount(() => {
|
||||
.flappy-app { --sky-a:#50d8f2;--sky-b:#765ce8;--tower:#574be8;--tower-light:#9b94ff;--tower-dark:#3429a6;--tower-glow:#79e7ff; position:absolute;inset:0;overflow:hidden;padding:52px 16px 27px;color:#fff;background:linear-gradient(160deg,#19375e,#433b80);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;user-select:none;touch-action:manipulation; }
|
||||
.flappy-app--playing { padding:0; }
|
||||
.flappy-app--neon { --sky-a:#151c58;--sky-b:#a329a2;--tower:#19dfe6;--tower-light:#82ffff;--tower-dark:#087f91;--tower-glow:#26fbff; }.flappy-app--storm { --sky-a:#6f8497;--sky-b:#2b3955;--tower:#df765f;--tower-light:#ffb18e;--tower-dark:#8d3e39;--tower-glow:#ff997d; }
|
||||
.flappy-header{height:55px;display:flex;align-items:center;justify-content:space-between}.flappy-header span{display:block;color:#d9e9fa;font-size:14px;font-weight:850;letter-spacing:1.1px;text-transform:uppercase}.flappy-header h1{margin:1px 0 0;font-size:32px;line-height:1}.flappy-header button:not(.sky-button--glass),.flappy-toolbar button:not(.sky-button--glass){width:36px;height:36px;display:grid;place-items:center;padding:0;border:1px solid #ffffff35;border-radius:12px;color:#fff;background:#ffffff18}.flappy-header .sky-button--glass,.flappy-toolbar .sky-button--glass{color:#fff}
|
||||
.flappy-header{height:55px;display:flex;align-items:center;justify-content:space-between}.flappy-header span{display:block;color:#d9e9fa;font-size:14px;font-weight:850;letter-spacing:1.1px;text-transform:uppercase}.flappy-header h1{margin:1px 0 0;font-size:32px;line-height:1}.flappy-header button,.flappy-toolbar button{width:36px;height:36px;display:grid;place-items:center;padding:0;border:1px solid #ffffff35;border-radius:12px;color:#fff;background:#ffffff18}
|
||||
.flappy-menu{height:calc(100% - 55px);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;text-align:center}.flappy-menu__hero{position:relative;width:220px;height:210px;overflow:hidden;border-radius:28px;background:linear-gradient(var(--sky-a),var(--sky-b));box-shadow:inset 0 0 28px #223c6d35,0 18px 32px #101b3b66}.flappy-menu__hero::before{position:absolute;z-index:0;top:22px;left:25px;width:39px;height:39px;border-radius:50%;background:#ffe8a7cc;box-shadow:0 0 18px #fff1b46b;content:""}.flappy-menu__hero::after{position:absolute;z-index:1;bottom:30px;left:-12px;width:68px;height:18px;border-radius:999px;background:#ffffff3d;box-shadow:27px -9px 0 3px #ffffff36,57px 1px 0 -2px #ffffff2c;content:""}.flappy-menu__tower{position:absolute;z-index:3;right:10px;width:42px;background:linear-gradient(90deg,color-mix(in srgb,var(--tower),white 30%),var(--tower));box-shadow:inset 7px 0 0 #ffffff22,inset -6px 0 9px #0003}.flappy-menu__tower::after{position:absolute;left:-8px;width:58px;height:22px;border:3px solid color-mix(in srgb,var(--tower),black 22%);border-radius:7px;background:linear-gradient(90deg,color-mix(in srgb,var(--tower),white 36%),var(--tower));box-shadow:inset 0 4px 0 #ffffff26;content:""}.flappy-menu__tower--top{top:0;height:61px;border-radius:0 0 5px 5px}.flappy-menu__tower--top::after{bottom:-11px}.flappy-menu__tower--bottom{bottom:0;height:67px;border-radius:5px 5px 0 0}.flappy-menu__tower--bottom::after{top:-11px}.flappy-menu__trail{position:absolute;z-index:2;top:102px;left:11px;width:46px;height:4px;border-radius:99px;background:#edfbffb8;box-shadow:13px 12px 0 -1px #e9faff8c,6px -12px 0 -1px #e9faff73;animation:flappy-trail 1s ease-in-out infinite}.sky-bird{position:absolute;z-index:4;width:66px;height:46px;overflow:visible;filter:drop-shadow(0 4px 5px #071d3e66)}.sky-bird :deep(path){stroke-linecap:round;stroke-linejoin:round}.sky-bird :deep(.sky-flappy-bird__far-wing){fill:#2d7188;stroke:#123f57;stroke-width:2}.sky-bird :deep(.sky-flappy-bird__far-wing path:last-child){fill:none;stroke:#8ec6ce;stroke-width:1.4;opacity:.65}.sky-bird :deep(.sky-flappy-bird__tail){fill:#285e78;stroke:#123f57;stroke-width:2}.sky-bird :deep(.sky-flappy-bird__body){fill:url(#sky-bird-body);stroke:#123f57;stroke-width:2}.sky-bird :deep(.sky-flappy-bird__belly){fill:#b7e5df;opacity:.84}.sky-bird :deep(.sky-flappy-bird__neck){fill:#377f98}.sky-bird :deep(.sky-flappy-bird__wing){transform-box:view-box;transform-origin:52px 38px}.sky-bird :deep(.sky-flappy-bird__wing-shape){fill:url(#sky-bird-wing);stroke:#123f57;stroke-width:2}.sky-bird :deep(.sky-flappy-bird__feather){fill:none;stroke:#d4f0ed;stroke-width:1.5;opacity:.78}.sky-bird :deep(.sky-flappy-bird__face){fill:#d8f0e9}.sky-bird :deep(.sky-flappy-bird__eye-ring){fill:#f5fbf7}.sky-bird :deep(.sky-flappy-bird__eye){fill:#102d3d}.sky-bird :deep(.sky-flappy-bird__beak){fill:#f2a84b;stroke:#6f4727;stroke-width:1.5}.sky-bird--hero{top:72px;left:42px;width:100px;height:69px;animation:flappy-hero 1.1s ease-in-out infinite alternate}.sky-bird--hero :deep(.sky-flappy-bird__wing){animation:flappy-bird-glide 1.1s ease-in-out infinite}.sky-bird--hero :deep(.sky-flappy-bird__far-wing){transform-box:view-box;transform-origin:50px 38px;animation:flappy-bird-far-glide 1.1s ease-in-out infinite}.flappy-menu__copy>span{color:#ffda70;font-size:9px;font-weight:900;letter-spacing:1px;text-transform:uppercase}.flappy-menu__copy h2{margin:3px 0 5px;font-size:21px}.flappy-menu__copy p{max-width:275px;margin:0;color:#c0c8df;font-size:10px;line-height:1.4}.flappy-record{width:100%;display:flex;align-items:center;justify-content:space-between;padding:9px 14px;border:1px solid #ffffff14;border-radius:13px;background:#ffffff0c}.flappy-record span{color:#bdc8df;font-size:9px;font-weight:800;text-transform:uppercase}.flappy-record strong{font-size:19px}.flappy-designs{width:100%;display:grid;grid-template-columns:repeat(3,1fr);gap:6px}.flappy-designs button{display:grid;place-items:center;gap:3px;padding:7px 3px;border:1px solid #ffffff12;border-radius:12px;color:#ccd4e9;background:#ffffff0a;font-size:8px}.flappy-designs button.active{border-color:#ffdd72;color:#fff;background:#ffffff1b}.flappy-designs i{width:26px;height:13px;border-radius:8px}.flappy-designs__dawn{background:linear-gradient(90deg,#58d6ee,#ff9b80)}.flappy-designs__neon{background:linear-gradient(90deg,#24255f,#ef55ca)}.flappy-designs__storm{background:linear-gradient(90deg,#71899b,#253750)}.flappy-primary,.flappy-secondary{width:100%;min-height:43px;display:flex;align-items:center;justify-content:center;gap:7px;border-radius:14px;font-size:11px;font-weight:850}.flappy-primary{border:0;color:#173353;background:linear-gradient(135deg,#ffe16c,#ff9d68)}.flappy-secondary{border:1px solid #ffffff18;color:#fff;background:#ffffff0b}.flappy-menu>p,.flappy-game__hint{margin:0;color:#aeb9d2;font-size:9px}
|
||||
.flappy-game{position:absolute;inset:0}.flappy-toolbar{position:absolute;z-index:10;top:48px;right:14px;left:14px;height:42px;display:grid;grid-template-columns:36px 1fr 1fr 36px;align-items:center;gap:7px;padding:4px 6px;border:1px solid #ffffff2b;border-radius:22px;background:#263c6da8;box-shadow:0 8px 24px #10193455;backdrop-filter:blur(14px)}.flappy-toolbar div{display:grid;justify-items:center}.flappy-toolbar span{color:#bac9df;font-size:8px;font-weight:850;text-transform:uppercase}.flappy-toolbar strong{font-size:16px}.flappy-toolbar button{width:34px;height:34px;border:0;border-radius:50%;box-shadow:none}.flappy-stage{position:absolute;inset:0;width:100%;height:100%;display:block;overflow:hidden;padding:0;border:0;border-radius:0;background:linear-gradient(var(--sky-a),var(--sky-b));box-shadow:inset 0 0 35px #15244c55;touch-action:manipulation}.flappy-clouds{position:absolute;z-index:1;inset:0;overflow:hidden;pointer-events:none}.flappy-clouds i{--cloud-scale:1;--cloud-opacity:.34;--cloud-duration:18s;--cloud-delay:0s;position:absolute;left:100%;width:70px;height:18px;border-radius:999px;background:linear-gradient(180deg,#ffffffd9,#eaf7ff9c);box-shadow:0 8px 16px #24376518;opacity:var(--cloud-opacity);animation:cloud-drift var(--cloud-duration) linear var(--cloud-delay) infinite;will-change:transform}.flappy-clouds i::before{position:absolute;bottom:4px;left:12px;width:29px;height:29px;border-radius:50%;background:#f8fcffe6;box-shadow:22px -8px 0 4px #f7fcff,40px 1px 0 -2px #eef9ff;content:""}.flappy-clouds i::after{position:absolute;right:8px;bottom:-3px;left:8px;height:8px;border-radius:50%;background:#bcdff477;filter:blur(4px);content:""}.flappy-clouds i:nth-child(1){--cloud-scale:.7;--cloud-opacity:.3;--cloud-duration:20s;--cloud-delay:-4s;top:9%}.flappy-clouds i:nth-child(2){--cloud-scale:1.05;--cloud-opacity:.4;--cloud-duration:15s;--cloud-delay:-11s;top:22%}.flappy-clouds i:nth-child(3){--cloud-scale:.52;--cloud-opacity:.25;--cloud-duration:23s;--cloud-delay:-17s;top:38%}.flappy-clouds i:nth-child(4){--cloud-scale:.88;--cloud-opacity:.36;--cloud-duration:17s;--cloud-delay:-7s;top:53%}.flappy-clouds i:nth-child(5){--cloud-scale:1.18;--cloud-opacity:.42;--cloud-duration:14s;--cloud-delay:-2s;top:68%}.flappy-clouds i:nth-child(6){--cloud-scale:.62;--cloud-opacity:.27;--cloud-duration:21s;--cloud-delay:-14s;top:78%}.flappy-clouds i:nth-child(7){--cloud-scale:.96;--cloud-opacity:.34;--cloud-duration:16s;--cloud-delay:-9s;top:86%}.flappy-obstacle{position:absolute;z-index:2;top:0;bottom:0}.flappy-obstacle span{position:absolute;right:0;left:0;background:linear-gradient(90deg,color-mix(in srgb,var(--tower),white 20%),var(--tower));box-shadow:inset -6px 0 8px #0003,0 0 13px #17204e55}.flappy-obstacle span::after{position:absolute;right:-4px;left:-4px;height:14px;border-radius:6px;background:color-mix(in srgb,var(--tower),white 10%);box-shadow:inset 0 3px 0 #ffffff25;content:""}.flappy-obstacle__top{top:0;border-radius:0 0 7px 7px}.flappy-obstacle__top::after{bottom:0}.flappy-obstacle__bottom{bottom:0;border-radius:7px 7px 0 0}.flappy-obstacle__bottom::after{top:0}.sky-bird--player{left:23%;animation:flappy-wing .24s ease-out}.sky-bird--player :deep(.sky-flappy-bird__wing){animation:flappy-bird-flap .24s cubic-bezier(.2,.75,.35,1)}.sky-bird--player :deep(.sky-flappy-bird__far-wing){transform-box:view-box;transform-origin:50px 38px;animation:flappy-bird-far-flap .24s cubic-bezier(.2,.75,.35,1)}.flappy-ready{position:absolute;z-index:6;top:36%;left:50%;padding:9px 15px;border-radius:17px;background:#15284fbb;font-size:11px;transform:translateX(-50%)}.flappy-horizon{position:absolute;z-index:3;right:0;bottom:0;left:0;height:12px;background:#263a62;box-shadow:0 -5px 14px #ffffff26}.flappy-stage--crashed{animation:flappy-crash .55s ease-out}.flappy-game__hint{position:absolute;z-index:6;right:45px;bottom:27px;left:45px;margin:0;padding:7px 10px;border-radius:999px;background:#263c6d91;backdrop-filter:blur(10px);text-align:center;pointer-events:none}.flappy-overlay{position:absolute;z-index:12;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:9px;padding:28px;background:#101a38dc;backdrop-filter:blur(7px);text-align:center}.flappy-overlay>svg{color:#ffdc71}.flappy-overlay>span{color:#ffad78;font-size:9px;font-weight:900;letter-spacing:1px;text-transform:uppercase}.flappy-overlay h2{margin:0 0 5px;font-size:26px}
|
||||
.flappy-menu__tower{border:2px solid var(--tower-dark);background:linear-gradient(90deg,var(--tower-light),var(--tower),var(--tower-dark));box-shadow:inset 7px 0 0 #ffffff38,inset -6px 0 9px #0004,0 0 18px var(--tower-glow)}
|
||||
@@ -191,7 +190,7 @@ onBeforeUnmount(() => {
|
||||
.flappy-toolbar strong{display:block;font-size:19px;line-height:20px}
|
||||
.flappy-toolbar{top:66px;right:18px;left:18px;height:42px;grid-template-columns:32px 1fr 1fr 32px;gap:4px;padding:4px;border-radius:21px;box-sizing:border-box}
|
||||
.flappy-toolbar div{height:32px;display:grid;grid-template-rows:10px 20px;align-content:center;justify-items:center}
|
||||
.flappy-toolbar button{--sky-touch-target:32px;width:32px;height:32px}
|
||||
.flappy-toolbar button{width:32px;height:32px}
|
||||
.flappy-clouds{top:110px}
|
||||
.flappy-stage{border:0;box-shadow:inset 0 0 35px #15244c38}
|
||||
.flappy-obstacle span{border:2px solid var(--tower-dark);background:linear-gradient(90deg,var(--tower-light),var(--tower),var(--tower-dark));box-shadow:inset 7px 0 0 #ffffff42,inset -6px 0 8px #0004,0 0 18px var(--tower-glow)}
|
||||
|
||||
@@ -90,31 +90,4 @@ describe('phone apps use Sky UI', () => {
|
||||
expect(source, file).not.toMatch(/(?:citymarkt|pages)__toast/)
|
||||
}
|
||||
})
|
||||
|
||||
it('uses shared liquid glass for remaining compact interaction controls', () => {
|
||||
const minimumGlassButtons: Record<string, number> = {
|
||||
'CameraApp.vue': 2,
|
||||
'FeatherApp.vue': 2,
|
||||
'FlareApp.vue': 1,
|
||||
'FlipTokApp.vue': 2,
|
||||
'MemoryApp.vue': 2,
|
||||
'MinesweeperApp.vue': 3,
|
||||
'NeonDropApp.vue': 3,
|
||||
'NumberMergeApp.vue': 3,
|
||||
'PicstagramApp.vue': 6,
|
||||
'SkyFlappyApp.vue': 3,
|
||||
'SnakeApp.vue': 2,
|
||||
'TowerStackApp.vue': 3,
|
||||
'WeazelNewsApp.vue': 2,
|
||||
}
|
||||
|
||||
for (const [file, minimum] of Object.entries(minimumGlassButtons)) {
|
||||
const source = appSources.find((app) => app.file === file)?.source ?? ''
|
||||
const glassButtons = source.match(
|
||||
/<(?:SkyButton|sky-button)(?=[^>]*\bglass\b)[^>]*>/g,
|
||||
)
|
||||
|
||||
expect(glassButtons?.length ?? 0, file).toBeGreaterThanOrEqual(minimum)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -17,7 +17,6 @@ import type {
|
||||
SnakeSpeed,
|
||||
} from '@/features/games/snake/types'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import { SkyButton } from '@/ui'
|
||||
|
||||
const phone = usePhoneStore()
|
||||
const snake = useSnakeStore()
|
||||
@@ -206,7 +205,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
<section v-else class="snake-game">
|
||||
<div class="snake-game__meta">
|
||||
<SkyButton glass icon-only rounded
|
||||
<button
|
||||
type="button"
|
||||
class="snake-game__back"
|
||||
:aria-label="phone.t('Apps.snake.backToMenu')"
|
||||
@@ -214,16 +213,13 @@ onBeforeUnmount(() => {
|
||||
@click="returnToMenu"
|
||||
>
|
||||
<ChevronLeft :size="18" :stroke-width="2.7" aria-hidden="true" />
|
||||
</SkyButton>
|
||||
</button>
|
||||
<div>
|
||||
<span>{{ phone.t('Apps.snake.score') }}</span>
|
||||
<strong>{{ game.score }}</strong>
|
||||
</div>
|
||||
<SkyButton
|
||||
<button
|
||||
v-if="game.status !== 'game-over'"
|
||||
glass
|
||||
icon-only
|
||||
rounded
|
||||
type="button"
|
||||
class="snake-game__pause"
|
||||
:aria-label="
|
||||
@@ -237,7 +233,7 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
<Play v-if="game.status === 'paused'" :size="18" fill="currentColor" />
|
||||
<Pause v-else :size="18" fill="currentColor" />
|
||||
</SkyButton>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -521,12 +517,14 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.snake-game__meta button {
|
||||
--sky-touch-target: 32px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
color: #dff6d9;
|
||||
background: rgb(255 255 255 / 8%);
|
||||
}
|
||||
|
||||
.snake-game__meta .snake-game__pause { justify-self: end; }
|
||||
|
||||
@@ -23,7 +23,6 @@ import type {
|
||||
TowerBlock,
|
||||
} from '@/features/games/tower-stack/types'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import { SkyButton } from '@/ui'
|
||||
|
||||
const phone = usePhoneStore()
|
||||
const tower = useTowerStackStore()
|
||||
@@ -186,17 +185,14 @@ onBeforeUnmount(() => {
|
||||
<span>{{ phone.t('Apps.towerStack.eyebrow') }}</span>
|
||||
<h1>{{ phone.t('Apps.towerStack.name') }}</h1>
|
||||
</div>
|
||||
<SkyButton
|
||||
glass
|
||||
icon-only
|
||||
rounded
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="phone.t(tower.soundEnabled ? 'Apps.towerStack.mute' : 'Apps.towerStack.unmute')"
|
||||
@click="toggleSound"
|
||||
>
|
||||
<Volume2 v-if="tower.soundEnabled" :size="18" aria-hidden="true" />
|
||||
<VolumeX v-else :size="18" aria-hidden="true" />
|
||||
</SkyButton>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<section v-if="tower.menuOpen" class="tower-menu">
|
||||
@@ -236,21 +232,18 @@ onBeforeUnmount(() => {
|
||||
|
||||
<section v-else-if="game" class="tower-game">
|
||||
<div class="tower-toolbar">
|
||||
<SkyButton
|
||||
glass
|
||||
icon-only
|
||||
rounded
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="phone.t('Apps.towerStack.backToMenu')"
|
||||
@pointerdown.stop="tower.showMenu()"
|
||||
@click.stop="tower.showMenu()"
|
||||
><ChevronLeft :size="19" /></SkyButton>
|
||||
><ChevronLeft :size="19" /></button>
|
||||
<div><span>{{ phone.t('Apps.towerStack.height') }}</span><strong>{{ game.blocks.length - 1 }}</strong></div>
|
||||
<div><span>{{ phone.t('Apps.towerStack.score') }}</span><strong>{{ game.score }}</strong></div>
|
||||
<SkyButton glass icon-only rounded type="button" :aria-label="phone.t('Apps.towerStack.pause')" @click="togglePause">
|
||||
<button type="button" :aria-label="phone.t('Apps.towerStack.pause')" @click="togglePause">
|
||||
<Pause v-if="game.status === 'playing'" :size="17" fill="currentColor" />
|
||||
<Play v-else :size="17" fill="currentColor" />
|
||||
</SkyButton>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@@ -326,8 +319,7 @@ onBeforeUnmount(() => {
|
||||
.tower-header { height: 50px; display: flex; align-items: center; justify-content: space-between; }
|
||||
.tower-header span { display: block; color: #c1b8f1; font-size: 10px; font-weight: 850; letter-spacing: 1.1px; text-transform: uppercase; }
|
||||
.tower-header h1 { margin: 1px 0 0; font-size: 27px; line-height: 1; letter-spacing: -0.8px; }
|
||||
.tower-header button:not(.sky-button--glass), .tower-toolbar button:not(.sky-button--glass) { width: 36px; height: 36px; display: grid; place-items: center; padding: 0; border: 1px solid #ffffff14; border-radius: 12px; color: #f2edff; background: #ffffff0d; }
|
||||
.tower-header .sky-button--glass, .tower-toolbar .sky-button--glass { color: #f2edff; }
|
||||
.tower-header button, .tower-toolbar button { width: 36px; height: 36px; display: grid; place-items: center; padding: 0; border: 1px solid #ffffff14; border-radius: 12px; color: #f2edff; background: #ffffff0d; }
|
||||
.tower-menu { height: calc(100% - 50px); display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 9px; text-align: center; }
|
||||
.tower-menu__preview { position: relative; flex: 0 0 124px; width: 158px; height: 124px; }
|
||||
.tower-menu__preview i { position: absolute; right: 12px; bottom: calc((var(--preview-index) - 1) * 15px); left: 23px; height: 21px; border-radius: 6px; background: hsl(calc(var(--preview-index) * 49deg + 5deg) 82% 62%); box-shadow: inset 0 3px 0 #ffffff35, 0 5px 11px #08091c5c; transform: perspective(200px) rotateX(5deg); }
|
||||
@@ -349,7 +341,7 @@ onBeforeUnmount(() => {
|
||||
.tower-toolbar div { height: 32px; display: grid; grid-template-rows: 10px 20px; align-content: center; justify-items: center; }
|
||||
.tower-toolbar span { color: #c9c2e9; font-size: 11px; font-weight: 850; line-height: 10px; letter-spacing: .35px; text-transform: uppercase; }
|
||||
.tower-toolbar strong { display: block; font-size: 19px; line-height: 20px; }
|
||||
.tower-toolbar button { --sky-touch-target: 32px; width: 32px; height: 32px; }
|
||||
.tower-toolbar button { width: 32px; height: 32px; border: 0; border-radius: 50%; box-shadow: none; }
|
||||
.tower-stage { position: absolute; inset: 0; width: 100%; height: 100%; display: block; overflow: hidden; padding: 0; border: 0; border-radius: 0; background: linear-gradient(#1d1b52, #433078 60%, #8e4c78); box-shadow: inset 0 0 35px #08091d80; touch-action: manipulation; }
|
||||
.tower-sky { position: absolute; inset: 0; pointer-events: none; }
|
||||
.tower-sky i { position: absolute; width: 3px; height: 3px; border-radius: 50%; background: #fff; box-shadow: 0 0 7px #c5c2ff; opacity: .65; }
|
||||
|
||||
@@ -6,10 +6,6 @@ const source = readFileSync(
|
||||
new URL('./WeatherApp.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const styles = readFileSync(
|
||||
new URL('../../assets/main.css', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
describe('WeatherApp layout contract', () => {
|
||||
it('preserves its exact custom forecast gutter instead of generic page padding', () => {
|
||||
@@ -23,22 +19,4 @@ describe('WeatherApp layout contract', () => {
|
||||
/<SkyScrollArea[\s\S]*?class="weather-scroll"[\s\S]*?\spadded(?:\s|=)[\s\S]*?>/,
|
||||
)
|
||||
})
|
||||
|
||||
it('removes generic card margins from the compact forecast layout', () => {
|
||||
expect(styles).toMatch(
|
||||
/\.weather-details > \.weather-detail-card\s*{[\s\S]*?margin:\s*0;/,
|
||||
)
|
||||
expect(styles).toMatch(
|
||||
/\.weather-scroll > \.weather-panel\s*{\s*margin:\s*0;/,
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps hourly separators straight outside the highlighted current hour', () => {
|
||||
expect(styles).toMatch(
|
||||
/\.weather-hour\s*{[\s\S]*?border-left:[\s\S]*?border-radius:\s*0;/,
|
||||
)
|
||||
expect(styles).toMatch(
|
||||
/\.weather-hour:first-child\s*{[\s\S]*?border-radius:\s*var\(--sky-radius-control\);/,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1193,7 +1193,7 @@ onBeforeUnmount(() => {
|
||||
<sky-button
|
||||
icon-only
|
||||
rounded
|
||||
glass
|
||||
tonal
|
||||
class="weazel-detail-gallery-control is-previous"
|
||||
:aria-label="t('accessibility.previousPhoto')"
|
||||
@click="showPreviousDetailImage"
|
||||
@@ -1203,7 +1203,7 @@ onBeforeUnmount(() => {
|
||||
<sky-button
|
||||
icon-only
|
||||
rounded
|
||||
glass
|
||||
tonal
|
||||
class="weazel-detail-gallery-control is-next"
|
||||
:aria-label="t('accessibility.nextPhoto')"
|
||||
@click="showNextDetailImage"
|
||||
@@ -2112,6 +2112,7 @@ onBeforeUnmount(() => {
|
||||
min-width: var(--sky-touch-target) !important;
|
||||
min-height: var(--sky-touch-target) !important;
|
||||
transform: translateY(-50%);
|
||||
background: rgb(0 0 0 / 58%) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
|
||||
@@ -18,8 +18,8 @@ const clientCalls = readFileSync(
|
||||
new URL('../../sky_phone/source/bridge/client/calls.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const clientNuiBridge = readFileSync(
|
||||
new URL('../../sky_phone/source/client/nui_server_bridge.lua', import.meta.url),
|
||||
const clientMain = readFileSync(
|
||||
new URL('../../sky_phone/source/client/main.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const phoneApp = readFileSync(
|
||||
@@ -70,7 +70,7 @@ describe('voice provider contracts', () => {
|
||||
expect(serverCalls).toMatch(
|
||||
/call\.speakers\[source\] = data\.enabled\s+send_state\(call, source, "connected", call\.channel\)/,
|
||||
)
|
||||
expect(clientNuiBridge).toMatch(/calls\s*=\s*\[\[[^\]]*set-speaker/)
|
||||
expect(clientMain).toContain('"calls:set-speaker"')
|
||||
expect(clientCalls).toContain(
|
||||
'SaltyChat call membership is owned by the server bridge.',
|
||||
)
|
||||
@@ -111,7 +111,7 @@ describe('voice provider contracts', () => {
|
||||
expect(serverCalls).toContain(
|
||||
'Bridge.Callbacks.Register("sky_phone:calls:set-muted"',
|
||||
)
|
||||
expect(clientNuiBridge).toMatch(/calls\s*=\s*\[\[[^\]]*set-muted/)
|
||||
expect(clientMain).toContain('"calls:set-muted"')
|
||||
expect(phoneApp).toContain('@click="toggleCallMute"')
|
||||
expect(phoneApp).not.toContain('callMuted = !callMuted')
|
||||
})
|
||||
|
||||
@@ -2206,12 +2206,12 @@ const attachmentAssets = {
|
||||
video: new Set(['city-loop', 'ocean-loop', 'sunset-loop']),
|
||||
}
|
||||
const gifMocks = [
|
||||
['JIX9t2j0ZTN9S', 'Cat reaction', 200, 200],
|
||||
['MDJ9IbxxvDUQM', 'Happy dog', 200, 112],
|
||||
['l0HlPystfePnAI3G8', 'Celebrate', 200, 200],
|
||||
['26ufdipQqU2lhNA4g', 'Wow', 200, 200],
|
||||
['3o7abKhOpu0NwenH3O', 'Perfect', 200, 112],
|
||||
['xT0xeJpnrWC4XWblEk', 'Party', 200, 132],
|
||||
['ICOgUNjpvO0PC', 'Cat reaction'],
|
||||
['MDJ9IbxxvDUQM', 'Happy dog'],
|
||||
['l0HlPystfePnAI3G8', 'Celebrate'],
|
||||
['26ufdipQqU2lhNA4g', 'Wow'],
|
||||
['3o7abKhOpu0NwenH3O', 'Perfect'],
|
||||
['xT0xeJpnrWC4XWblEk', 'Party'],
|
||||
['111ebonMs90YLu', 'Thumbs up'],
|
||||
['5GoVLqeAOo6PK', 'Excited'],
|
||||
['TdfyKrN7HGTIY', 'Happy dance'],
|
||||
@@ -4657,7 +4657,7 @@ app.post('/api/:endpoint', async (request, response, next) => {
|
||||
if (endpoint === 'memos:devCapture') {
|
||||
loggedBody.audioDataUrl = `<${String(request.body.audioDataUrl ?? '').length} characters>`
|
||||
}
|
||||
console.log('[NUI]', endpoint, loggedBody)
|
||||
console.log(`[NUI] ${endpoint}`, loggedBody)
|
||||
if (endpoint === 'music:bootstrap') {
|
||||
response.json({ success: true, data: musicBootstrap() })
|
||||
return
|
||||
@@ -9648,13 +9648,13 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
const pageSize = 6
|
||||
const results = gifMocks
|
||||
.slice(offset, offset + pageSize)
|
||||
.map(([id, title, width, height]) => ({
|
||||
height: height ?? 200,
|
||||
.map(([id, title]) => ({
|
||||
height: 200,
|
||||
id,
|
||||
previewUrl: `https://media.giphy.com/media/${id}/200w.gif`,
|
||||
title,
|
||||
url: `https://media.giphy.com/media/${id}/giphy.gif`,
|
||||
width: width ?? 200,
|
||||
width: 200,
|
||||
}))
|
||||
response.json({
|
||||
success: true,
|
||||
|
||||
@@ -1177,18 +1177,6 @@ async function main() {
|
||||
'factory reset did not restore a browser-testable setup state',
|
||||
)
|
||||
|
||||
const loggedRequests = []
|
||||
const originalConsoleLog = console.log
|
||||
try {
|
||||
console.log = (...values) => loggedRequests.push(values)
|
||||
await post(baseUrl, '%25s', { marker: 'format-string' })
|
||||
} finally {
|
||||
console.log = originalConsoleLog
|
||||
}
|
||||
assert.deepEqual(loggedRequests, [
|
||||
['[NUI]', '%s', { marker: 'format-string' }],
|
||||
])
|
||||
|
||||
const unknown = await post(baseUrl, 'development:missing-mock', {})
|
||||
assert.deepEqual(unknown, {
|
||||
error: 'mock_endpoint_missing',
|
||||
|
||||
@@ -560,8 +560,6 @@ The resource provides the compatibility aliases `lb-phone`, `17mov_Phone`, `high
|
||||
|
||||
That means servers can replace LB Phone without giving up supported custom apps, while developers can build directly against Sky Phone for deeper lifecycle, permission, and storage integration.
|
||||
|
||||
Start Sky Phone before the custom app resources and do not start the original phone resource for an alias at the same time. For example, an unchanged app using `exports["lb-phone"]:AddCustomApp(...)` must run with `sky_phone`, not with the original `lb-phone`, as the active provider. Two active providers expose the same FiveM export event and can send registrations to the wrong phone.
|
||||
|
||||
## Frontend development
|
||||
|
||||
Customers installing a release do not need to build the frontend.
|
||||
|
||||
@@ -52,6 +52,9 @@ Config.CustomApps = {
|
||||
MaximumStorageKeyLength = 64, -- Bridge v1 ceiling; lower values tighten the server policy.
|
||||
MaximumStorageKeysPerApp = 128,
|
||||
StorageRequestsPerMinute = 120,
|
||||
AllowRemoteOrigins = {
|
||||
-- ["https://apps.example.com"] = true,
|
||||
},
|
||||
TrustedAdapters = {},
|
||||
}
|
||||
|
||||
|
||||
@@ -20,12 +20,7 @@ shared_scripts {
|
||||
'source/shared/imei.lua',
|
||||
'source/shared/sim_number.lua',
|
||||
'source/shared/custom_apps.lua',
|
||||
'source/bridge/phones/shared.lua',
|
||||
'source/bridge/phones/shared/lb.lua',
|
||||
'source/bridge/phones/shared/seventeen.lua',
|
||||
'source/bridge/phones/shared/high.lua',
|
||||
'source/bridge/phones/shared/quasar.lua',
|
||||
'source/bridge/phones/shared/yseries.lua',
|
||||
'source/shared/custom_app_compat.lua',
|
||||
}
|
||||
|
||||
client_scripts {
|
||||
@@ -39,11 +34,7 @@ client_scripts {
|
||||
'source/bridge/client/calls.lua',
|
||||
'source/client/animations.lua',
|
||||
'source/client/focus.lua',
|
||||
'source/client/calls.lua',
|
||||
'source/client/sim.lua',
|
||||
'source/client/camera.lua',
|
||||
'source/client/location.lua',
|
||||
'source/client/weather.lua',
|
||||
'source/client/garage.lua',
|
||||
'source/client/skyride.lua',
|
||||
'source/client/housing.lua',
|
||||
@@ -52,21 +43,9 @@ client_scripts {
|
||||
'source/bridge/client/radio.lua',
|
||||
'source/client/payphones.lua',
|
||||
'source/client/custom_apps.lua',
|
||||
'source/client/nui_server_bridge.lua',
|
||||
'source/client/nui_events.lua',
|
||||
'source/client/notifications.lua',
|
||||
'source/client/custom_app_compat.lua',
|
||||
'source/client/main.lua',
|
||||
'source/client/navigation.lua',
|
||||
'source/shared/public_api.lua',
|
||||
'source/client/public_api.lua',
|
||||
'source/client/radio.lua',
|
||||
'source/bridge/phones/client/core.lua',
|
||||
'source/bridge/phones/client/lb.lua',
|
||||
'source/bridge/phones/client/seventeen.lua',
|
||||
'source/bridge/phones/client/high.lua',
|
||||
'source/bridge/phones/client/quasar.lua',
|
||||
'source/bridge/phones/client/yseries.lua',
|
||||
'source/bridge/phones/client/lifecycle.lua',
|
||||
}
|
||||
|
||||
server_scripts {
|
||||
@@ -88,24 +67,18 @@ server_scripts {
|
||||
'source/bridge/server/inventory/*.lua',
|
||||
'source/bridge/server/voice.lua',
|
||||
'source/server/custom_apps.lua',
|
||||
'source/server/custom_app_compat.lua',
|
||||
'source/server/media_metadata.lua',
|
||||
'source/server/companies.lua',
|
||||
'source/server/sim.lua',
|
||||
'source/server/memos.lua',
|
||||
'source/server/notes.lua',
|
||||
'source/server/phone_security.lua',
|
||||
'source/server/phone_accounts.lua',
|
||||
'source/server/phone_persistence.lua',
|
||||
'source/server/phone.lua',
|
||||
'source/server/device_directory.lua',
|
||||
'source/server/db_migrate.lua',
|
||||
'source/server/lb_phone_migration.lua',
|
||||
'source/server/custom_app_storage.lua',
|
||||
'source/server/payphones.lua',
|
||||
'source/server/calls.lua',
|
||||
'source/server/notifications.lua',
|
||||
'source/shared/public_api.lua',
|
||||
'source/server/public_api.lua',
|
||||
'source/server/media_import.lua',
|
||||
'source/server/media_import/fivemanage.lua',
|
||||
'source/server/media_import/manifest.lua',
|
||||
@@ -135,14 +108,6 @@ server_scripts {
|
||||
'source/server/music.lua',
|
||||
'source/server/radio.lua',
|
||||
'source/server/testdata.lua',
|
||||
'source/server/lb_app_compat_migration.lua',
|
||||
'source/bridge/phones/server/core.lua',
|
||||
'source/bridge/phones/server/lb.lua',
|
||||
'source/bridge/phones/server/seventeen.lua',
|
||||
'source/bridge/phones/server/high.lua',
|
||||
'source/bridge/phones/server/quasar.lua',
|
||||
'source/bridge/phones/server/yseries.lua',
|
||||
'source/bridge/phones/server/lifecycle.lua',
|
||||
}
|
||||
|
||||
files {
|
||||
|
||||
@@ -1,175 +0,0 @@
|
||||
SkyPhoneCompatibilityClient = {}
|
||||
|
||||
local RESOURCE_NAME = GetCurrentResourceName()
|
||||
local providers = SkyPhoneCompatibility.Providers
|
||||
local compatibility_core = SkyPhoneApps.CompatibilityCore
|
||||
local debug_custom_app = SkyPhoneApps.Debug or function() end
|
||||
local provider_apps = {}
|
||||
|
||||
if type(providers) ~= "table" or type(compatibility_core) ~= "table" then
|
||||
error("[sky_phone] Phone compatibility client core initialized before its shared dependencies.")
|
||||
end
|
||||
if type(SkyPhoneClient) ~= "table"
|
||||
or type(SkyPhoneCalls) ~= "table"
|
||||
or type(SkyPhoneCamera) ~= "table"
|
||||
or type(SkyPhoneNotifications) ~= "table"
|
||||
then
|
||||
error("[sky_phone] Phone compatibility client core initialized before the neutral client modules.")
|
||||
end
|
||||
|
||||
SkyPhoneCompatibilityClient.ResourceName = RESOURCE_NAME
|
||||
SkyPhoneCompatibilityClient.Providers = providers
|
||||
SkyPhoneCompatibilityClient.Core = compatibility_core
|
||||
SkyPhoneCompatibilityClient.Phone = SkyPhoneClient
|
||||
SkyPhoneCompatibilityClient.Calls = SkyPhoneCalls
|
||||
SkyPhoneCompatibilityClient.Camera = SkyPhoneCamera
|
||||
SkyPhoneCompatibilityClient.Notifications = SkyPhoneNotifications
|
||||
SkyPhoneCompatibilityClient.Debug = debug_custom_app
|
||||
|
||||
function SkyPhoneCompatibilityClient.TrackProviderApp(provider, owner_resource, definition, vendor_data)
|
||||
provider_apps[definition.id] = {
|
||||
definition = definition,
|
||||
owner_resource = owner_resource,
|
||||
provider = provider,
|
||||
vendor_data = vendor_data,
|
||||
}
|
||||
end
|
||||
|
||||
function SkyPhoneCompatibilityClient.GetCallingResource(export_name)
|
||||
local owner_resource = GetInvokingResource()
|
||||
if owner_resource then
|
||||
return owner_resource
|
||||
end
|
||||
|
||||
Bridge.Debug(
|
||||
"warn",
|
||||
"[%s] %s rejected: the export must be called by another resource.",
|
||||
RESOURCE_NAME,
|
||||
export_name
|
||||
)
|
||||
return nil, "invalid_owner"
|
||||
end
|
||||
|
||||
function SkyPhoneCompatibilityClient.CopyRecordData(value)
|
||||
if type(value) ~= "table" then
|
||||
return nil
|
||||
end
|
||||
|
||||
local copied = {}
|
||||
for key, nested_value in pairs(value) do
|
||||
copied[key] = nested_value
|
||||
end
|
||||
return copied
|
||||
end
|
||||
|
||||
function SkyPhoneCompatibilityClient.RegisterProviderApp(provider, owner_resource, definition, vendor_data)
|
||||
local app_id = definition.id
|
||||
local existing = provider_apps[app_id]
|
||||
debug_custom_app(
|
||||
"provider",
|
||||
"registration requested provider=%s id=%s owner=%s operation=%s",
|
||||
tostring(provider),
|
||||
tostring(app_id),
|
||||
tostring(owner_resource),
|
||||
existing and "update" or "add"
|
||||
)
|
||||
if existing and (existing.owner_resource ~= owner_resource or existing.provider ~= provider) then
|
||||
debug_custom_app(
|
||||
"provider",
|
||||
"registration rejected provider=%s id=%s owner=%s existing_provider=%s existing_owner=%s error=duplicate_app_id",
|
||||
tostring(provider),
|
||||
tostring(app_id),
|
||||
tostring(owner_resource),
|
||||
tostring(existing.provider),
|
||||
tostring(existing.owner_resource)
|
||||
)
|
||||
return false, "duplicate_app_id"
|
||||
end
|
||||
|
||||
local success, error_message
|
||||
if existing then
|
||||
success, error_message = compatibility_core.Update(owner_resource, definition)
|
||||
else
|
||||
success, error_message = compatibility_core.Add(owner_resource, definition)
|
||||
end
|
||||
if success then
|
||||
SkyPhoneCompatibilityClient.TrackProviderApp(provider, owner_resource, definition, vendor_data)
|
||||
end
|
||||
debug_custom_app(
|
||||
"provider",
|
||||
"registration result provider=%s id=%s owner=%s success=%s error=%s",
|
||||
tostring(provider),
|
||||
tostring(app_id),
|
||||
tostring(owner_resource),
|
||||
tostring(success),
|
||||
tostring(error_message)
|
||||
)
|
||||
return success, error_message
|
||||
end
|
||||
|
||||
function SkyPhoneCompatibilityClient.GetProviderApp(owner_resource, app_id, allowed_providers)
|
||||
if type(app_id) ~= "string" then
|
||||
return nil, "invalid_app_id"
|
||||
end
|
||||
|
||||
local record = provider_apps[app_id]
|
||||
if not record then
|
||||
return nil, "app_not_found"
|
||||
end
|
||||
if record.owner_resource ~= owner_resource then
|
||||
return nil, "app_owner_mismatch"
|
||||
end
|
||||
if allowed_providers and not allowed_providers[record.provider] then
|
||||
return nil, "app_provider_mismatch"
|
||||
end
|
||||
return record
|
||||
end
|
||||
|
||||
function SkyPhoneCompatibilityClient.FindProviderApp(app_id)
|
||||
return provider_apps[app_id]
|
||||
end
|
||||
|
||||
function SkyPhoneCompatibilityClient.GetProviderApps(provider)
|
||||
local app_ids = {}
|
||||
for app_id, record in pairs(provider_apps) do
|
||||
if record.provider == provider then
|
||||
app_ids[#app_ids + 1] = app_id
|
||||
end
|
||||
end
|
||||
table.sort(app_ids)
|
||||
|
||||
local records = {}
|
||||
for index = 1, #app_ids do
|
||||
records[index] = provider_apps[app_ids[index]]
|
||||
end
|
||||
return records
|
||||
end
|
||||
|
||||
function SkyPhoneCompatibilityClient.RemoveProviderApp(owner_resource, app_id, allowed_providers)
|
||||
local record, record_error = SkyPhoneCompatibilityClient.GetProviderApp(
|
||||
owner_resource,
|
||||
app_id,
|
||||
allowed_providers
|
||||
)
|
||||
if not record then
|
||||
return false, record_error
|
||||
end
|
||||
|
||||
return compatibility_core.Remove(owner_resource, app_id)
|
||||
end
|
||||
|
||||
function SkyPhoneCompatibilityClient.FormatNumber(phone_number)
|
||||
return SkyPhoneSimNumber.Format(
|
||||
phone_number,
|
||||
Config.Sim.NumberGroups,
|
||||
Config.Sim.NumberLength,
|
||||
Config.Sim.NumberPrefix
|
||||
)
|
||||
end
|
||||
|
||||
AddEventHandler("sky_phone:client:customAppRemoved", function(owner_resource, app_id)
|
||||
local record = provider_apps[app_id]
|
||||
if record and record.owner_resource == owner_resource then
|
||||
provider_apps[app_id] = nil
|
||||
end
|
||||
end)
|
||||
@@ -1,340 +0,0 @@
|
||||
local client_bridge = SkyPhoneCompatibilityClient
|
||||
local RESOURCE_NAME = client_bridge.ResourceName
|
||||
local providers = client_bridge.Providers
|
||||
local compatibility_core = client_bridge.Core
|
||||
local phone = client_bridge.Phone
|
||||
local calls = client_bridge.Calls
|
||||
local notifications = client_bridge.Notifications
|
||||
local camera = client_bridge.Camera
|
||||
local high_client_apps = {}
|
||||
local high_server_apps = {}
|
||||
|
||||
local function add_application(app_name, data, locales)
|
||||
local owner_resource, owner_error = client_bridge.GetCallingResource("addApplication")
|
||||
if not owner_resource then
|
||||
return false, owner_error
|
||||
end
|
||||
|
||||
local definition, definition_error = SkyPhoneCompatibility.BuildHighDefinition(
|
||||
owner_resource,
|
||||
app_name,
|
||||
data,
|
||||
locales
|
||||
)
|
||||
if not definition then
|
||||
Bridge.Debug(
|
||||
"warn",
|
||||
"[%s] High Phone registration rejected for %s: %s.",
|
||||
RESOURCE_NAME,
|
||||
owner_resource,
|
||||
definition_error
|
||||
)
|
||||
return false, definition_error
|
||||
end
|
||||
|
||||
local server_record = high_server_apps[app_name]
|
||||
if server_record and server_record.owner_resource ~= owner_resource then
|
||||
return false, "duplicate_app_id"
|
||||
end
|
||||
|
||||
if server_record then
|
||||
if server_record.registered then
|
||||
high_client_apps[app_name] = {
|
||||
definition = definition,
|
||||
owner_resource = owner_resource,
|
||||
}
|
||||
return true
|
||||
end
|
||||
return false, "server_definition_not_registered"
|
||||
end
|
||||
|
||||
local success, register_error = client_bridge.RegisterProviderApp(
|
||||
providers.high,
|
||||
owner_resource,
|
||||
definition,
|
||||
client_bridge.CopyRecordData(data)
|
||||
)
|
||||
if success then
|
||||
high_client_apps[app_name] = {
|
||||
definition = definition,
|
||||
owner_resource = owner_resource,
|
||||
}
|
||||
end
|
||||
return success, register_error
|
||||
end
|
||||
|
||||
local function send_app_nui(app_name, data)
|
||||
local owner_resource, owner_error = client_bridge.GetCallingResource("sendAppNui")
|
||||
if not owner_resource then
|
||||
return false, owner_error
|
||||
end
|
||||
|
||||
local record, record_error = client_bridge.GetProviderApp(owner_resource, app_name, {
|
||||
[providers.high] = true,
|
||||
})
|
||||
if not record then
|
||||
return false, record_error
|
||||
end
|
||||
return compatibility_core.SendMessage(owner_resource, app_name, data)
|
||||
end
|
||||
|
||||
local function register_server_app(owner_resource, definition, revision)
|
||||
if type(owner_resource) ~= "string"
|
||||
or type(definition) ~= "table"
|
||||
or type(definition.id) ~= "string"
|
||||
or type(revision) ~= "number"
|
||||
or revision ~= math.floor(revision)
|
||||
or revision < 1
|
||||
then
|
||||
Bridge.Debug("warn", "[%s] Rejected invalid High Phone server application snapshot.", RESOURCE_NAME)
|
||||
return nil
|
||||
end
|
||||
|
||||
local app_id = definition.id
|
||||
local existing = high_server_apps[app_id]
|
||||
if existing and existing.owner_resource ~= owner_resource then
|
||||
Bridge.Debug("warn", "[%s] Rejected conflicting High Phone owner for %s.", RESOURCE_NAME, app_id)
|
||||
return app_id
|
||||
end
|
||||
if existing and revision <= existing.revision then
|
||||
if revision < existing.revision or existing.registered then
|
||||
return app_id
|
||||
end
|
||||
end
|
||||
|
||||
local provider_record = client_bridge.FindProviderApp(app_id)
|
||||
local success, error_message
|
||||
if provider_record then
|
||||
if provider_record.owner_resource ~= owner_resource or provider_record.provider ~= providers.high then
|
||||
success, error_message = false, "duplicate_app_id"
|
||||
else
|
||||
success, error_message = compatibility_core.UpdateServerAuthorized(
|
||||
owner_resource,
|
||||
providers.high,
|
||||
definition
|
||||
)
|
||||
end
|
||||
else
|
||||
success, error_message = compatibility_core.AddServerAuthorized(
|
||||
owner_resource,
|
||||
providers.high,
|
||||
definition
|
||||
)
|
||||
end
|
||||
|
||||
high_server_apps[app_id] = {
|
||||
definition = definition,
|
||||
last_error = success and nil or error_message,
|
||||
owner_resource = owner_resource,
|
||||
registered = success == true,
|
||||
revision = revision,
|
||||
}
|
||||
if success then
|
||||
client_bridge.TrackProviderApp(providers.high, owner_resource, definition, nil)
|
||||
elseif not existing or existing.revision ~= revision or existing.last_error ~= error_message then
|
||||
Bridge.Debug(
|
||||
"error",
|
||||
"[%s] Could not register High Phone server application %s revision %s: %s.",
|
||||
RESOURCE_NAME,
|
||||
app_id,
|
||||
revision,
|
||||
error_message or "unknown_error"
|
||||
)
|
||||
end
|
||||
return app_id
|
||||
end
|
||||
|
||||
local function remove_server_app(owner_resource, app_id)
|
||||
local record = high_server_apps[app_id]
|
||||
if not record or record.owner_resource ~= owner_resource then
|
||||
return
|
||||
end
|
||||
|
||||
high_server_apps[app_id] = nil
|
||||
if record.registered then
|
||||
compatibility_core.RemoveServerAuthorized(owner_resource, providers.high, app_id)
|
||||
end
|
||||
|
||||
local client_record = high_client_apps[app_id]
|
||||
if client_record then
|
||||
local provider_record = client_bridge.FindProviderApp(app_id)
|
||||
if provider_record
|
||||
and provider_record.owner_resource == client_record.owner_resource
|
||||
and provider_record.provider == providers.high
|
||||
then
|
||||
return
|
||||
end
|
||||
local success, error_message = client_bridge.RegisterProviderApp(
|
||||
providers.high,
|
||||
client_record.owner_resource,
|
||||
client_record.definition,
|
||||
nil
|
||||
)
|
||||
if not success then
|
||||
Bridge.Debug(
|
||||
"error",
|
||||
"[%s] Could not restore High Phone client application %s: %s.",
|
||||
RESOURCE_NAME,
|
||||
app_id,
|
||||
error_message or "unknown_error"
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function close_phone()
|
||||
phone.Toggle(false)
|
||||
end
|
||||
|
||||
local function start_call(phone_number, video)
|
||||
if type(phone_number) ~= "string" or not phone_number:match("%S") then
|
||||
Bridge.Debug("error", "[%s] Rejected invalid High Phone call target.", RESOURCE_NAME)
|
||||
return
|
||||
end
|
||||
if video ~= nil and type(video) ~= "boolean" then
|
||||
Bridge.Debug("error", "[%s] Rejected invalid High Phone video-call state.", RESOURCE_NAME)
|
||||
return
|
||||
end
|
||||
if video then
|
||||
Bridge.Debug("warn", "[%s] High Phone video calls are not supported.", RESOURCE_NAME)
|
||||
return
|
||||
end
|
||||
|
||||
local success, error_message = calls.Dial(phone_number)
|
||||
if not success then
|
||||
Bridge.Debug(
|
||||
"warn",
|
||||
"[%s] High Phone audio call could not be started: %s.",
|
||||
RESOURCE_NAME,
|
||||
tostring(error_message or "request_failed")
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
local function end_call()
|
||||
calls.Terminate()
|
||||
end
|
||||
|
||||
local function send_notification(notification)
|
||||
local mapped, map_error = SkyPhoneCompatibility.MapHighNotification(notification)
|
||||
if not mapped then
|
||||
Bridge.Debug(
|
||||
"warn",
|
||||
"[%s] Rejected unsupported High Phone notification: %s.",
|
||||
RESOURCE_NAME,
|
||||
tostring(map_error)
|
||||
)
|
||||
return
|
||||
end
|
||||
|
||||
local success, notification_error = notifications.Send(mapped)
|
||||
if not success then
|
||||
Bridge.Debug(
|
||||
"warn",
|
||||
"[%s] Rejected High Phone notification: %s.",
|
||||
RESOURCE_NAME,
|
||||
tostring(notification_error)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
local function set_camera_facing(facing)
|
||||
if facing ~= "front" and facing ~= "rear" then
|
||||
Bridge.Debug("error", "[%s] Rejected invalid High Phone camera facing.", RESOURCE_NAME)
|
||||
return
|
||||
end
|
||||
camera.SetSelfie(facing == "front")
|
||||
end
|
||||
|
||||
local function use_phone_item()
|
||||
phone.Toggle(true)
|
||||
end
|
||||
|
||||
RegisterNetEvent("sky_phone:compat:high:client:syncApplication", function(owner_resource, definition, revision)
|
||||
if source ~= 65535 then
|
||||
Bridge.Debug("warn", "[%s] Rejected locally invoked High Phone application sync.", RESOURCE_NAME)
|
||||
return
|
||||
end
|
||||
register_server_app(owner_resource, definition, revision)
|
||||
end)
|
||||
|
||||
RegisterNetEvent("sky_phone:compat:high:client:removeApplication", function(owner_resource, app_id)
|
||||
if source ~= 65535 then
|
||||
Bridge.Debug("warn", "[%s] Rejected locally invoked High Phone application removal.", RESOURCE_NAME)
|
||||
return
|
||||
end
|
||||
if type(owner_resource) ~= "string" or type(app_id) ~= "string" then
|
||||
Bridge.Debug("warn", "[%s] Rejected invalid High Phone application removal.", RESOURCE_NAME)
|
||||
return
|
||||
end
|
||||
remove_server_app(owner_resource, app_id)
|
||||
end)
|
||||
|
||||
RegisterNetEvent("sky_phone:compat:high:client:replaceSnapshot", function(snapshot)
|
||||
if source ~= 65535 then
|
||||
Bridge.Debug("warn", "[%s] Rejected locally invoked High Phone application snapshot.", RESOURCE_NAME)
|
||||
return
|
||||
end
|
||||
if type(snapshot) ~= "table" then
|
||||
Bridge.Debug("warn", "[%s] Rejected invalid High Phone application snapshot.", RESOURCE_NAME)
|
||||
return
|
||||
end
|
||||
|
||||
local seen = {}
|
||||
for index = 1, #snapshot do
|
||||
local record = snapshot[index]
|
||||
if type(record) == "table" then
|
||||
local app_id = register_server_app(record.owner_resource, record.definition, record.revision)
|
||||
if app_id then
|
||||
seen[app_id] = true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local removed = {}
|
||||
for app_id, record in pairs(high_server_apps) do
|
||||
if not seen[app_id] then
|
||||
removed[#removed + 1] = {
|
||||
app_id = app_id,
|
||||
owner_resource = record.owner_resource,
|
||||
}
|
||||
end
|
||||
end
|
||||
for index = 1, #removed do
|
||||
remove_server_app(removed[index].owner_resource, removed[index].app_id)
|
||||
end
|
||||
end)
|
||||
|
||||
AddEventHandler("sky_phone:client:customAppRemoved", function(owner_resource, app_id)
|
||||
local record = high_server_apps[app_id]
|
||||
if record and record.owner_resource == owner_resource then
|
||||
record.registered = false
|
||||
record.last_error = "registration_removed"
|
||||
end
|
||||
end)
|
||||
|
||||
AddEventHandler("onClientResourceStart", function(resource_name)
|
||||
for _, record in pairs(high_server_apps) do
|
||||
if record.owner_resource == resource_name and not record.registered then
|
||||
register_server_app(record.owner_resource, record.definition, record.revision)
|
||||
end
|
||||
end
|
||||
end)
|
||||
|
||||
AddEventHandler("onClientResourceStop", function(resource_name)
|
||||
for app_id, record in pairs(high_client_apps) do
|
||||
if record.owner_resource == resource_name then
|
||||
high_client_apps[app_id] = nil
|
||||
end
|
||||
end
|
||||
end)
|
||||
|
||||
SkyPhoneCompatibility.RegisterExportAlias("high-phone", "addApplication", add_application)
|
||||
SkyPhoneCompatibility.RegisterExportAlias("high-phone", "sendAppNui", send_app_nui)
|
||||
SkyPhoneCompatibility.RegisterExportAlias("high-phone", "closePhone", close_phone)
|
||||
SkyPhoneCompatibility.RegisterExportAlias("high-phone", "startCall", start_call)
|
||||
SkyPhoneCompatibility.RegisterExportAlias("high-phone", "endCall", end_call)
|
||||
SkyPhoneCompatibility.RegisterExportAlias("high-phone", "sendNotification", send_notification)
|
||||
SkyPhoneCompatibility.RegisterExportAlias("high-phone", "setCameraFacing", set_camera_facing)
|
||||
SkyPhoneCompatibility.RegisterExportAlias("high-phone", "formatNumber", client_bridge.FormatNumber)
|
||||
SkyPhoneCompatibility.RegisterExportAlias("high-phone", "usePhoneItem", use_phone_item)
|
||||
@@ -1,263 +0,0 @@
|
||||
local client_bridge = SkyPhoneCompatibilityClient
|
||||
local RESOURCE_NAME = client_bridge.ResourceName
|
||||
local providers = client_bridge.Providers
|
||||
local compatibility_core = client_bridge.Core
|
||||
local phone = client_bridge.Phone
|
||||
local calls = client_bridge.Calls
|
||||
local notifications = client_bridge.Notifications
|
||||
local camera = client_bridge.Camera
|
||||
local debug_custom_app = client_bridge.Debug
|
||||
|
||||
local function add_custom_app(app_data)
|
||||
local owner_resource, owner_error = client_bridge.GetCallingResource("AddCustomApp")
|
||||
if not owner_resource then
|
||||
return false, owner_error
|
||||
end
|
||||
if type(app_data) ~= "table" or app_data.identifier == nil then
|
||||
return false, "invalid_definition"
|
||||
end
|
||||
|
||||
debug_custom_app(
|
||||
"provider",
|
||||
"compatibility export received owner=%s identifier=%s data_type=%s",
|
||||
tostring(owner_resource),
|
||||
tostring(app_data.identifier),
|
||||
tostring(type(app_data))
|
||||
)
|
||||
local definition, definition_error = SkyPhoneCompatibility.BuildLbDefinition(owner_resource, app_data)
|
||||
if not definition then
|
||||
debug_custom_app(
|
||||
"provider",
|
||||
"definition rejected provider=%s owner=%s error=%s",
|
||||
tostring(providers.lb),
|
||||
tostring(owner_resource),
|
||||
tostring(definition_error)
|
||||
)
|
||||
Bridge.Debug(
|
||||
"warn",
|
||||
"[%s] %s registration rejected for %s: %s.",
|
||||
RESOURCE_NAME,
|
||||
providers.lb,
|
||||
owner_resource,
|
||||
definition_error
|
||||
)
|
||||
return false, definition_error
|
||||
end
|
||||
|
||||
return client_bridge.RegisterProviderApp(
|
||||
providers.lb,
|
||||
owner_resource,
|
||||
definition,
|
||||
client_bridge.CopyRecordData(app_data)
|
||||
)
|
||||
end
|
||||
|
||||
local function remove_custom_app(app_id)
|
||||
local owner_resource, owner_error = client_bridge.GetCallingResource("RemoveCustomApp")
|
||||
if not owner_resource then
|
||||
return false, owner_error
|
||||
end
|
||||
return client_bridge.RemoveProviderApp(owner_resource, app_id, {
|
||||
[providers.lb] = true,
|
||||
})
|
||||
end
|
||||
|
||||
local function send_custom_app_message(app_id, data)
|
||||
local owner_resource, owner_error = client_bridge.GetCallingResource("SendCustomAppMessage")
|
||||
if not owner_resource then
|
||||
return false, owner_error
|
||||
end
|
||||
local record, record_error = client_bridge.GetProviderApp(owner_resource, app_id, {
|
||||
[providers.lb] = true,
|
||||
})
|
||||
if not record then
|
||||
return false, record_error
|
||||
end
|
||||
return compatibility_core.SendMessage(owner_resource, app_id, data)
|
||||
end
|
||||
|
||||
local function open_app(app_id, data)
|
||||
local owner_resource, owner_error = client_bridge.GetCallingResource("OpenApp")
|
||||
if not owner_resource then
|
||||
return false, owner_error
|
||||
end
|
||||
|
||||
local record, record_error = client_bridge.GetProviderApp(owner_resource, app_id, {
|
||||
[providers.lb] = true,
|
||||
})
|
||||
if not record then
|
||||
return false, record_error
|
||||
end
|
||||
return compatibility_core.Open(owner_resource, app_id, data)
|
||||
end
|
||||
|
||||
local function close_app(options)
|
||||
local owner_resource, owner_error = client_bridge.GetCallingResource("CloseApp")
|
||||
if not owner_resource then
|
||||
return false, owner_error
|
||||
end
|
||||
|
||||
local app_id = type(options) == "table" and options.app or nil
|
||||
if app_id then
|
||||
local record, record_error = client_bridge.GetProviderApp(owner_resource, app_id, {
|
||||
[providers.lb] = true,
|
||||
})
|
||||
if not record then
|
||||
return false, record_error
|
||||
end
|
||||
return compatibility_core.Close(owner_resource, app_id)
|
||||
end
|
||||
return compatibility_core.CloseActive(owner_resource)
|
||||
end
|
||||
|
||||
local function normalize_notification_text(value, maximum_length, error_code)
|
||||
if type(value) ~= "string" then
|
||||
return nil, error_code
|
||||
end
|
||||
|
||||
local normalized = value:match("^%s*(.-)%s*$")
|
||||
if normalized == "" or #normalized > maximum_length then
|
||||
return nil, error_code
|
||||
end
|
||||
return normalized
|
||||
end
|
||||
|
||||
local function send_notification(data)
|
||||
local owner_resource, owner_error = client_bridge.GetCallingResource("SendNotification")
|
||||
if not owner_resource then
|
||||
return false, owner_error
|
||||
end
|
||||
if type(data) ~= "table" then
|
||||
return false, "invalid_notification"
|
||||
end
|
||||
|
||||
local app_id = data.app or data.identifier
|
||||
local valid_app_id, app_id_error = SkyPhoneApps.ValidateAppId(app_id)
|
||||
if not valid_app_id then
|
||||
return false, app_id_error
|
||||
end
|
||||
|
||||
local registered_record = client_bridge.FindProviderApp(app_id)
|
||||
if registered_record then
|
||||
local record, record_error = client_bridge.GetProviderApp(owner_resource, app_id, {
|
||||
[providers.lb] = true,
|
||||
})
|
||||
if not record then
|
||||
return false, record_error
|
||||
end
|
||||
elseif not SkyPhoneApps.ReservedAppIds[app_id] then
|
||||
return false, "app_not_found"
|
||||
end
|
||||
|
||||
local title, title_error = normalize_notification_text(
|
||||
data.title,
|
||||
128,
|
||||
"invalid_notification_title"
|
||||
)
|
||||
if not title then
|
||||
return false, title_error
|
||||
end
|
||||
|
||||
local content, content_error = normalize_notification_text(
|
||||
data.content or data.message or data.text,
|
||||
512,
|
||||
"invalid_notification_text"
|
||||
)
|
||||
if not content then
|
||||
return false, content_error
|
||||
end
|
||||
|
||||
return notifications.Send({
|
||||
appId = app_id,
|
||||
text = content,
|
||||
title = title,
|
||||
})
|
||||
end
|
||||
|
||||
local function create_call(options)
|
||||
if type(options) ~= "table"
|
||||
or (type(options.number) ~= "string" and type(options.company) ~= "string")
|
||||
then
|
||||
Bridge.Debug("error", "[%s] Rejected invalid LB Phone CreateCall options.", RESOURCE_NAME)
|
||||
return false, "invalid_request"
|
||||
end
|
||||
return calls.Dial(options.number, options.company)
|
||||
end
|
||||
|
||||
local function create_sms(options)
|
||||
local phone_number = type(options) == "table" and (options.number or options.phoneNumber) or options
|
||||
if type(phone_number) ~= "string" or phone_number == "" then
|
||||
Bridge.Debug("error", "[%s] Rejected invalid LB Phone CreateSMS target.", RESOURCE_NAME)
|
||||
return false, "invalid_number"
|
||||
end
|
||||
|
||||
SendNUIMessage({
|
||||
type = "compat:open-messages",
|
||||
data = { phoneNumber = phone_number },
|
||||
})
|
||||
return true
|
||||
end
|
||||
|
||||
local function get_phone_state_value(key)
|
||||
return phone.GetState()[key]
|
||||
end
|
||||
|
||||
local function get_camera_state_value(key)
|
||||
return camera.GetState()[key]
|
||||
end
|
||||
|
||||
SkyPhoneCompatibility.RegisterExportAlias("lb-phone", "AddCustomApp", add_custom_app)
|
||||
SkyPhoneCompatibility.RegisterExportAlias("lb-phone", "RemoveCustomApp", remove_custom_app)
|
||||
SkyPhoneCompatibility.RegisterExportAlias("lb-phone", "SendCustomAppMessage", send_custom_app_message)
|
||||
SkyPhoneCompatibility.RegisterExportAlias("lb-phone", "OpenApp", open_app)
|
||||
SkyPhoneCompatibility.RegisterExportAlias("lb-phone", "CloseApp", close_app)
|
||||
SkyPhoneCompatibility.RegisterExportAlias("lb-phone", "SendNotification", send_notification)
|
||||
SkyPhoneCompatibility.RegisterExportAlias("lb-phone", "ToggleOpen", function(open, no_focus)
|
||||
return phone.Toggle(open, no_focus)
|
||||
end)
|
||||
SkyPhoneCompatibility.RegisterExportAlias("lb-phone", "FormatNumber", client_bridge.FormatNumber)
|
||||
SkyPhoneCompatibility.RegisterExportAlias(
|
||||
"lb-phone",
|
||||
"GetEquippedPhoneNumber",
|
||||
phone.GetEquippedPhoneNumber
|
||||
)
|
||||
SkyPhoneCompatibility.RegisterExportAlias("lb-phone", "IsOpen", function()
|
||||
return get_phone_state_value("open")
|
||||
end)
|
||||
SkyPhoneCompatibility.RegisterExportAlias("lb-phone", "IsPhoneOnScreen", function()
|
||||
return get_phone_state_value("onScreen")
|
||||
end)
|
||||
SkyPhoneCompatibility.RegisterExportAlias("lb-phone", "IsInCall", function()
|
||||
return get_phone_state_value("inCall")
|
||||
end)
|
||||
SkyPhoneCompatibility.RegisterExportAlias("lb-phone", "CreateCall", create_call)
|
||||
SkyPhoneCompatibility.RegisterExportAlias("lb-phone", "CreateSMS", create_sms)
|
||||
SkyPhoneCompatibility.RegisterExportAlias("lb-phone", "EnableWalkableCam", camera.EnableWalkable)
|
||||
SkyPhoneCompatibility.RegisterExportAlias("lb-phone", "DisableWalkableCam", camera.DisableWalkable)
|
||||
SkyPhoneCompatibility.RegisterExportAlias("lb-phone", "ToggleSelfieCam", camera.SetSelfie)
|
||||
SkyPhoneCompatibility.RegisterExportAlias("lb-phone", "ToggleCameraFrozen", camera.ToggleFrozen)
|
||||
SkyPhoneCompatibility.RegisterExportAlias("lb-phone", "ToggleFlashlight", camera.SetFlashlight)
|
||||
SkyPhoneCompatibility.RegisterExportAlias("lb-phone", "GetFlashlight", function()
|
||||
return get_camera_state_value("flashEnabled")
|
||||
end)
|
||||
SkyPhoneCompatibility.RegisterExportAlias("lb-phone", "IsWalkingCamEnabled", function()
|
||||
return get_camera_state_value("walkable")
|
||||
end)
|
||||
SkyPhoneCompatibility.RegisterExportAlias("lb-phone", "IsSelfieCam", function()
|
||||
return get_camera_state_value("selfie")
|
||||
end)
|
||||
SkyPhoneCompatibility.RegisterExportAlias("lb-phone", "IsCameraOpen", function()
|
||||
return get_camera_state_value("active")
|
||||
end)
|
||||
|
||||
AddEventHandler("sky_phone:client:phoneNumberChanged", function(phone_number)
|
||||
TriggerEvent("lb-phone:numberChanged", phone_number)
|
||||
end)
|
||||
|
||||
AddEventHandler("sky_phone:client:phoneToggled", function(open)
|
||||
TriggerEvent("lb-phone:phoneToggled", open)
|
||||
end)
|
||||
|
||||
AddEventHandler("sky_phone:client:cameraActiveChanged", function(active)
|
||||
TriggerEvent("lb-phone:toggleHud", active)
|
||||
end)
|
||||
@@ -1,81 +0,0 @@
|
||||
local client_bridge = SkyPhoneCompatibilityClient
|
||||
local RESOURCE_NAME = client_bridge.ResourceName
|
||||
local debug_custom_app = client_bridge.Debug
|
||||
local provider_resources = {
|
||||
"lb-phone",
|
||||
"17mov_Phone",
|
||||
"high-phone",
|
||||
"qs-smartphone",
|
||||
"yseries",
|
||||
}
|
||||
local provider_registration_exports = {
|
||||
{ resource = "lb-phone", export = "AddCustomApp" },
|
||||
{ resource = "17mov_Phone", export = "AddApplication" },
|
||||
{ resource = "high-phone", export = "addApplication" },
|
||||
{ resource = "qs-smartphone", export = "addCustomApp" },
|
||||
{ resource = "yseries", export = "AddCustomApp" },
|
||||
}
|
||||
|
||||
local function emit_provider_stop_signals(reason)
|
||||
for index = 1, #provider_resources do
|
||||
local provider_resource = provider_resources[index]
|
||||
debug_custom_app(
|
||||
"provider",
|
||||
"emitting stop compatibility signals for %s reason=%s",
|
||||
provider_resource,
|
||||
reason
|
||||
)
|
||||
TriggerEvent("onClientResourceStop", provider_resource)
|
||||
TriggerEvent("onResourceStop", provider_resource)
|
||||
end
|
||||
end
|
||||
|
||||
local function report_provider_export_collisions()
|
||||
for index = 1, #provider_registration_exports do
|
||||
local provider_export = provider_registration_exports[index]
|
||||
local provider_count = 0
|
||||
TriggerEvent(
|
||||
("__cfx_export_%s_%s"):format(provider_export.resource, provider_export.export),
|
||||
function()
|
||||
provider_count = provider_count + 1
|
||||
end
|
||||
)
|
||||
|
||||
if provider_count > 1 then
|
||||
Bridge.Debug(
|
||||
"error",
|
||||
"[%s] Compatibility collision: %s:%s has %s providers. Stop the original phone resource; otherwise custom apps can register in the wrong phone.",
|
||||
RESOURCE_NAME,
|
||||
provider_export.resource,
|
||||
provider_export.export,
|
||||
provider_count
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
AddEventHandler("onResourceStop", function(resource_name)
|
||||
if resource_name ~= RESOURCE_NAME then
|
||||
return
|
||||
end
|
||||
|
||||
emit_provider_stop_signals("resource_stop")
|
||||
end)
|
||||
|
||||
CreateThread(function()
|
||||
report_provider_export_collisions()
|
||||
emit_provider_stop_signals("startup_reset")
|
||||
debug_custom_app("provider", "requesting provider snapshots and emitting compatibility ready signals")
|
||||
TriggerServerEvent("sky_phone:compat:high:server:requestSnapshot")
|
||||
TriggerEvent("17mov_Phone:Client:Ready")
|
||||
|
||||
for index = 1, #provider_resources do
|
||||
debug_custom_app(
|
||||
"provider",
|
||||
"emitting onResourceStart compatibility signal for %s (state=%s)",
|
||||
provider_resources[index],
|
||||
tostring(GetResourceState(provider_resources[index]))
|
||||
)
|
||||
TriggerEvent("onResourceStart", provider_resources[index])
|
||||
end
|
||||
end)
|
||||
@@ -1,177 +0,0 @@
|
||||
local client_bridge = SkyPhoneCompatibilityClient
|
||||
local RESOURCE_NAME = client_bridge.ResourceName
|
||||
local providers = client_bridge.Providers
|
||||
local compatibility_core = client_bridge.Core
|
||||
local phone = client_bridge.Phone
|
||||
local calls = client_bridge.Calls
|
||||
local navigation = SkyPhoneNavigation
|
||||
|
||||
local function add_app_for_owner(owner_resource, app_data)
|
||||
local definition, definition_error = SkyPhoneCompatibility.BuildQuasarDefinition(app_data)
|
||||
if not definition then
|
||||
Bridge.Debug(
|
||||
"warn",
|
||||
"[%s] Quasar registration rejected for %s: %s.",
|
||||
RESOURCE_NAME,
|
||||
owner_resource,
|
||||
definition_error
|
||||
)
|
||||
return false, definition_error
|
||||
end
|
||||
|
||||
return client_bridge.RegisterProviderApp(
|
||||
providers.quasar,
|
||||
owner_resource,
|
||||
definition,
|
||||
SkyPhoneCompatibility.CopyQuasarData(app_data)
|
||||
)
|
||||
end
|
||||
|
||||
local function add_custom_app(app_data)
|
||||
local owner_resource, owner_error = client_bridge.GetCallingResource("addCustomApp")
|
||||
if not owner_resource then
|
||||
return false, owner_error
|
||||
end
|
||||
if type(app_data) ~= "table" then
|
||||
return false, "invalid_app_data"
|
||||
end
|
||||
return add_app_for_owner(owner_resource, app_data)
|
||||
end
|
||||
|
||||
local function add_custom_apps_batch(apps)
|
||||
local owner_resource, owner_error = client_bridge.GetCallingResource("addCustomAppsBatch")
|
||||
if not owner_resource then
|
||||
return false, owner_error
|
||||
end
|
||||
if type(apps) ~= "table" then
|
||||
return false, "invalid_app_batch"
|
||||
end
|
||||
|
||||
for index = 1, #apps do
|
||||
local success, error_message = add_app_for_owner(owner_resource, apps[index])
|
||||
if not success then
|
||||
return false, error_message
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
local function update_custom_app(app_id, patch)
|
||||
local owner_resource, owner_error = client_bridge.GetCallingResource("updateCustomApp")
|
||||
if not owner_resource then
|
||||
return false, owner_error
|
||||
end
|
||||
if type(patch) ~= "table" then
|
||||
return false, "invalid_patch"
|
||||
end
|
||||
|
||||
local record, record_error = client_bridge.GetProviderApp(owner_resource, app_id, {
|
||||
[providers.quasar] = true,
|
||||
})
|
||||
if not record then
|
||||
return false, record_error
|
||||
end
|
||||
|
||||
local merged = SkyPhoneCompatibility.CopyQuasarData(record.vendor_data)
|
||||
for key, value in pairs(patch) do
|
||||
if key == "iframe" and type(value) == "table" then
|
||||
merged.iframe = merged.iframe or {}
|
||||
for iframe_key, iframe_value in pairs(value) do
|
||||
merged.iframe[iframe_key] = iframe_value
|
||||
end
|
||||
elseif key ~= "id" then
|
||||
merged[key] = value
|
||||
end
|
||||
end
|
||||
merged.id = app_id
|
||||
|
||||
local definition, definition_error = SkyPhoneCompatibility.BuildQuasarDefinition(merged)
|
||||
if not definition then
|
||||
return false, definition_error
|
||||
end
|
||||
|
||||
local success, error_message = compatibility_core.Update(owner_resource, definition)
|
||||
if success then
|
||||
record.definition = definition
|
||||
record.vendor_data = SkyPhoneCompatibility.CopyQuasarData(merged)
|
||||
end
|
||||
return success, error_message
|
||||
end
|
||||
|
||||
local function remove_custom_app(app_id)
|
||||
local owner_resource, owner_error = client_bridge.GetCallingResource("removeCustomApp")
|
||||
if not owner_resource then
|
||||
return false, owner_error
|
||||
end
|
||||
return client_bridge.RemoveProviderApp(owner_resource, app_id, {
|
||||
[providers.quasar] = true,
|
||||
})
|
||||
end
|
||||
|
||||
local function get_custom_apps()
|
||||
local records = client_bridge.GetProviderApps(providers.quasar)
|
||||
local apps = {}
|
||||
for index = 1, #records do
|
||||
apps[index] = SkyPhoneCompatibility.CopyQuasarData(records[index].vendor_data)
|
||||
end
|
||||
return apps
|
||||
end
|
||||
|
||||
local function is_phone_open()
|
||||
return phone.GetState().open == true
|
||||
end
|
||||
|
||||
local function start_call(phone_number, call_type)
|
||||
if type(phone_number) ~= "string" or not phone_number:match("%S") then
|
||||
return { success = false, error = "invalid_phone_number" }
|
||||
end
|
||||
if call_type ~= "audio" then
|
||||
if call_type == "video" then
|
||||
return { success = false, error = "video_unsupported" }
|
||||
end
|
||||
return { success = false, error = "invalid_call_type" }
|
||||
end
|
||||
|
||||
local success, error_message = calls.Dial(phone_number)
|
||||
if success then
|
||||
return { success = true }
|
||||
end
|
||||
return { success = false, error = error_message or "request_failed" }
|
||||
end
|
||||
|
||||
local function open_phone_app(app_id)
|
||||
local success = navigation.Open(app_id)
|
||||
return success == true
|
||||
end
|
||||
|
||||
SkyPhoneCompatibility.RegisterExportAlias("qs-smartphone", "addCustomApp", add_custom_app)
|
||||
SkyPhoneCompatibility.RegisterExportAlias("qs-smartphone", "addCustomAppsBatch", add_custom_apps_batch)
|
||||
SkyPhoneCompatibility.RegisterExportAlias("qs-smartphone", "updateCustomApp", update_custom_app)
|
||||
SkyPhoneCompatibility.RegisterExportAlias("qs-smartphone", "removeCustomApp", remove_custom_app)
|
||||
SkyPhoneCompatibility.RegisterExportAlias("qs-smartphone", "getCustomApps", get_custom_apps)
|
||||
SkyPhoneCompatibility.RegisterExportAlias("qs-smartphone", "IsPhoneOpen", is_phone_open)
|
||||
SkyPhoneCompatibility.RegisterExportAlias("qs-smartphone", "call", start_call)
|
||||
SkyPhoneCompatibility.RegisterExportAlias("qs-smartphone", "OpenPhoneApp", open_phone_app)
|
||||
|
||||
RegisterCommand("phone:toggle", function()
|
||||
phone.Toggle()
|
||||
end, false)
|
||||
|
||||
RegisterCommand("phone_peek_call_accept", function()
|
||||
calls.Answer()
|
||||
end, false)
|
||||
|
||||
RegisterCommand("phone_peek_call_reject", function()
|
||||
calls.Decline()
|
||||
end, false)
|
||||
|
||||
AddEventHandler("sky_phone:client:pushNotification", function(notification)
|
||||
if type(notification) ~= "table" then
|
||||
return
|
||||
end
|
||||
TriggerEvent("phone:pushNotification", {
|
||||
appId = notification.appId,
|
||||
text = notification.text,
|
||||
title = notification.title,
|
||||
})
|
||||
end)
|
||||
@@ -1,136 +0,0 @@
|
||||
local client_bridge = SkyPhoneCompatibilityClient
|
||||
local RESOURCE_NAME = client_bridge.ResourceName
|
||||
local providers = client_bridge.Providers
|
||||
local compatibility_core = client_bridge.Core
|
||||
local phone = client_bridge.Phone
|
||||
local notifications = client_bridge.Notifications
|
||||
local camera = client_bridge.Camera
|
||||
local navigation = SkyPhoneNavigation
|
||||
|
||||
local function add_application(app_data)
|
||||
local owner_resource, owner_error = client_bridge.GetCallingResource("AddApplication")
|
||||
if not owner_resource then
|
||||
return false, owner_error
|
||||
end
|
||||
|
||||
local definition, definition_error = SkyPhoneCompatibility.Build17MovDefinition(app_data)
|
||||
if not definition then
|
||||
Bridge.Debug(
|
||||
"warn",
|
||||
"[%s] 17mov registration rejected for %s: %s.",
|
||||
RESOURCE_NAME,
|
||||
owner_resource,
|
||||
definition_error
|
||||
)
|
||||
return false, definition_error
|
||||
end
|
||||
return client_bridge.RegisterProviderApp(
|
||||
providers.seventeen,
|
||||
owner_resource,
|
||||
definition,
|
||||
client_bridge.CopyRecordData(app_data)
|
||||
)
|
||||
end
|
||||
|
||||
local function remove_application(app_name, _resource_name)
|
||||
local owner_resource, owner_error = client_bridge.GetCallingResource("RemoveApplication")
|
||||
if not owner_resource then
|
||||
return false, owner_error
|
||||
end
|
||||
|
||||
local app_id = type(app_name) == "table" and app_name.name or app_name
|
||||
return client_bridge.RemoveProviderApp(owner_resource, app_id, {
|
||||
[providers.seventeen] = true,
|
||||
})
|
||||
end
|
||||
|
||||
local function send_app_message(app_id, data)
|
||||
local owner_resource, owner_error = client_bridge.GetCallingResource("SendAppMessage")
|
||||
if not owner_resource then
|
||||
return false, owner_error
|
||||
end
|
||||
|
||||
local record, record_error = client_bridge.GetProviderApp(owner_resource, app_id, {
|
||||
[providers.seventeen] = true,
|
||||
})
|
||||
if not record then
|
||||
return false, record_error
|
||||
end
|
||||
return compatibility_core.SendMessage(owner_resource, app_id, data)
|
||||
end
|
||||
|
||||
local function open_phone()
|
||||
phone.Toggle(true)
|
||||
end
|
||||
|
||||
local function close_phone()
|
||||
phone.Toggle(false)
|
||||
end
|
||||
|
||||
local function is_phone_open()
|
||||
return phone.GetState().open == true
|
||||
end
|
||||
|
||||
local function toggle_flashlight(state)
|
||||
if type(state) ~= "boolean" then
|
||||
Bridge.Debug("error", "[%s] Rejected invalid 17Movement flashlight state.", RESOURCE_NAME)
|
||||
return
|
||||
end
|
||||
camera.SetFlashlight(state)
|
||||
end
|
||||
|
||||
local function get_flashlight_state()
|
||||
return camera.GetState().flashEnabled == true
|
||||
end
|
||||
|
||||
local function create_notification(notification)
|
||||
local mapped, map_error = SkyPhoneCompatibility.Map17MovNotification(notification)
|
||||
if not mapped then
|
||||
Bridge.Debug(
|
||||
"warn",
|
||||
"[%s] Rejected unsupported 17Movement notification: %s.",
|
||||
RESOURCE_NAME,
|
||||
tostring(map_error)
|
||||
)
|
||||
return
|
||||
end
|
||||
|
||||
local success, notification_error = notifications.Send(mapped)
|
||||
if not success then
|
||||
Bridge.Debug(
|
||||
"warn",
|
||||
"[%s] Rejected 17Movement notification: %s.",
|
||||
RESOURCE_NAME,
|
||||
tostring(notification_error)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
local function open_app(app_name)
|
||||
navigation.Open(app_name)
|
||||
end
|
||||
|
||||
local function close_app(app_name)
|
||||
navigation.Close(app_name)
|
||||
end
|
||||
|
||||
SkyPhoneCompatibility.RegisterExportAlias("17mov_Phone", "AddApplication", add_application)
|
||||
SkyPhoneCompatibility.RegisterExportAlias("17mov_Phone", "RemoveApplication", remove_application)
|
||||
SkyPhoneCompatibility.RegisterExportAlias("17mov_Phone", "SendAppMessage", send_app_message)
|
||||
SkyPhoneCompatibility.RegisterExportAlias("17mov_Phone", "OpenPhone", open_phone)
|
||||
SkyPhoneCompatibility.RegisterExportAlias("17mov_Phone", "ClosePhone", close_phone)
|
||||
SkyPhoneCompatibility.RegisterExportAlias("17mov_Phone", "IsPhoneOpen", is_phone_open)
|
||||
SkyPhoneCompatibility.RegisterExportAlias(
|
||||
"17mov_Phone",
|
||||
"CreateNotification",
|
||||
create_notification
|
||||
)
|
||||
SkyPhoneCompatibility.RegisterExportAlias(
|
||||
"17mov_Phone",
|
||||
"GetPlayerNumber",
|
||||
phone.GetEquippedPhoneNumber
|
||||
)
|
||||
SkyPhoneCompatibility.RegisterExportAlias("17mov_Phone", "ToggleFlashlight", toggle_flashlight)
|
||||
SkyPhoneCompatibility.RegisterExportAlias("17mov_Phone", "GetFlashlightState", get_flashlight_state)
|
||||
SkyPhoneCompatibility.RegisterExportAlias("17mov_Phone", "OpenApp", open_app)
|
||||
SkyPhoneCompatibility.RegisterExportAlias("17mov_Phone", "CloseApp", close_app)
|
||||
@@ -1,124 +0,0 @@
|
||||
local client_bridge = SkyPhoneCompatibilityClient
|
||||
local RESOURCE_NAME = client_bridge.ResourceName
|
||||
local providers = client_bridge.Providers
|
||||
local compatibility_core = client_bridge.Core
|
||||
local phone = client_bridge.Phone
|
||||
local calls = client_bridge.Calls
|
||||
local camera = client_bridge.Camera
|
||||
local focus = SkyPhoneFocus
|
||||
local navigation = SkyPhoneNavigation
|
||||
|
||||
local function add_custom_app(app_data)
|
||||
local owner_resource, owner_error = client_bridge.GetCallingResource("AddCustomApp")
|
||||
if not owner_resource then
|
||||
return false, owner_error
|
||||
end
|
||||
if type(app_data) ~= "table" or app_data.key == nil then
|
||||
return false, "invalid_definition"
|
||||
end
|
||||
|
||||
local definition, definition_error = SkyPhoneCompatibility.BuildYSeriesDefinition(app_data)
|
||||
if not definition then
|
||||
Bridge.Debug(
|
||||
"warn",
|
||||
"[%s] %s registration rejected for %s: %s.",
|
||||
RESOURCE_NAME,
|
||||
providers.yseries,
|
||||
owner_resource,
|
||||
definition_error
|
||||
)
|
||||
return false, definition_error
|
||||
end
|
||||
|
||||
return client_bridge.RegisterProviderApp(
|
||||
providers.yseries,
|
||||
owner_resource,
|
||||
definition,
|
||||
client_bridge.CopyRecordData(app_data)
|
||||
)
|
||||
end
|
||||
|
||||
local function remove_custom_app(app_id)
|
||||
local owner_resource, owner_error = client_bridge.GetCallingResource("RemoveCustomApp")
|
||||
if not owner_resource then
|
||||
return false, owner_error
|
||||
end
|
||||
return client_bridge.RemoveProviderApp(owner_resource, app_id, {
|
||||
[providers.yseries] = true,
|
||||
})
|
||||
end
|
||||
|
||||
local function send_app_message(app_id, data)
|
||||
local owner_resource, owner_error = client_bridge.GetCallingResource("SendAppMessage")
|
||||
if not owner_resource then
|
||||
return false, owner_error
|
||||
end
|
||||
|
||||
local record, record_error = client_bridge.GetProviderApp(owner_resource, app_id, {
|
||||
[providers.yseries] = true,
|
||||
})
|
||||
if not record then
|
||||
return false, record_error
|
||||
end
|
||||
return compatibility_core.SendMessage(owner_resource, app_id, data)
|
||||
end
|
||||
|
||||
local function toggle_open(open)
|
||||
phone.Toggle(open)
|
||||
end
|
||||
|
||||
local function is_open()
|
||||
return phone.GetState().open == true
|
||||
end
|
||||
|
||||
local function toggle_flashlight(enabled)
|
||||
if type(enabled) ~= "boolean" then
|
||||
Bridge.Debug("error", "[%s] Rejected invalid YSeries flashlight state.", RESOURCE_NAME)
|
||||
return
|
||||
end
|
||||
camera.SetFlashlight(enabled)
|
||||
end
|
||||
|
||||
local function get_flashlight_state()
|
||||
return camera.GetState().flashEnabled == true
|
||||
end
|
||||
|
||||
local function close_app()
|
||||
navigation.Close()
|
||||
end
|
||||
|
||||
local function cancel_call()
|
||||
calls.Terminate()
|
||||
end
|
||||
|
||||
local function set_nui_focus_keep_input(allow_game_input)
|
||||
local owner_resource = client_bridge.GetCallingResource("SetNuiFocusKeepInput")
|
||||
if not owner_resource then
|
||||
return
|
||||
end
|
||||
if type(allow_game_input) ~= "boolean" then
|
||||
Bridge.Debug("error", "[%s] Rejected invalid YSeries game input state.", RESOURCE_NAME)
|
||||
return
|
||||
end
|
||||
focus.SetExternalGameInput(owner_resource, allow_game_input)
|
||||
end
|
||||
|
||||
SkyPhoneCompatibility.RegisterExportAlias("yseries", "SendAppMessage", send_app_message)
|
||||
SkyPhoneCompatibility.RegisterExportAlias("yseries", "AddCustomApp", add_custom_app)
|
||||
SkyPhoneCompatibility.RegisterExportAlias("yseries", "RemoveCustomApp", remove_custom_app)
|
||||
SkyPhoneCompatibility.RegisterExportAlias("yseries", "GetDataLoaded", function()
|
||||
return navigation.IsDataLoaded()
|
||||
end)
|
||||
SkyPhoneCompatibility.RegisterExportAlias("yseries", "ToggleOpen", toggle_open)
|
||||
SkyPhoneCompatibility.RegisterExportAlias("yseries", "IsOpen", is_open)
|
||||
SkyPhoneCompatibility.RegisterExportAlias("yseries", "ToggleFlashlight", toggle_flashlight)
|
||||
SkyPhoneCompatibility.RegisterExportAlias("yseries", "GetFlashlightState", get_flashlight_state)
|
||||
SkyPhoneCompatibility.RegisterExportAlias("yseries", "CloseApp", close_app)
|
||||
SkyPhoneCompatibility.RegisterExportAlias("yseries", "IsAppInstalled", navigation.IsInstalled)
|
||||
SkyPhoneCompatibility.RegisterExportAlias("yseries", "GetCurrentAppId", navigation.GetCurrent)
|
||||
SkyPhoneCompatibility.RegisterExportAlias("yseries", "CancelCall", cancel_call)
|
||||
SkyPhoneCompatibility.RegisterExportAlias(
|
||||
"yseries",
|
||||
"SetNuiFocusKeepInput",
|
||||
set_nui_focus_keep_input
|
||||
)
|
||||
@@ -1,159 +0,0 @@
|
||||
SkyPhoneCompatibilityServer = {}
|
||||
|
||||
local RESOURCE_NAME = GetCurrentResourceName()
|
||||
|
||||
local function reject_argument(provider_name, export_name, argument_name, expectation)
|
||||
Bridge.Debug(
|
||||
"warn",
|
||||
"[sky_phone] %s:%s rejected: %s must be %s.",
|
||||
provider_name,
|
||||
export_name,
|
||||
argument_name,
|
||||
expectation
|
||||
)
|
||||
return false
|
||||
end
|
||||
|
||||
local function is_finite_integer(value)
|
||||
return type(value) == "number"
|
||||
and value == value
|
||||
and value > -math.huge
|
||||
and value < math.huge
|
||||
and value == math.floor(value)
|
||||
end
|
||||
|
||||
local function require_phone_core()
|
||||
local phone = SkyPhone
|
||||
assert(
|
||||
type(phone) == "table",
|
||||
"[sky_phone] Phone compatibility bridge initialized before the phone core."
|
||||
)
|
||||
assert(
|
||||
type(phone.GetEquippedPhoneNumber) == "function",
|
||||
"[sky_phone] Phone compatibility bridge requires GetEquippedPhoneNumber."
|
||||
)
|
||||
assert(
|
||||
type(phone.GetSourceFromNumber) == "function",
|
||||
"[sky_phone] Phone compatibility bridge requires GetSourceFromNumber."
|
||||
)
|
||||
assert(
|
||||
type(phone.FormatNumber) == "function",
|
||||
"[sky_phone] Phone compatibility bridge requires FormatNumber."
|
||||
)
|
||||
|
||||
return phone
|
||||
end
|
||||
|
||||
SkyPhoneCompatibilityServer.ResourceName = RESOURCE_NAME
|
||||
SkyPhoneCompatibilityServer.Phone = nil
|
||||
|
||||
function SkyPhoneCompatibilityServer.AfterPhoneReady(callback)
|
||||
assert(type(callback) == "function", "Phone compatibility callback must be a function")
|
||||
|
||||
local function run_callback()
|
||||
local phone = require_phone_core()
|
||||
SkyPhoneCompatibilityServer.Phone = phone
|
||||
callback(phone)
|
||||
end
|
||||
|
||||
Bridge.Database.AfterMigration("sky_phone", run_callback)
|
||||
end
|
||||
|
||||
function SkyPhoneCompatibilityServer.GetCalls(provider_name, export_name)
|
||||
local calls = SkyPhoneCalls
|
||||
if type(calls) == "table"
|
||||
and type(calls.GetForSource) == "function"
|
||||
and type(calls.GetById) == "function"
|
||||
and type(calls.IsActiveForSource) == "function"
|
||||
and type(calls.EndForSource) == "function"
|
||||
and type(calls.TerminateForSource) == "function"
|
||||
then
|
||||
return calls
|
||||
end
|
||||
|
||||
Bridge.Debug(
|
||||
"error",
|
||||
"[sky_phone] %s:%s unavailable: the call service is not ready.",
|
||||
provider_name,
|
||||
export_name
|
||||
)
|
||||
return nil
|
||||
end
|
||||
|
||||
function SkyPhoneCompatibilityServer.GetNotifications(provider_name, export_name)
|
||||
local notifications = SkyPhoneNotifications
|
||||
if type(notifications) == "table" and type(notifications.Send) == "function" then
|
||||
return notifications
|
||||
end
|
||||
|
||||
Bridge.Debug(
|
||||
"error",
|
||||
"[sky_phone] %s:%s unavailable: the notification service is not ready.",
|
||||
provider_name,
|
||||
export_name
|
||||
)
|
||||
return nil
|
||||
end
|
||||
|
||||
function SkyPhoneCompatibilityServer.ValidatePlayerSource(
|
||||
provider_name,
|
||||
export_name,
|
||||
player_source
|
||||
)
|
||||
if is_finite_integer(player_source) and player_source > 0 then
|
||||
return true
|
||||
end
|
||||
|
||||
return reject_argument(
|
||||
provider_name,
|
||||
export_name,
|
||||
"player source",
|
||||
"a positive integer"
|
||||
)
|
||||
end
|
||||
|
||||
function SkyPhoneCompatibilityServer.ValidateIdentifier(provider_name, export_name, identifier)
|
||||
if type(identifier) == "string" and identifier:match("%S") then
|
||||
return true
|
||||
end
|
||||
|
||||
return reject_argument(
|
||||
provider_name,
|
||||
export_name,
|
||||
"identifier",
|
||||
"a non-empty string"
|
||||
)
|
||||
end
|
||||
|
||||
function SkyPhoneCompatibilityServer.ValidatePhoneNumber(provider_name, export_name, phone_number)
|
||||
if type(phone_number) == "string" and phone_number:match("%S") then
|
||||
return true
|
||||
end
|
||||
if is_finite_integer(phone_number) and phone_number >= 0 then
|
||||
return true
|
||||
end
|
||||
|
||||
return reject_argument(
|
||||
provider_name,
|
||||
export_name,
|
||||
"phone number",
|
||||
"a non-empty string or non-negative integer"
|
||||
)
|
||||
end
|
||||
|
||||
function SkyPhoneCompatibilityServer.ValidatePhoneNumberString(
|
||||
provider_name,
|
||||
export_name,
|
||||
phone_number
|
||||
)
|
||||
if type(phone_number) == "string" and phone_number:match("%S") then
|
||||
return true
|
||||
end
|
||||
|
||||
return reject_argument(
|
||||
provider_name,
|
||||
export_name,
|
||||
"phone number",
|
||||
"a non-empty string"
|
||||
)
|
||||
end
|
||||
@@ -1,253 +0,0 @@
|
||||
local server_bridge = SkyPhoneCompatibilityServer
|
||||
local phone
|
||||
local RESOURCE_NAME = server_bridge.ResourceName
|
||||
local PROVIDER_NAME = "high-phone"
|
||||
local SNAPSHOT_COOLDOWN_MS = 2000
|
||||
local SNAPSHOT_REJECTION_LOG_COOLDOWN_MS = 10000
|
||||
local registered_apps = {}
|
||||
local snapshot_requests = {}
|
||||
local snapshot_rejection_logs = {}
|
||||
|
||||
local function get_calling_resource(export_name)
|
||||
local owner_resource = GetInvokingResource()
|
||||
if owner_resource then
|
||||
return owner_resource
|
||||
end
|
||||
|
||||
Bridge.Debug(
|
||||
"warn",
|
||||
"[%s] %s rejected: the export must be called by another resource.",
|
||||
RESOURCE_NAME,
|
||||
export_name
|
||||
)
|
||||
return nil, "invalid_owner"
|
||||
end
|
||||
|
||||
local function add_high_application(app_name, data, locales)
|
||||
local owner_resource, owner_error = get_calling_resource("addApplication")
|
||||
if not owner_resource then
|
||||
return false, owner_error
|
||||
end
|
||||
|
||||
local definition, definition_error = SkyPhoneCompatibility.BuildHighDefinition(
|
||||
owner_resource,
|
||||
app_name,
|
||||
data,
|
||||
locales
|
||||
)
|
||||
if not definition then
|
||||
Bridge.Debug(
|
||||
"warn",
|
||||
"[%s] High Phone registration rejected for %s: %s.",
|
||||
RESOURCE_NAME,
|
||||
owner_resource,
|
||||
definition_error
|
||||
)
|
||||
return false, definition_error
|
||||
end
|
||||
|
||||
local existing = registered_apps[app_name]
|
||||
if existing and existing.owner_resource ~= owner_resource then
|
||||
return false, "duplicate_app_id"
|
||||
end
|
||||
|
||||
local revision = existing and existing.revision + 1 or 1
|
||||
registered_apps[app_name] = {
|
||||
definition = definition,
|
||||
owner_resource = owner_resource,
|
||||
revision = revision,
|
||||
}
|
||||
TriggerClientEvent(
|
||||
"sky_phone:compat:high:client:syncApplication",
|
||||
-1,
|
||||
owner_resource,
|
||||
definition,
|
||||
revision
|
||||
)
|
||||
return true
|
||||
end
|
||||
|
||||
local function build_snapshot()
|
||||
local ids = {}
|
||||
for app_id in pairs(registered_apps) do
|
||||
ids[#ids + 1] = app_id
|
||||
end
|
||||
table.sort(ids)
|
||||
|
||||
local snapshot = {}
|
||||
for index = 1, #ids do
|
||||
snapshot[index] = registered_apps[ids[index]]
|
||||
end
|
||||
return snapshot
|
||||
end
|
||||
|
||||
---@param player_source number
|
||||
---@return string|nil phone_number
|
||||
local function get_player_phone_number(player_source)
|
||||
if not server_bridge.ValidatePlayerSource(
|
||||
PROVIDER_NAME,
|
||||
"getPlayerPhoneNumber",
|
||||
player_source
|
||||
) then
|
||||
return nil
|
||||
end
|
||||
|
||||
return phone.GetEquippedPhoneNumber(player_source)
|
||||
end
|
||||
|
||||
---@param phone_number string
|
||||
---@return string|nil formatted_number
|
||||
local function format_number(phone_number)
|
||||
if not server_bridge.ValidatePhoneNumberString(
|
||||
PROVIDER_NAME,
|
||||
"formatNumber",
|
||||
phone_number
|
||||
) then
|
||||
return nil
|
||||
end
|
||||
|
||||
return phone.FormatNumber(phone_number)
|
||||
end
|
||||
|
||||
local function end_call(player_source)
|
||||
if not server_bridge.ValidatePlayerSource(PROVIDER_NAME, "endCall", player_source) then
|
||||
return
|
||||
end
|
||||
|
||||
local calls = server_bridge.GetCalls(PROVIDER_NAME, "endCall")
|
||||
if calls then
|
||||
calls.TerminateForSource(player_source)
|
||||
end
|
||||
end
|
||||
|
||||
local function send_notification(receiver, notification)
|
||||
local mapped, map_error = SkyPhoneCompatibility.MapHighNotification(notification)
|
||||
if not mapped then
|
||||
Bridge.Debug(
|
||||
"warn",
|
||||
"[sky_phone] %s:sendNotification rejected an unsupported notification: %s.",
|
||||
PROVIDER_NAME,
|
||||
tostring(map_error)
|
||||
)
|
||||
return
|
||||
end
|
||||
|
||||
local target
|
||||
if receiver == -1 then
|
||||
target = { kind = "all" }
|
||||
elseif type(receiver) == "number"
|
||||
and receiver == receiver
|
||||
and receiver > 0
|
||||
and receiver < math.huge
|
||||
and receiver == math.floor(receiver)
|
||||
then
|
||||
target = { kind = "source", value = receiver }
|
||||
elseif type(receiver) == "string" and receiver:match("%S") then
|
||||
target = { kind = "number", value = receiver }
|
||||
else
|
||||
Bridge.Debug(
|
||||
"warn",
|
||||
"[sky_phone] %s:sendNotification rejected an invalid receiver.",
|
||||
PROVIDER_NAME
|
||||
)
|
||||
return
|
||||
end
|
||||
|
||||
local notifications = server_bridge.GetNotifications(PROVIDER_NAME, "sendNotification")
|
||||
if not notifications then
|
||||
return
|
||||
end
|
||||
local result, notification_error = notifications.Send(target, mapped)
|
||||
if not result then
|
||||
Bridge.Debug(
|
||||
"warn",
|
||||
"[sky_phone] %s:sendNotification rejected a notification: %s.",
|
||||
PROVIDER_NAME,
|
||||
tostring(notification_error)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
RegisterNetEvent("sky_phone:compat:high:server:requestSnapshot", function()
|
||||
local player_source = source
|
||||
if player_source <= 0 then
|
||||
Bridge.Debug(
|
||||
"warn",
|
||||
"[%s] Rejected High Phone snapshot request without a player source.",
|
||||
RESOURCE_NAME
|
||||
)
|
||||
return
|
||||
end
|
||||
|
||||
local now = GetGameTimer()
|
||||
local last_request = snapshot_requests[player_source]
|
||||
local elapsed = last_request and now - last_request or SNAPSHOT_COOLDOWN_MS
|
||||
if elapsed >= 0 and elapsed < SNAPSHOT_COOLDOWN_MS then
|
||||
local last_log = snapshot_rejection_logs[player_source]
|
||||
local log_elapsed = last_log and now - last_log or SNAPSHOT_REJECTION_LOG_COOLDOWN_MS
|
||||
if log_elapsed < 0 or log_elapsed >= SNAPSHOT_REJECTION_LOG_COOLDOWN_MS then
|
||||
snapshot_rejection_logs[player_source] = now
|
||||
Bridge.Debug(
|
||||
"warn",
|
||||
"[%s] Rate-limited High Phone snapshot request from player %s.",
|
||||
RESOURCE_NAME,
|
||||
player_source
|
||||
)
|
||||
end
|
||||
return
|
||||
end
|
||||
snapshot_requests[player_source] = now
|
||||
|
||||
TriggerClientEvent(
|
||||
"sky_phone:compat:high:client:replaceSnapshot",
|
||||
player_source,
|
||||
build_snapshot()
|
||||
)
|
||||
end)
|
||||
|
||||
AddEventHandler("playerDropped", function()
|
||||
snapshot_requests[source] = nil
|
||||
snapshot_rejection_logs[source] = nil
|
||||
end)
|
||||
|
||||
AddEventHandler("onResourceStop", function(resource_name)
|
||||
if resource_name == RESOURCE_NAME then
|
||||
return
|
||||
end
|
||||
|
||||
local removed_ids = {}
|
||||
for app_id, record in pairs(registered_apps) do
|
||||
if record.owner_resource == resource_name then
|
||||
removed_ids[#removed_ids + 1] = app_id
|
||||
end
|
||||
end
|
||||
for index = 1, #removed_ids do
|
||||
local app_id = removed_ids[index]
|
||||
registered_apps[app_id] = nil
|
||||
TriggerClientEvent(
|
||||
"sky_phone:compat:high:client:removeApplication",
|
||||
-1,
|
||||
resource_name,
|
||||
app_id
|
||||
)
|
||||
end
|
||||
end)
|
||||
|
||||
local function register_aliases(ready_phone)
|
||||
phone = ready_phone
|
||||
SkyPhoneCompatibility.RegisterExportAlias(PROVIDER_NAME, "addApplication", add_high_application)
|
||||
SkyPhoneCompatibility.RegisterExportAlias(
|
||||
PROVIDER_NAME,
|
||||
"getPlayerPhoneNumber",
|
||||
get_player_phone_number
|
||||
)
|
||||
SkyPhoneCompatibility.RegisterExportAlias(PROVIDER_NAME, "formatNumber", format_number)
|
||||
SkyPhoneCompatibility.RegisterExportAlias(PROVIDER_NAME, "endCall", end_call)
|
||||
SkyPhoneCompatibility.RegisterExportAlias(
|
||||
PROVIDER_NAME,
|
||||
"sendNotification",
|
||||
send_notification
|
||||
)
|
||||
end
|
||||
|
||||
server_bridge.AfterPhoneReady(register_aliases)
|
||||
@@ -1,20 +0,0 @@
|
||||
local server_bridge = SkyPhoneCompatibilityServer
|
||||
local phone
|
||||
local PROVIDER_NAME = "lb-phone"
|
||||
|
||||
local function register_aliases(ready_phone)
|
||||
phone = ready_phone
|
||||
SkyPhoneCompatibility.RegisterExportAlias(
|
||||
PROVIDER_NAME,
|
||||
"GetEquippedPhoneNumber",
|
||||
phone.GetEquippedPhoneNumber
|
||||
)
|
||||
SkyPhoneCompatibility.RegisterExportAlias(
|
||||
PROVIDER_NAME,
|
||||
"GetSourceFromNumber",
|
||||
phone.GetSourceFromNumber
|
||||
)
|
||||
SkyPhoneCompatibility.RegisterExportAlias(PROVIDER_NAME, "FormatNumber", phone.FormatNumber)
|
||||
end
|
||||
|
||||
server_bridge.AfterPhoneReady(register_aliases)
|
||||
@@ -1,39 +0,0 @@
|
||||
local server_bridge = SkyPhoneCompatibilityServer
|
||||
local RESOURCE_NAME = server_bridge.ResourceName
|
||||
local LB_PROVIDER_NAME = "lb-phone"
|
||||
|
||||
AddEventHandler("sky_phone:server:phoneNumberChanged", function(player_source, phone_number)
|
||||
TriggerEvent("lb-phone:numberChanged", player_source, phone_number)
|
||||
end)
|
||||
|
||||
AddEventHandler("sky_phone:server:phoneNumberGenerated", function(player_source, phone_number)
|
||||
TriggerEvent("lb-phone:phoneNumberGenerated", player_source, phone_number)
|
||||
end)
|
||||
|
||||
AddEventHandler("sky_phone:server:factoryReset", function(player_source, phone_number)
|
||||
TriggerEvent("lb-phone:factoryReset", player_source, phone_number)
|
||||
end)
|
||||
|
||||
AddEventHandler("sky_phone:server:galleryMediaDeleted", function(player_source, phone_number, link)
|
||||
TriggerEvent("lb-phone:deletedFromGallery", player_source, phone_number, link)
|
||||
end)
|
||||
|
||||
AddEventHandler("onResourceStop", function(resource_name)
|
||||
if resource_name ~= RESOURCE_NAME then
|
||||
return
|
||||
end
|
||||
|
||||
SkyPhoneCompatibility.EmitServerProviderStop(LB_PROVIDER_NAME)
|
||||
end)
|
||||
|
||||
local function reset_lb_export_cache()
|
||||
Bridge.Debug(
|
||||
"debug",
|
||||
"[sky_phone] Resetting the LB Phone server export cache after bridge startup.",
|
||||
{ always = true }
|
||||
)
|
||||
SkyPhoneCompatibility.EmitServerProviderStop(LB_PROVIDER_NAME)
|
||||
SkyPhoneCompatibility.EmitServerProviderStart(LB_PROVIDER_NAME)
|
||||
end
|
||||
|
||||
server_bridge.AfterPhoneReady(reset_lb_export_cache)
|
||||
@@ -1,101 +0,0 @@
|
||||
local server_bridge = SkyPhoneCompatibilityServer
|
||||
local phone
|
||||
local PROVIDER_NAME = "qs-smartphone"
|
||||
|
||||
---@param player_source number
|
||||
---@return string|nil phone_number
|
||||
local function get_current_phone_number(player_source)
|
||||
if not server_bridge.ValidatePlayerSource(
|
||||
PROVIDER_NAME,
|
||||
"GetCurrentPhoneNumber",
|
||||
player_source
|
||||
) then
|
||||
return nil
|
||||
end
|
||||
|
||||
return phone.GetEquippedPhoneNumber(player_source)
|
||||
end
|
||||
|
||||
local function is_player_in_call(player_source)
|
||||
if not server_bridge.ValidatePlayerSource(
|
||||
PROVIDER_NAME,
|
||||
"isPlayerInCall",
|
||||
player_source
|
||||
) then
|
||||
return false
|
||||
end
|
||||
|
||||
local calls = server_bridge.GetCalls(PROVIDER_NAME, "isPlayerInCall")
|
||||
return calls and calls.IsActiveForSource(player_source) or false
|
||||
end
|
||||
|
||||
local function end_call_by_source(player_source)
|
||||
if not server_bridge.ValidatePlayerSource(
|
||||
PROVIDER_NAME,
|
||||
"endCallBySource",
|
||||
player_source
|
||||
) then
|
||||
return
|
||||
end
|
||||
|
||||
local calls = server_bridge.GetCalls(PROVIDER_NAME, "endCallBySource")
|
||||
if calls then
|
||||
calls.TerminateForSource(player_source)
|
||||
end
|
||||
end
|
||||
|
||||
local function send_phone_notification(player_source, notification)
|
||||
if not server_bridge.ValidatePlayerSource(
|
||||
PROVIDER_NAME,
|
||||
"sendPhoneNotification",
|
||||
player_source
|
||||
) then
|
||||
return
|
||||
end
|
||||
|
||||
local notifications = server_bridge.GetNotifications(
|
||||
PROVIDER_NAME,
|
||||
"sendPhoneNotification"
|
||||
)
|
||||
if not notifications then
|
||||
return
|
||||
end
|
||||
local result, notification_error = notifications.Send(
|
||||
{ kind = "source", value = player_source },
|
||||
notification
|
||||
)
|
||||
if not result then
|
||||
Bridge.Debug(
|
||||
"warn",
|
||||
"[sky_phone] %s:sendPhoneNotification rejected a notification: %s.",
|
||||
PROVIDER_NAME,
|
||||
tostring(notification_error)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
local function register_aliases(ready_phone)
|
||||
phone = ready_phone
|
||||
SkyPhoneCompatibility.RegisterExportAlias(
|
||||
PROVIDER_NAME,
|
||||
"GetCurrentPhoneNumber",
|
||||
get_current_phone_number
|
||||
)
|
||||
SkyPhoneCompatibility.RegisterExportAlias(
|
||||
PROVIDER_NAME,
|
||||
"isPlayerInCall",
|
||||
is_player_in_call
|
||||
)
|
||||
SkyPhoneCompatibility.RegisterExportAlias(
|
||||
PROVIDER_NAME,
|
||||
"endCallBySource",
|
||||
end_call_by_source
|
||||
)
|
||||
SkyPhoneCompatibility.RegisterExportAlias(
|
||||
PROVIDER_NAME,
|
||||
"sendPhoneNotification",
|
||||
send_phone_notification
|
||||
)
|
||||
end
|
||||
|
||||
server_bridge.AfterPhoneReady(register_aliases)
|
||||
@@ -1,352 +0,0 @@
|
||||
local server_bridge = SkyPhoneCompatibilityServer
|
||||
local phone
|
||||
local PROVIDER_NAME = "17mov_Phone"
|
||||
|
||||
---@param player_source number
|
||||
---@return string|nil phone_number
|
||||
local function get_number_from_player(player_source)
|
||||
if not server_bridge.ValidatePlayerSource(
|
||||
PROVIDER_NAME,
|
||||
"GetNumberFromPlayer",
|
||||
player_source
|
||||
) then
|
||||
return nil
|
||||
end
|
||||
|
||||
return phone.GetEquippedPhoneNumber(player_source)
|
||||
end
|
||||
|
||||
---@param identifier string
|
||||
---@return string|nil phone_number
|
||||
local function get_number_from_identifier(identifier)
|
||||
if not server_bridge.ValidateIdentifier(
|
||||
PROVIDER_NAME,
|
||||
"GetNumberFromIdentifier",
|
||||
identifier
|
||||
) then
|
||||
return nil
|
||||
end
|
||||
|
||||
return phone.GetEquippedPhoneNumber(identifier)
|
||||
end
|
||||
|
||||
---@param phone_number string|number
|
||||
---@return number|nil player_source
|
||||
local function get_player_source_from_active_number(phone_number)
|
||||
if not server_bridge.ValidatePhoneNumber(
|
||||
PROVIDER_NAME,
|
||||
"GetPlayerSrcFromActiveNumber",
|
||||
phone_number
|
||||
) then
|
||||
return nil
|
||||
end
|
||||
|
||||
return phone.GetSourceFromNumber(phone_number)
|
||||
end
|
||||
|
||||
local function is_finite_source(value)
|
||||
return type(value) == "number"
|
||||
and value == value
|
||||
and value ~= math.huge
|
||||
and value ~= -math.huge
|
||||
and value > 0
|
||||
and value == math.floor(value)
|
||||
end
|
||||
|
||||
local function is_uuid(value)
|
||||
return type(value) == "string"
|
||||
and value:match(
|
||||
"^%x%x%x%x%x%x%x%x%-%x%x%x%x%-%x%x%x%x%-%x%x%x%x%-%x%x%x%x%x%x%x%x%x%x%x%x$"
|
||||
) ~= nil
|
||||
end
|
||||
|
||||
local function get_call_source_from_number(export_name, phone_number)
|
||||
if not server_bridge.ValidatePhoneNumber(PROVIDER_NAME, export_name, phone_number) then
|
||||
return nil
|
||||
end
|
||||
return phone.GetSourceFromNumber(phone_number)
|
||||
end
|
||||
|
||||
local function build_phone_call(call)
|
||||
if call == nil then
|
||||
return nil
|
||||
end
|
||||
|
||||
local caller = type(call) == "table" and call.caller or nil
|
||||
local callee = type(call) == "table" and call.callee or nil
|
||||
local valid = type(call) == "table"
|
||||
and is_uuid(call.id)
|
||||
and type(caller) == "table"
|
||||
and is_finite_source(caller.source)
|
||||
and type(caller.number) == "string"
|
||||
and type(callee) == "table"
|
||||
and is_finite_source(callee.source)
|
||||
and type(callee.number) == "string"
|
||||
and type(call.startedAt) == "number"
|
||||
and call.startedAt == call.startedAt
|
||||
and call.startedAt ~= math.huge
|
||||
and call.startedAt ~= -math.huge
|
||||
if not valid then
|
||||
Bridge.Debug(
|
||||
"error",
|
||||
"[sky_phone] Could not project an invalid call snapshot for 17Movement."
|
||||
)
|
||||
return nil
|
||||
end
|
||||
|
||||
return {
|
||||
callId = call.id,
|
||||
fromId = caller.source,
|
||||
fromNumber = caller.number,
|
||||
toId = callee.source,
|
||||
toNumber = callee.number,
|
||||
callTime = call.startedAt,
|
||||
inCall = true,
|
||||
type = call.video and "video" or "phone",
|
||||
isNumberHidden = call.anonymous == true,
|
||||
isCompanyCall = call.companyId ~= nil,
|
||||
}
|
||||
end
|
||||
|
||||
local function end_call_by_source(player_source)
|
||||
if not server_bridge.ValidatePlayerSource(
|
||||
PROVIDER_NAME,
|
||||
"PhoneApp_EndCallBySrc",
|
||||
player_source
|
||||
) then
|
||||
return false
|
||||
end
|
||||
local calls = server_bridge.GetCalls(PROVIDER_NAME, "PhoneApp_EndCallBySrc")
|
||||
return calls and calls.TerminateForSource(player_source) == true or false
|
||||
end
|
||||
|
||||
local function end_call_by_number(phone_number)
|
||||
local player_source = get_call_source_from_number(
|
||||
"PhoneApp_EndCallByNumber",
|
||||
phone_number
|
||||
)
|
||||
if not player_source then
|
||||
return false
|
||||
end
|
||||
local calls = server_bridge.GetCalls(PROVIDER_NAME, "PhoneApp_EndCallByNumber")
|
||||
return calls and calls.TerminateForSource(player_source) == true or false
|
||||
end
|
||||
|
||||
local function is_in_call_by_source(player_source)
|
||||
if not server_bridge.ValidatePlayerSource(
|
||||
PROVIDER_NAME,
|
||||
"PhoneApp_IsInCallBySrc",
|
||||
player_source
|
||||
) then
|
||||
return false
|
||||
end
|
||||
local calls = server_bridge.GetCalls(PROVIDER_NAME, "PhoneApp_IsInCallBySrc")
|
||||
return calls and calls.IsActiveForSource(player_source) or false
|
||||
end
|
||||
|
||||
local function is_in_call_by_number(phone_number)
|
||||
local player_source = get_call_source_from_number(
|
||||
"PhoneApp_IsInCallByNumber",
|
||||
phone_number
|
||||
)
|
||||
if not player_source then
|
||||
return false
|
||||
end
|
||||
local calls = server_bridge.GetCalls(PROVIDER_NAME, "PhoneApp_IsInCallByNumber")
|
||||
return calls and calls.IsActiveForSource(player_source) or false
|
||||
end
|
||||
|
||||
local function get_call_by_source(export_name, player_source)
|
||||
if not server_bridge.ValidatePlayerSource(PROVIDER_NAME, export_name, player_source) then
|
||||
return nil
|
||||
end
|
||||
local calls = server_bridge.GetCalls(PROVIDER_NAME, export_name)
|
||||
return calls and calls.GetForSource(player_source) or nil
|
||||
end
|
||||
|
||||
local function get_call_by_number(export_name, phone_number)
|
||||
local player_source = get_call_source_from_number(export_name, phone_number)
|
||||
if not player_source then
|
||||
return nil
|
||||
end
|
||||
local calls = server_bridge.GetCalls(PROVIDER_NAME, export_name)
|
||||
return calls and calls.GetForSource(player_source) or nil
|
||||
end
|
||||
|
||||
local function get_call_id_by_source(player_source)
|
||||
local call = get_call_by_source("PhoneApp_GetCallIdFromSrc", player_source)
|
||||
return type(call) == "table" and is_uuid(call.id) and call.id or nil
|
||||
end
|
||||
|
||||
local function get_call_id_by_number(phone_number)
|
||||
local call = get_call_by_number("PhoneApp_GetCallIdFromNumber", phone_number)
|
||||
return type(call) == "table" and is_uuid(call.id) and call.id or nil
|
||||
end
|
||||
|
||||
local function get_call_data_by_source(player_source)
|
||||
return build_phone_call(get_call_by_source("PhoneApp_GetCallDataFromSrc", player_source))
|
||||
end
|
||||
|
||||
local function get_call_data_by_number(phone_number)
|
||||
return build_phone_call(get_call_by_number("PhoneApp_GetCallDataFromNumber", phone_number))
|
||||
end
|
||||
|
||||
local function get_call_data_by_id(call_id)
|
||||
if not is_uuid(call_id) then
|
||||
Bridge.Debug(
|
||||
"warn",
|
||||
"[sky_phone] 17mov_Phone:PhoneApp_GetCallDataFromCallId rejected: call ID must be a UUID."
|
||||
)
|
||||
return nil
|
||||
end
|
||||
local calls = server_bridge.GetCalls(PROVIDER_NAME, "PhoneApp_GetCallDataFromCallId")
|
||||
return build_phone_call(calls and calls.GetById(call_id) or nil)
|
||||
end
|
||||
|
||||
local function send_notification(export_name, target, notification)
|
||||
local mapped, map_error = SkyPhoneCompatibility.Map17MovNotification(notification)
|
||||
if not mapped then
|
||||
Bridge.Debug(
|
||||
"warn",
|
||||
"[sky_phone] %s:%s rejected an unsupported notification: %s.",
|
||||
PROVIDER_NAME,
|
||||
export_name,
|
||||
tostring(map_error)
|
||||
)
|
||||
return
|
||||
end
|
||||
|
||||
local notifications = server_bridge.GetNotifications(PROVIDER_NAME, export_name)
|
||||
if not notifications then
|
||||
return
|
||||
end
|
||||
local result, notification_error = notifications.Send(target, mapped)
|
||||
if not result then
|
||||
Bridge.Debug(
|
||||
"warn",
|
||||
"[sky_phone] %s:%s rejected a notification: %s.",
|
||||
PROVIDER_NAME,
|
||||
export_name,
|
||||
tostring(notification_error)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
local function send_notification_to_source(player_source, notification)
|
||||
if not server_bridge.ValidatePlayerSource(
|
||||
PROVIDER_NAME,
|
||||
"SendNotificationToSrc",
|
||||
player_source
|
||||
) then
|
||||
return
|
||||
end
|
||||
send_notification(
|
||||
"SendNotificationToSrc",
|
||||
{ kind = "source", value = player_source },
|
||||
notification
|
||||
)
|
||||
end
|
||||
|
||||
local function send_notification_to_number(phone_number, notification)
|
||||
if not server_bridge.ValidatePhoneNumberString(
|
||||
PROVIDER_NAME,
|
||||
"SendNotificationToNumber",
|
||||
phone_number
|
||||
) then
|
||||
return
|
||||
end
|
||||
send_notification(
|
||||
"SendNotificationToNumber",
|
||||
{ kind = "number", value = phone_number },
|
||||
notification
|
||||
)
|
||||
end
|
||||
|
||||
local function send_notification_to_everyone(notification)
|
||||
send_notification(
|
||||
"SendNotificationToEveryone",
|
||||
{ kind = "all" },
|
||||
notification
|
||||
)
|
||||
end
|
||||
|
||||
local function register_aliases(ready_phone)
|
||||
phone = ready_phone
|
||||
SkyPhoneCompatibility.RegisterExportAlias(
|
||||
PROVIDER_NAME,
|
||||
"GetNumberFromPlayer",
|
||||
get_number_from_player
|
||||
)
|
||||
SkyPhoneCompatibility.RegisterExportAlias(
|
||||
PROVIDER_NAME,
|
||||
"GetNumberFromIdentifier",
|
||||
get_number_from_identifier
|
||||
)
|
||||
SkyPhoneCompatibility.RegisterExportAlias(
|
||||
PROVIDER_NAME,
|
||||
"GetPlayerSrcFromActiveNumber",
|
||||
get_player_source_from_active_number
|
||||
)
|
||||
SkyPhoneCompatibility.RegisterExportAlias(
|
||||
PROVIDER_NAME,
|
||||
"PhoneApp_EndCallBySrc",
|
||||
end_call_by_source
|
||||
)
|
||||
SkyPhoneCompatibility.RegisterExportAlias(
|
||||
PROVIDER_NAME,
|
||||
"PhoneApp_EndCallByNumber",
|
||||
end_call_by_number
|
||||
)
|
||||
SkyPhoneCompatibility.RegisterExportAlias(
|
||||
PROVIDER_NAME,
|
||||
"PhoneApp_IsInCallBySrc",
|
||||
is_in_call_by_source
|
||||
)
|
||||
SkyPhoneCompatibility.RegisterExportAlias(
|
||||
PROVIDER_NAME,
|
||||
"PhoneApp_IsInCallByNumber",
|
||||
is_in_call_by_number
|
||||
)
|
||||
SkyPhoneCompatibility.RegisterExportAlias(
|
||||
PROVIDER_NAME,
|
||||
"PhoneApp_GetCallIdFromSrc",
|
||||
get_call_id_by_source
|
||||
)
|
||||
SkyPhoneCompatibility.RegisterExportAlias(
|
||||
PROVIDER_NAME,
|
||||
"PhoneApp_GetCallIdFromNumber",
|
||||
get_call_id_by_number
|
||||
)
|
||||
SkyPhoneCompatibility.RegisterExportAlias(
|
||||
PROVIDER_NAME,
|
||||
"PhoneApp_GetCallDataFromSrc",
|
||||
get_call_data_by_source
|
||||
)
|
||||
SkyPhoneCompatibility.RegisterExportAlias(
|
||||
PROVIDER_NAME,
|
||||
"PhoneApp_GetCallDataFromNumber",
|
||||
get_call_data_by_number
|
||||
)
|
||||
SkyPhoneCompatibility.RegisterExportAlias(
|
||||
PROVIDER_NAME,
|
||||
"PhoneApp_GetCallDataFromCallId",
|
||||
get_call_data_by_id
|
||||
)
|
||||
SkyPhoneCompatibility.RegisterExportAlias(
|
||||
PROVIDER_NAME,
|
||||
"SendNotificationToSrc",
|
||||
send_notification_to_source
|
||||
)
|
||||
SkyPhoneCompatibility.RegisterExportAlias(
|
||||
PROVIDER_NAME,
|
||||
"SendNotificationToNumber",
|
||||
send_notification_to_number
|
||||
)
|
||||
SkyPhoneCompatibility.RegisterExportAlias(
|
||||
PROVIDER_NAME,
|
||||
"SendNotificationToEveryone",
|
||||
send_notification_to_everyone
|
||||
)
|
||||
end
|
||||
|
||||
server_bridge.AfterPhoneReady(register_aliases)
|
||||
@@ -1,192 +0,0 @@
|
||||
local server_bridge = SkyPhoneCompatibilityServer
|
||||
local phone
|
||||
local PROVIDER_NAME = "yseries"
|
||||
|
||||
local function reject_imei(export_name)
|
||||
Bridge.Debug(
|
||||
"warn",
|
||||
"[sky_phone] %s:%s rejected: phone IMEI must be a valid IMEI string.",
|
||||
PROVIDER_NAME,
|
||||
export_name
|
||||
)
|
||||
return false
|
||||
end
|
||||
|
||||
---@param export_name string
|
||||
---@param phone_imei unknown
|
||||
---@return boolean
|
||||
local function validate_phone_imei(export_name, phone_imei)
|
||||
if SkyPhoneImei.IsValid(phone_imei) then
|
||||
return true
|
||||
end
|
||||
|
||||
return reject_imei(export_name)
|
||||
end
|
||||
|
||||
---@param export_name string
|
||||
---@param identifier unknown
|
||||
---@return string|nil identifier
|
||||
local function normalize_identifier(export_name, identifier)
|
||||
if not server_bridge.ValidateIdentifier(PROVIDER_NAME, export_name, identifier) then
|
||||
return nil
|
||||
end
|
||||
|
||||
identifier = identifier:match("^%s*(.-)%s*$")
|
||||
if #identifier <= 80 then
|
||||
return identifier
|
||||
end
|
||||
|
||||
Bridge.Debug(
|
||||
"warn",
|
||||
"[sky_phone] %s:%s rejected: identifier must not exceed 80 characters.",
|
||||
PROVIDER_NAME,
|
||||
export_name
|
||||
)
|
||||
return nil
|
||||
end
|
||||
|
||||
---@param phone_number string|number
|
||||
---@return number|nil player_source
|
||||
local function get_player_source_by_phone_number(phone_number)
|
||||
if not server_bridge.ValidatePhoneNumber(
|
||||
PROVIDER_NAME,
|
||||
"GetPlayerSourceIdByPhoneNumber",
|
||||
phone_number
|
||||
) then
|
||||
return nil
|
||||
end
|
||||
|
||||
return phone.GetSourceFromNumber(phone_number)
|
||||
end
|
||||
|
||||
---@param player_source number
|
||||
---@return string|nil phone_number
|
||||
local function get_phone_number_by_source(player_source)
|
||||
if not server_bridge.ValidatePlayerSource(
|
||||
PROVIDER_NAME,
|
||||
"GetPhoneNumberBySourceId",
|
||||
player_source
|
||||
) then
|
||||
return nil
|
||||
end
|
||||
|
||||
return phone.GetEquippedPhoneNumber(player_source)
|
||||
end
|
||||
|
||||
---@param phone_imei string
|
||||
---@return number|nil player_source
|
||||
local function get_player_source_by_phone_imei(phone_imei)
|
||||
if not validate_phone_imei("GetPlayerSourceIdByPhoneImei", phone_imei) then
|
||||
return nil
|
||||
end
|
||||
|
||||
local identity = SkyPhoneDeviceDirectory.GetOnlineByImei(phone_imei)
|
||||
return identity and identity.source or nil
|
||||
end
|
||||
|
||||
---@param identifier string
|
||||
---@return number|nil player_source
|
||||
local function get_player_source_by_identifier(identifier)
|
||||
identifier = normalize_identifier("GetPlayerSourceIdByIdentifier", identifier)
|
||||
if not identifier then
|
||||
return nil
|
||||
end
|
||||
|
||||
local identity = SkyPhoneDeviceDirectory.GetOnlineByIdentifier(identifier)
|
||||
return identity and identity.source or nil
|
||||
end
|
||||
|
||||
---@param phone_imei string
|
||||
---@return string|nil phone_number
|
||||
local function get_phone_number_by_imei(phone_imei)
|
||||
if not validate_phone_imei("GetPhoneNumberByImei", phone_imei) then
|
||||
return nil
|
||||
end
|
||||
|
||||
local identity = SkyPhoneDeviceDirectory.GetStoredDeviceByImei(phone_imei)
|
||||
return identity and identity.phoneNumber or nil
|
||||
end
|
||||
|
||||
---@param phone_number string
|
||||
---@return string|nil phone_imei
|
||||
local function get_phone_imei_by_phone_number(phone_number)
|
||||
if not server_bridge.ValidatePhoneNumberString(
|
||||
PROVIDER_NAME,
|
||||
"GetPhoneImeiByPhoneNumber",
|
||||
phone_number
|
||||
) then
|
||||
return nil
|
||||
end
|
||||
|
||||
local identity = SkyPhoneDeviceDirectory.GetStoredDeviceByPhoneNumber(phone_number)
|
||||
return identity and identity.imei or nil
|
||||
end
|
||||
|
||||
---@param player_source number
|
||||
---@return string|nil phone_imei
|
||||
local function get_phone_imei_by_source(player_source)
|
||||
if not server_bridge.ValidatePlayerSource(
|
||||
PROVIDER_NAME,
|
||||
"GetPhoneImeiBySourceId",
|
||||
player_source
|
||||
) then
|
||||
return nil
|
||||
end
|
||||
|
||||
local identity = SkyPhoneDeviceDirectory.GetOnlineBySource(player_source)
|
||||
return identity and identity.imei or nil
|
||||
end
|
||||
|
||||
local function register_aliases(ready_phone)
|
||||
phone = ready_phone
|
||||
assert(
|
||||
type(SkyPhoneDeviceDirectory) == "table",
|
||||
"[sky_phone] YSeries identity compatibility requires the device directory."
|
||||
)
|
||||
assert(
|
||||
type(SkyPhoneDeviceDirectory.GetOnlineByImei) == "function"
|
||||
and type(SkyPhoneDeviceDirectory.GetOnlineByIdentifier) == "function"
|
||||
and type(SkyPhoneDeviceDirectory.GetOnlineBySource) == "function"
|
||||
and type(SkyPhoneDeviceDirectory.GetStoredDeviceByImei) == "function"
|
||||
and type(SkyPhoneDeviceDirectory.GetStoredDeviceByPhoneNumber) == "function",
|
||||
"[sky_phone] YSeries identity compatibility requires the complete device directory."
|
||||
)
|
||||
|
||||
SkyPhoneCompatibility.RegisterExportAlias(
|
||||
PROVIDER_NAME,
|
||||
"GetPlayerSourceIdByPhoneNumber",
|
||||
get_player_source_by_phone_number
|
||||
)
|
||||
SkyPhoneCompatibility.RegisterExportAlias(
|
||||
PROVIDER_NAME,
|
||||
"GetPhoneNumberBySourceId",
|
||||
get_phone_number_by_source
|
||||
)
|
||||
SkyPhoneCompatibility.RegisterExportAlias(
|
||||
PROVIDER_NAME,
|
||||
"GetPlayerSourceIdByPhoneImei",
|
||||
get_player_source_by_phone_imei
|
||||
)
|
||||
SkyPhoneCompatibility.RegisterExportAlias(
|
||||
PROVIDER_NAME,
|
||||
"GetPlayerSourceIdByIdentifier",
|
||||
get_player_source_by_identifier
|
||||
)
|
||||
SkyPhoneCompatibility.RegisterExportAlias(
|
||||
PROVIDER_NAME,
|
||||
"GetPhoneNumberByImei",
|
||||
get_phone_number_by_imei
|
||||
)
|
||||
SkyPhoneCompatibility.RegisterExportAlias(
|
||||
PROVIDER_NAME,
|
||||
"GetPhoneImeiByPhoneNumber",
|
||||
get_phone_imei_by_phone_number
|
||||
)
|
||||
SkyPhoneCompatibility.RegisterExportAlias(
|
||||
PROVIDER_NAME,
|
||||
"GetPhoneImeiBySourceId",
|
||||
get_phone_imei_by_source
|
||||
)
|
||||
end
|
||||
|
||||
server_bridge.AfterPhoneReady(register_aliases)
|
||||
@@ -1,78 +0,0 @@
|
||||
SkyPhoneCompatibility = {}
|
||||
|
||||
function SkyPhoneCompatibility.RegisterExportAlias(resource_name, export_name, handler)
|
||||
assert(type(resource_name) == "string" and resource_name ~= "", "Export alias resource must be a non-empty string")
|
||||
assert(type(export_name) == "string" and export_name ~= "", "Export alias name must be a non-empty string")
|
||||
assert(type(handler) == "function", "Export alias handler must be a function")
|
||||
|
||||
AddEventHandler(("__cfx_export_%s_%s"):format(resource_name, export_name), function(set_callback)
|
||||
set_callback(function(...)
|
||||
local debug_custom_app = SkyPhoneApps and SkyPhoneApps.Debug
|
||||
local invoking_resource = GetInvokingResource and GetInvokingResource() or nil
|
||||
if debug_custom_app then
|
||||
debug_custom_app(
|
||||
"export",
|
||||
"call provider=%s export=%s invoking_resource=%s argument_count=%s",
|
||||
resource_name,
|
||||
export_name,
|
||||
tostring(invoking_resource),
|
||||
select("#", ...)
|
||||
)
|
||||
end
|
||||
|
||||
local results = table.pack(handler(...))
|
||||
if debug_custom_app then
|
||||
debug_custom_app(
|
||||
"export",
|
||||
"result provider=%s export=%s invoking_resource=%s success=%s error=%s",
|
||||
resource_name,
|
||||
export_name,
|
||||
tostring(invoking_resource),
|
||||
tostring(results[1]),
|
||||
tostring(results[2])
|
||||
)
|
||||
end
|
||||
return table.unpack(results, 1, results.n)
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
function SkyPhoneCompatibility.EmitServerProviderStop(resource_name)
|
||||
assert(type(resource_name) == "string" and resource_name ~= "", "Provider resource must be a non-empty string")
|
||||
|
||||
TriggerEvent("onServerResourceStop", resource_name)
|
||||
TriggerEvent("onResourceStop", resource_name)
|
||||
end
|
||||
|
||||
function SkyPhoneCompatibility.EmitServerProviderStart(resource_name)
|
||||
assert(type(resource_name) == "string" and resource_name ~= "", "Provider resource must be a non-empty string")
|
||||
|
||||
TriggerEvent("onServerResourceStart", resource_name)
|
||||
TriggerEvent("onResourceStart", resource_name)
|
||||
end
|
||||
|
||||
SkyPhoneCompatibility.Providers = {
|
||||
lb = "lb_phone",
|
||||
seventeen = "17mov",
|
||||
high = "high_phone",
|
||||
quasar = "quasar_v3",
|
||||
yseries = "yseries",
|
||||
}
|
||||
|
||||
function SkyPhoneCompatibility.NormalizeAtResourceUrl(owner_resource, url, error_prefix)
|
||||
if type(url) ~= "string" or url == "" then
|
||||
return nil, "invalid_" .. error_prefix
|
||||
end
|
||||
if url:sub(1, 7) == "http://" then
|
||||
return nil, "insecure_" .. error_prefix
|
||||
end
|
||||
if url:sub(1, 1) ~= "@" then
|
||||
return url
|
||||
end
|
||||
|
||||
local url_owner, url_path = url:match("^@([^/]+)/(.+)$")
|
||||
if url_owner ~= owner_resource or not url_path then
|
||||
return nil, error_prefix .. "_owner_mismatch"
|
||||
end
|
||||
return owner_resource .. "/" .. url_path
|
||||
end
|
||||
@@ -1,103 +0,0 @@
|
||||
local compatibility = assert(SkyPhoneCompatibility, "Phone compatibility shared core must load before the High provider")
|
||||
local providers = assert(compatibility.Providers, "Phone compatibility providers are unavailable")
|
||||
local normalize_at_resource_url = assert(
|
||||
compatibility.NormalizeAtResourceUrl,
|
||||
"Phone compatibility URL validator is unavailable"
|
||||
)
|
||||
|
||||
local NOTIFICATION_APP_IDS = {
|
||||
bank = "banking",
|
||||
banking = "banking",
|
||||
mail = "mail",
|
||||
messages = "messages",
|
||||
phone = "phone",
|
||||
settings = "settings",
|
||||
twizzler = "flare",
|
||||
}
|
||||
|
||||
function compatibility.MapHighNotification(notification)
|
||||
local application = type(notification) == "table" and notification.application or nil
|
||||
if type(notification) ~= "table"
|
||||
or type(application) ~= "table"
|
||||
or type(application.name) ~= "string"
|
||||
or type(notification.title) ~= "string"
|
||||
or type(notification.content) ~= "string"
|
||||
then
|
||||
return nil, "unsupported_notification"
|
||||
end
|
||||
|
||||
local app_name = application.name:match("^%s*(.-)%s*$"):lower()
|
||||
return {
|
||||
appId = NOTIFICATION_APP_IDS[app_name] or app_name,
|
||||
text = notification.content,
|
||||
title = notification.title,
|
||||
}
|
||||
end
|
||||
|
||||
local function build_locale_maps(app_name, locales)
|
||||
local names = {}
|
||||
local descriptions = {}
|
||||
|
||||
if type(locales) == "table" then
|
||||
for locale, locale_data in pairs(locales) do
|
||||
if type(locale) == "string" and type(locale_data) == "table" then
|
||||
if type(locale_data.label) == "string" and locale_data.label ~= "" then
|
||||
names[locale] = locale_data.label
|
||||
end
|
||||
if type(locale_data.description) == "string" and locale_data.description ~= "" then
|
||||
descriptions[locale] = locale_data.description
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if not next(names) then
|
||||
return app_name, app_name
|
||||
end
|
||||
for locale, label in pairs(names) do
|
||||
if not descriptions[locale] then
|
||||
descriptions[locale] = label
|
||||
end
|
||||
end
|
||||
return names, descriptions
|
||||
end
|
||||
|
||||
function compatibility.BuildHighDefinition(owner_resource, app_name, data, locales)
|
||||
if type(owner_resource) ~= "string" or type(app_name) ~= "string" or type(data) ~= "table" then
|
||||
return nil, "invalid_app_data"
|
||||
end
|
||||
if #app_name > 64 or not app_name:match("^[a-z0-9][a-z0-9._-]+$") then
|
||||
return nil, "invalid_app_id"
|
||||
end
|
||||
|
||||
local external_url, url_error = normalize_at_resource_url(
|
||||
owner_resource,
|
||||
data.externalUrl,
|
||||
"external_url"
|
||||
)
|
||||
if not external_url then
|
||||
return nil, url_error
|
||||
end
|
||||
|
||||
local name, description = build_locale_maps(app_name, locales)
|
||||
local icon = type(data.icon) == "table" and data.icon.imageUrl or data.icon
|
||||
return {
|
||||
schemaVersion = 1,
|
||||
id = app_name,
|
||||
name = name,
|
||||
description = description,
|
||||
developer = data.developer,
|
||||
category = "utilities",
|
||||
ui = external_url,
|
||||
bridgeMode = "legacy",
|
||||
icon = icon,
|
||||
iconBackground = type(data.icon) == "table" and data.icon.background or nil,
|
||||
defaultInstalled = data.preAdded or false,
|
||||
removable = data.removable ~= false,
|
||||
orientation = "portrait",
|
||||
compatibility = {
|
||||
provider = providers.high,
|
||||
apiVersion = 1,
|
||||
},
|
||||
}
|
||||
end
|
||||
@@ -1,92 +0,0 @@
|
||||
local compatibility = assert(SkyPhoneCompatibility, "Phone compatibility shared core must load before the LB provider")
|
||||
local providers = assert(compatibility.Providers, "Phone compatibility providers are unavailable")
|
||||
local normalize_at_resource_url = assert(
|
||||
compatibility.NormalizeAtResourceUrl,
|
||||
"Phone compatibility URL validator is unavailable"
|
||||
)
|
||||
|
||||
local function resolve_lb_asset_resource(owner_resource, app_data)
|
||||
local ui = app_data.ui
|
||||
if type(ui) ~= "string" then
|
||||
return nil
|
||||
end
|
||||
|
||||
local explicit_ui_resource = ui:match("^https://cfx%-nui%-([^/]+)/")
|
||||
or ui:match("^nui://([^/]+)/")
|
||||
local asset_resource = explicit_ui_resource or ui:match("^([%w][%w._-]*)/")
|
||||
if not asset_resource or asset_resource == owner_resource then
|
||||
return nil
|
||||
end
|
||||
|
||||
if explicit_ui_resource or app_data.resource == asset_resource then
|
||||
return asset_resource
|
||||
end
|
||||
|
||||
local icon = app_data.icon
|
||||
local icon_resource = type(icon) == "string" and (
|
||||
icon:match("^https://cfx%-nui%-([^/]+)/") or icon:match("^nui://([^/]+)/")
|
||||
) or nil
|
||||
if icon_resource == asset_resource then
|
||||
return asset_resource
|
||||
end
|
||||
|
||||
return nil
|
||||
end
|
||||
|
||||
local function combine_lifecycle_hooks(first_hook, second_hook)
|
||||
if first_hook == nil then
|
||||
return second_hook
|
||||
end
|
||||
if second_hook == nil or second_hook == first_hook then
|
||||
return first_hook
|
||||
end
|
||||
|
||||
return function(...)
|
||||
local first_result = table.pack(pcall(first_hook, ...))
|
||||
local second_result = table.pack(pcall(second_hook, ...))
|
||||
|
||||
if not first_result[1] then
|
||||
error(first_result[2], 0)
|
||||
end
|
||||
if not second_result[1] then
|
||||
error(second_result[2], 0)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function compatibility.BuildLbDefinition(owner_resource, app_data)
|
||||
if type(app_data) ~= "table" or type(app_data.identifier) ~= "string" then
|
||||
return nil, "invalid_app_data"
|
||||
end
|
||||
|
||||
local ui, ui_error = normalize_at_resource_url(owner_resource, app_data.ui, "ui")
|
||||
if not ui then
|
||||
return nil, ui_error
|
||||
end
|
||||
|
||||
return {
|
||||
schemaVersion = 1,
|
||||
id = app_data.identifier,
|
||||
name = app_data.name,
|
||||
description = app_data.description,
|
||||
developer = app_data.developer,
|
||||
category = app_data.game and "games" or "utilities",
|
||||
ui = ui,
|
||||
assetResource = resolve_lb_asset_resource(owner_resource, app_data),
|
||||
bridgeMode = "legacy",
|
||||
icon = app_data.icon,
|
||||
defaultInstalled = app_data.defaultApp or false,
|
||||
removable = not app_data.defaultApp,
|
||||
orientation = app_data.landscape and "landscape" or "portrait",
|
||||
onInstall = app_data.onInstall,
|
||||
onDelete = app_data.onDelete,
|
||||
onOpen = combine_lifecycle_hooks(app_data.onOpen, app_data.onUse),
|
||||
onClose = app_data.onClose,
|
||||
compatibility = {
|
||||
provider = providers.lb,
|
||||
apiVersion = 1,
|
||||
fixBlur = app_data.fixBlur == true,
|
||||
resourceName = type(app_data.resource) == "string" and app_data.resource or owner_resource,
|
||||
},
|
||||
}
|
||||
end
|
||||
@@ -1,49 +0,0 @@
|
||||
local compatibility = assert(SkyPhoneCompatibility, "Phone compatibility shared core must load before the Quasar provider")
|
||||
local providers = assert(compatibility.Providers, "Phone compatibility providers are unavailable")
|
||||
|
||||
function compatibility.CopyQuasarData(app_data)
|
||||
local copy = {}
|
||||
for key, value in pairs(app_data) do
|
||||
if key == "iframe" and type(value) == "table" then
|
||||
copy.iframe = {}
|
||||
for iframe_key, iframe_value in pairs(value) do
|
||||
copy.iframe[iframe_key] = iframe_value
|
||||
end
|
||||
else
|
||||
copy[key] = value
|
||||
end
|
||||
end
|
||||
return copy
|
||||
end
|
||||
|
||||
function compatibility.BuildQuasarDefinition(app_data)
|
||||
local iframe_url = type(app_data) == "table"
|
||||
and type(app_data.iframe) == "table"
|
||||
and app_data.iframe.url
|
||||
or nil
|
||||
if type(app_data) ~= "table"
|
||||
or type(app_data.id) ~= "string"
|
||||
or type(app_data.label) ~= "string"
|
||||
or type(iframe_url) ~= "string"
|
||||
then
|
||||
return nil, "invalid_app_data"
|
||||
end
|
||||
|
||||
return {
|
||||
schemaVersion = 1,
|
||||
id = app_data.id,
|
||||
name = app_data.label,
|
||||
description = app_data.description or app_data.label,
|
||||
category = "utilities",
|
||||
ui = iframe_url,
|
||||
bridgeMode = "legacy",
|
||||
icon = app_data.icon,
|
||||
defaultInstalled = app_data.defaultInstalled or false,
|
||||
removable = true,
|
||||
orientation = "portrait",
|
||||
compatibility = {
|
||||
provider = providers.quasar,
|
||||
apiVersion = 1,
|
||||
},
|
||||
}
|
||||
end
|
||||
@@ -1,51 +0,0 @@
|
||||
local compatibility = assert(SkyPhoneCompatibility, "Phone compatibility shared core must load before the 17mov provider")
|
||||
local providers = assert(compatibility.Providers, "Phone compatibility providers are unavailable")
|
||||
|
||||
local NOTIFICATION_APP_IDS = {
|
||||
BANK = "banking",
|
||||
MESSAGES = "messages",
|
||||
SYSTEM = "settings",
|
||||
}
|
||||
|
||||
function compatibility.Map17MovNotification(notification)
|
||||
if type(notification) ~= "table"
|
||||
or type(notification.app) ~= "string"
|
||||
or type(notification.title) ~= "string"
|
||||
or type(notification.message) ~= "string"
|
||||
then
|
||||
return nil, "unsupported_notification"
|
||||
end
|
||||
|
||||
local app_name = notification.app:match("^%s*(.-)%s*$")
|
||||
local app_id = NOTIFICATION_APP_IDS[app_name:upper()] or app_name:lower()
|
||||
return {
|
||||
appId = app_id,
|
||||
text = notification.message,
|
||||
title = notification.title,
|
||||
}
|
||||
end
|
||||
|
||||
function compatibility.Build17MovDefinition(app_data)
|
||||
if type(app_data) ~= "table" or type(app_data.name) ~= "string" then
|
||||
return nil, "invalid_app_data"
|
||||
end
|
||||
|
||||
return {
|
||||
schemaVersion = 1,
|
||||
id = app_data.name,
|
||||
name = app_data.label,
|
||||
description = app_data.description or app_data.label,
|
||||
category = "utilities",
|
||||
ui = app_data.ui,
|
||||
bridgeMode = "legacy",
|
||||
icon = app_data.icon,
|
||||
iconBackground = type(app_data.iconBackground) == "string" and app_data.iconBackground or nil,
|
||||
defaultInstalled = app_data.default or app_data.preInstalled or false,
|
||||
removable = not app_data.default,
|
||||
orientation = "portrait",
|
||||
compatibility = {
|
||||
provider = providers.seventeen,
|
||||
apiVersion = 1,
|
||||
},
|
||||
}
|
||||
end
|
||||
@@ -1,31 +0,0 @@
|
||||
local compatibility = assert(SkyPhoneCompatibility, "Phone compatibility shared core must load before the YSeries provider")
|
||||
local providers = assert(compatibility.Providers, "Phone compatibility providers are unavailable")
|
||||
|
||||
function compatibility.BuildYSeriesDefinition(app_data)
|
||||
if type(app_data) ~= "table" or type(app_data.key) ~= "string" then
|
||||
return nil, "invalid_app_data"
|
||||
end
|
||||
|
||||
local icon = app_data.icon
|
||||
if type(icon) == "table" then
|
||||
icon = icon.yos or icon.humanoid
|
||||
end
|
||||
|
||||
return {
|
||||
schemaVersion = 1,
|
||||
id = app_data.key,
|
||||
name = app_data.name,
|
||||
description = app_data.description or app_data.name,
|
||||
category = app_data.game and "games" or "utilities",
|
||||
ui = app_data.ui,
|
||||
bridgeMode = "legacy",
|
||||
icon = icon,
|
||||
defaultInstalled = app_data.defaultApp or false,
|
||||
removable = true,
|
||||
orientation = "portrait",
|
||||
compatibility = {
|
||||
provider = providers.yseries,
|
||||
apiVersion = 1,
|
||||
},
|
||||
}
|
||||
end
|
||||
@@ -1,176 +0,0 @@
|
||||
SkyPhoneCalls = {}
|
||||
|
||||
local active_call_payload = nil
|
||||
local call_channel = 0
|
||||
|
||||
local function copy_payload(value)
|
||||
if type(value) ~= "table" then
|
||||
return value
|
||||
end
|
||||
|
||||
local copy = {}
|
||||
for key, nested_value in pairs(value) do
|
||||
copy[key] = copy_payload(nested_value)
|
||||
end
|
||||
return copy
|
||||
end
|
||||
|
||||
local function emit_call_changed(call)
|
||||
TriggerEvent("sky_phone:client:callChanged", copy_payload(call))
|
||||
end
|
||||
|
||||
local function request_call_action(action, call)
|
||||
local result = Bridge.Callbacks.Trigger("sky_phone:calls:" .. action, { id = call.id })
|
||||
if not result or not result.success then
|
||||
local call_error = result and result.error or "request_failed"
|
||||
Bridge.Debug(
|
||||
"error",
|
||||
"[sky_phone] Call %s request failed: %s.",
|
||||
action,
|
||||
tostring(call_error)
|
||||
)
|
||||
return false, call_error
|
||||
end
|
||||
|
||||
return true
|
||||
end
|
||||
|
||||
local function leave_voice()
|
||||
if call_channel == 0 then
|
||||
return
|
||||
end
|
||||
Bridge.Calls.Leave()
|
||||
call_channel = 0
|
||||
end
|
||||
|
||||
local function join_voice(channel)
|
||||
local next_channel = tonumber(channel) or 0
|
||||
if not Bridge.Calls.Join(next_channel) then
|
||||
return false
|
||||
end
|
||||
call_channel = next_channel
|
||||
return true
|
||||
end
|
||||
|
||||
function SkyPhoneCalls.Dial(phone_number, company)
|
||||
if (phone_number ~= nil and (type(phone_number) ~= "string" or phone_number == ""))
|
||||
or (company ~= nil and (type(company) ~= "string" or company == ""))
|
||||
or (phone_number == nil and company == nil)
|
||||
then
|
||||
Bridge.Debug("error", "[sky_phone] Rejected invalid call target.")
|
||||
return false, "invalid_request"
|
||||
end
|
||||
|
||||
local result = Bridge.Callbacks.Trigger("sky_phone:calls:dial", {
|
||||
company = company,
|
||||
phoneNumber = phone_number,
|
||||
})
|
||||
if not result or not result.success then
|
||||
local call_error = result and result.error or "request_failed"
|
||||
Bridge.Debug("error", "[sky_phone] Call request failed: %s.", tostring(call_error))
|
||||
return false, call_error
|
||||
end
|
||||
|
||||
return true
|
||||
end
|
||||
|
||||
function SkyPhoneCalls.IsActive()
|
||||
return active_call_payload ~= nil
|
||||
end
|
||||
|
||||
function SkyPhoneCalls.GetActive()
|
||||
if not active_call_payload then
|
||||
return nil
|
||||
end
|
||||
return copy_payload(active_call_payload)
|
||||
end
|
||||
|
||||
function SkyPhoneCalls.Answer()
|
||||
local call = active_call_payload
|
||||
if not call or call.state ~= "ringing" or call.direction ~= "incoming" then
|
||||
return false, "call_not_found"
|
||||
end
|
||||
return request_call_action("answer", call)
|
||||
end
|
||||
|
||||
function SkyPhoneCalls.Decline()
|
||||
local call = active_call_payload
|
||||
if not call or call.state ~= "ringing" or call.direction ~= "incoming" then
|
||||
return false, "call_not_found"
|
||||
end
|
||||
return request_call_action("decline", call)
|
||||
end
|
||||
|
||||
function SkyPhoneCalls.Hangup()
|
||||
local call = active_call_payload
|
||||
if not call or (call.state ~= "ringing" and call.state ~= "connected") then
|
||||
return false, "call_not_found"
|
||||
end
|
||||
return request_call_action("hangup", call)
|
||||
end
|
||||
|
||||
function SkyPhoneCalls.Terminate()
|
||||
local call = active_call_payload
|
||||
if not call or (call.state ~= "ringing" and call.state ~= "connected") then
|
||||
return false, "call_not_found"
|
||||
end
|
||||
return request_call_action("terminate", call)
|
||||
end
|
||||
|
||||
function SkyPhoneCalls.ReleaseFocus()
|
||||
SkyPhoneFocus.SetCall(false)
|
||||
end
|
||||
|
||||
function SkyPhoneCalls.ReplayNui()
|
||||
if active_call_payload then
|
||||
SendNUIMessage({ type = "call:state", data = active_call_payload })
|
||||
end
|
||||
end
|
||||
|
||||
function SkyPhoneCalls.Reset()
|
||||
local had_active_call = active_call_payload ~= nil
|
||||
active_call_payload = nil
|
||||
SkyPhoneFocus.SetCall(false)
|
||||
leave_voice()
|
||||
if had_active_call then
|
||||
emit_call_changed(nil)
|
||||
end
|
||||
end
|
||||
|
||||
RegisterNetEvent("sky_phone:call:incoming", function(data)
|
||||
if type(data) ~= "table" or type(data.id) ~= "string" or data.state ~= "ringing" then
|
||||
Bridge.Debug("error", "[sky_phone] Rejected invalid incoming call data.")
|
||||
return
|
||||
end
|
||||
active_call_payload = data
|
||||
SkyPhoneFocus.SetCall(true)
|
||||
emit_call_changed(data)
|
||||
TriggerEvent("sky_phone:animation:call", data)
|
||||
SendNUIMessage({ type = "call:incoming", data = data })
|
||||
end)
|
||||
|
||||
RegisterNetEvent("sky_phone:call:state", function(data)
|
||||
if type(data) ~= "table" or type(data.id) ~= "string" or type(data.state) ~= "string" then
|
||||
Bridge.Debug("error", "[sky_phone] Rejected invalid call state data.")
|
||||
return
|
||||
end
|
||||
if data.state == "ringing" or data.state == "connected" then
|
||||
active_call_payload = data
|
||||
end
|
||||
if data.state ~= "ringing" then
|
||||
SkyPhoneFocus.SetCall(false)
|
||||
end
|
||||
if data.state ~= "ringing" and data.state ~= "connected" then
|
||||
active_call_payload = nil
|
||||
end
|
||||
if data.state == "connected" and data.channel then
|
||||
if not join_voice(data.channel) then
|
||||
TriggerEvent("sky_phone:device:error", "voice_unavailable")
|
||||
end
|
||||
elseif data.state ~= "ringing" then
|
||||
leave_voice()
|
||||
end
|
||||
emit_call_changed(data)
|
||||
TriggerEvent("sky_phone:animation:call", data)
|
||||
SendNUIMessage({ type = "call:state", data = data })
|
||||
end)
|
||||
@@ -1,5 +1,3 @@
|
||||
SkyPhoneCamera = {}
|
||||
|
||||
local minimum_zoom = 0.5
|
||||
local maximum_zoom = 3.0
|
||||
local mouse_wheel_zoom_step = 0.08
|
||||
@@ -53,7 +51,6 @@ local camera_state = {
|
||||
previous_ped_view = nil,
|
||||
previous_radar_hidden = nil,
|
||||
previous_vehicle_view = nil,
|
||||
walkable = false,
|
||||
zoom = 1.0,
|
||||
}
|
||||
|
||||
@@ -253,7 +250,6 @@ local function set_camera_active(active)
|
||||
return
|
||||
end
|
||||
camera_state.active = active
|
||||
TriggerEvent("sky_phone:client:cameraActiveChanged", active)
|
||||
if active then
|
||||
camera_state.front_camera = false
|
||||
camera_state.landscape = false
|
||||
@@ -293,7 +289,6 @@ local function set_camera_active(active)
|
||||
camera_state.game_input = false
|
||||
camera_state.landscape = false
|
||||
camera_state.locked = false
|
||||
camera_state.walkable = false
|
||||
clear_front_camera()
|
||||
restore_camera_view()
|
||||
camera_state.nui_focused = true
|
||||
@@ -340,50 +335,11 @@ local function set_camera_landscape(active)
|
||||
end
|
||||
end
|
||||
|
||||
local function enable_walkable_camera(selfie_mode)
|
||||
set_camera_active(true)
|
||||
camera_state.walkable = true
|
||||
set_front_camera(selfie_mode == true)
|
||||
set_camera_focus(false)
|
||||
end
|
||||
|
||||
local function disable_walkable_camera()
|
||||
if not camera_state.walkable then
|
||||
return
|
||||
end
|
||||
|
||||
set_camera_active(false)
|
||||
end
|
||||
|
||||
local function toggle_camera_frozen()
|
||||
camera_state.locked = not camera_state.locked
|
||||
end
|
||||
|
||||
local function get_camera_state()
|
||||
return {
|
||||
active = camera_state.active,
|
||||
flashEnabled = camera_state.flash_enabled,
|
||||
frozen = camera_state.locked,
|
||||
selfie = camera_state.front_camera,
|
||||
walkable = camera_state.walkable and camera_state.active,
|
||||
}
|
||||
end
|
||||
|
||||
SkyPhoneCamera.DisableWalkable = disable_walkable_camera
|
||||
SkyPhoneCamera.EnableWalkable = enable_walkable_camera
|
||||
SkyPhoneCamera.GetState = get_camera_state
|
||||
SkyPhoneCamera.SetFlashlight = set_flash_enabled
|
||||
SkyPhoneCamera.SetSelfie = set_front_camera
|
||||
SkyPhoneCamera.ToggleFrozen = toggle_camera_frozen
|
||||
|
||||
RegisterNUICallback("camera:setActive", function(data, cb)
|
||||
if type(data) ~= "table" then
|
||||
cb({ success = false, error = "invalid_request" })
|
||||
return
|
||||
end
|
||||
if data.active == true then
|
||||
camera_state.walkable = false
|
||||
end
|
||||
set_camera_active(data.active == true)
|
||||
cb({ success = true })
|
||||
end)
|
||||
|
||||
@@ -0,0 +1,747 @@
|
||||
local RESOURCE_NAME = GetCurrentResourceName()
|
||||
local providers = SkyPhoneCompatibility.Providers
|
||||
local core = SkyPhoneApps.CompatibilityCore
|
||||
local debug_custom_app = SkyPhoneApps.Debug or function() end
|
||||
local provider_apps = {}
|
||||
local high_client_apps = {}
|
||||
local high_server_apps = {}
|
||||
|
||||
local function get_calling_resource(export_name)
|
||||
local owner_resource = GetInvokingResource()
|
||||
if owner_resource then
|
||||
return owner_resource
|
||||
end
|
||||
|
||||
Bridge.Debug("warn", "[%s] %s rejected: the export must be called by another resource.",
|
||||
RESOURCE_NAME,
|
||||
export_name
|
||||
)
|
||||
return nil, "invalid_owner"
|
||||
end
|
||||
|
||||
local function copy_record_data(value)
|
||||
if type(value) ~= "table" then
|
||||
return nil
|
||||
end
|
||||
|
||||
local copied = {}
|
||||
for key, nested_value in pairs(value) do
|
||||
copied[key] = nested_value
|
||||
end
|
||||
return copied
|
||||
end
|
||||
|
||||
local function register_provider_app(provider, owner_resource, definition, vendor_data)
|
||||
local app_id = definition.id
|
||||
local existing = provider_apps[app_id]
|
||||
debug_custom_app(
|
||||
"provider",
|
||||
"registration requested provider=%s id=%s owner=%s operation=%s",
|
||||
tostring(provider),
|
||||
tostring(app_id),
|
||||
tostring(owner_resource),
|
||||
existing and "update" or "add"
|
||||
)
|
||||
if existing and (existing.owner_resource ~= owner_resource or existing.provider ~= provider) then
|
||||
debug_custom_app(
|
||||
"provider",
|
||||
"registration rejected provider=%s id=%s owner=%s existing_provider=%s existing_owner=%s error=duplicate_app_id",
|
||||
tostring(provider),
|
||||
tostring(app_id),
|
||||
tostring(owner_resource),
|
||||
tostring(existing.provider),
|
||||
tostring(existing.owner_resource)
|
||||
)
|
||||
return false, "duplicate_app_id"
|
||||
end
|
||||
|
||||
local success, error_message
|
||||
if existing then
|
||||
success, error_message = core.Update(owner_resource, definition)
|
||||
else
|
||||
success, error_message = core.Add(owner_resource, definition)
|
||||
end
|
||||
if success then
|
||||
provider_apps[app_id] = {
|
||||
definition = definition,
|
||||
owner_resource = owner_resource,
|
||||
provider = provider,
|
||||
vendor_data = vendor_data,
|
||||
}
|
||||
end
|
||||
debug_custom_app(
|
||||
"provider",
|
||||
"registration result provider=%s id=%s owner=%s success=%s error=%s",
|
||||
tostring(provider),
|
||||
tostring(app_id),
|
||||
tostring(owner_resource),
|
||||
tostring(success),
|
||||
tostring(error_message)
|
||||
)
|
||||
return success, error_message
|
||||
end
|
||||
|
||||
local function get_provider_app(owner_resource, app_id, allowed_providers)
|
||||
if type(app_id) ~= "string" then
|
||||
return nil, "invalid_app_id"
|
||||
end
|
||||
|
||||
local record = provider_apps[app_id]
|
||||
if not record then
|
||||
return nil, "app_not_found"
|
||||
end
|
||||
if record.owner_resource ~= owner_resource then
|
||||
return nil, "app_owner_mismatch"
|
||||
end
|
||||
if allowed_providers and not allowed_providers[record.provider] then
|
||||
return nil, "app_provider_mismatch"
|
||||
end
|
||||
return record
|
||||
end
|
||||
|
||||
local function remove_provider_app(owner_resource, app_id, allowed_providers)
|
||||
local record, record_error = get_provider_app(owner_resource, app_id, allowed_providers)
|
||||
if not record then
|
||||
return false, record_error
|
||||
end
|
||||
|
||||
return core.Remove(owner_resource, app_id)
|
||||
end
|
||||
|
||||
function SkyPhoneApps.RegisterCompatibilityExport(owner_resource, app_data)
|
||||
debug_custom_app(
|
||||
"provider",
|
||||
"compatibility export received owner=%s identifier=%s key=%s data_type=%s",
|
||||
tostring(owner_resource),
|
||||
type(app_data) == "table" and tostring(app_data.identifier) or "nil",
|
||||
type(app_data) == "table" and tostring(app_data.key) or "nil",
|
||||
type(app_data)
|
||||
)
|
||||
local provider
|
||||
local definition
|
||||
local definition_error
|
||||
if app_data.identifier ~= nil then
|
||||
provider = providers.lb
|
||||
definition, definition_error = SkyPhoneCompatibility.BuildLbDefinition(owner_resource, app_data)
|
||||
elseif app_data.key ~= nil then
|
||||
provider = providers.yseries
|
||||
definition, definition_error = SkyPhoneCompatibility.BuildYSeriesDefinition(app_data)
|
||||
else
|
||||
debug_custom_app(
|
||||
"provider",
|
||||
"compatibility export rejected owner=%s error=unknown_app_provider",
|
||||
tostring(owner_resource)
|
||||
)
|
||||
return false, "unknown_app_provider"
|
||||
end
|
||||
|
||||
if not definition then
|
||||
debug_custom_app(
|
||||
"provider",
|
||||
"definition rejected provider=%s owner=%s error=%s",
|
||||
tostring(provider),
|
||||
tostring(owner_resource),
|
||||
tostring(definition_error)
|
||||
)
|
||||
Bridge.Debug("warn", "[%s] %s registration rejected for %s: %s.",
|
||||
RESOURCE_NAME,
|
||||
provider,
|
||||
owner_resource,
|
||||
definition_error
|
||||
)
|
||||
return false, definition_error
|
||||
end
|
||||
debug_custom_app(
|
||||
"provider",
|
||||
"definition built provider=%s id=%s owner=%s ui=%s icon=%s default_installed=%s fix_blur=%s",
|
||||
tostring(provider),
|
||||
tostring(definition.id),
|
||||
tostring(owner_resource),
|
||||
tostring(definition.ui),
|
||||
tostring(definition.icon),
|
||||
tostring(definition.defaultInstalled),
|
||||
definition.compatibility and tostring(definition.compatibility.fixBlur) or "nil"
|
||||
)
|
||||
return register_provider_app(provider, owner_resource, definition, copy_record_data(app_data))
|
||||
end
|
||||
|
||||
SkyPhoneApps.OnCompatibilityAppRemoved = function(owner_resource, app_id)
|
||||
local record = provider_apps[app_id]
|
||||
if record and record.owner_resource == owner_resource then
|
||||
provider_apps[app_id] = nil
|
||||
end
|
||||
end
|
||||
|
||||
local function add_17mov_application(app_data)
|
||||
local owner_resource, owner_error = get_calling_resource("AddApplication")
|
||||
if not owner_resource then
|
||||
return false, owner_error
|
||||
end
|
||||
|
||||
local definition, definition_error = SkyPhoneCompatibility.Build17MovDefinition(app_data)
|
||||
if not definition then
|
||||
Bridge.Debug("warn", "[%s] 17mov registration rejected for %s: %s.",
|
||||
RESOURCE_NAME,
|
||||
owner_resource,
|
||||
definition_error
|
||||
)
|
||||
return false, definition_error
|
||||
end
|
||||
return register_provider_app(
|
||||
providers.seventeen,
|
||||
owner_resource,
|
||||
definition,
|
||||
copy_record_data(app_data)
|
||||
)
|
||||
end
|
||||
|
||||
local function remove_17mov_application(app_name, _resource_name)
|
||||
local owner_resource, owner_error = get_calling_resource("RemoveApplication")
|
||||
if not owner_resource then
|
||||
return false, owner_error
|
||||
end
|
||||
|
||||
local app_id = type(app_name) == "table" and app_name.name or app_name
|
||||
return remove_provider_app(owner_resource, app_id, {
|
||||
[providers.seventeen] = true,
|
||||
})
|
||||
end
|
||||
|
||||
local function send_app_message(app_id, data)
|
||||
local owner_resource, owner_error = get_calling_resource("SendAppMessage")
|
||||
if not owner_resource then
|
||||
return false, owner_error
|
||||
end
|
||||
|
||||
local record, record_error = get_provider_app(owner_resource, app_id, {
|
||||
[providers.seventeen] = true,
|
||||
[providers.yseries] = true,
|
||||
})
|
||||
if not record then
|
||||
return false, record_error
|
||||
end
|
||||
return core.SendMessage(owner_resource, app_id, data)
|
||||
end
|
||||
|
||||
local function add_high_application(app_name, data, locales)
|
||||
local owner_resource, owner_error = get_calling_resource("addApplication")
|
||||
if not owner_resource then
|
||||
return false, owner_error
|
||||
end
|
||||
|
||||
local definition, definition_error = SkyPhoneCompatibility.BuildHighDefinition(
|
||||
owner_resource,
|
||||
app_name,
|
||||
data,
|
||||
locales
|
||||
)
|
||||
if not definition then
|
||||
Bridge.Debug("warn", "[%s] High Phone registration rejected for %s: %s.",
|
||||
RESOURCE_NAME,
|
||||
owner_resource,
|
||||
definition_error
|
||||
)
|
||||
return false, definition_error
|
||||
end
|
||||
|
||||
high_client_apps[app_name] = {
|
||||
definition = definition,
|
||||
owner_resource = owner_resource,
|
||||
}
|
||||
local server_record = high_server_apps[app_name]
|
||||
if server_record then
|
||||
if server_record.owner_resource ~= owner_resource then
|
||||
return false, "duplicate_app_id"
|
||||
end
|
||||
if server_record.registered then
|
||||
return true
|
||||
end
|
||||
return false, "server_definition_not_registered"
|
||||
end
|
||||
|
||||
return register_provider_app(
|
||||
providers.high,
|
||||
owner_resource,
|
||||
definition,
|
||||
copy_record_data(data)
|
||||
)
|
||||
end
|
||||
|
||||
local function send_high_app_nui(app_name, data)
|
||||
local owner_resource, owner_error = get_calling_resource("sendAppNui")
|
||||
if not owner_resource then
|
||||
return false, owner_error
|
||||
end
|
||||
|
||||
local record, record_error = get_provider_app(owner_resource, app_name, {
|
||||
[providers.high] = true,
|
||||
})
|
||||
if not record then
|
||||
return false, record_error
|
||||
end
|
||||
return core.SendMessage(owner_resource, app_name, data)
|
||||
end
|
||||
|
||||
local function add_quasar_app_for_owner(owner_resource, app_data)
|
||||
local definition, definition_error = SkyPhoneCompatibility.BuildQuasarDefinition(app_data)
|
||||
if not definition then
|
||||
Bridge.Debug("warn", "[%s] Quasar registration rejected for %s: %s.",
|
||||
RESOURCE_NAME,
|
||||
owner_resource,
|
||||
definition_error
|
||||
)
|
||||
return false, definition_error
|
||||
end
|
||||
|
||||
return register_provider_app(
|
||||
providers.quasar,
|
||||
owner_resource,
|
||||
definition,
|
||||
SkyPhoneCompatibility.CopyQuasarData(app_data)
|
||||
)
|
||||
end
|
||||
|
||||
local function add_quasar_app(app_data)
|
||||
local owner_resource, owner_error = get_calling_resource("addCustomApp")
|
||||
if not owner_resource then
|
||||
return false, owner_error
|
||||
end
|
||||
if type(app_data) ~= "table" then
|
||||
return false, "invalid_app_data"
|
||||
end
|
||||
return add_quasar_app_for_owner(owner_resource, app_data)
|
||||
end
|
||||
|
||||
local function add_quasar_apps_batch(apps)
|
||||
local owner_resource, owner_error = get_calling_resource("addCustomAppsBatch")
|
||||
if not owner_resource then
|
||||
return false, owner_error
|
||||
end
|
||||
if type(apps) ~= "table" then
|
||||
return false, "invalid_app_batch"
|
||||
end
|
||||
|
||||
for index = 1, #apps do
|
||||
local success, error_message = add_quasar_app_for_owner(owner_resource, apps[index])
|
||||
if not success then
|
||||
return false, error_message
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
local function update_quasar_app(app_id, patch)
|
||||
local owner_resource, owner_error = get_calling_resource("updateCustomApp")
|
||||
if not owner_resource then
|
||||
return false, owner_error
|
||||
end
|
||||
if type(patch) ~= "table" then
|
||||
return false, "invalid_patch"
|
||||
end
|
||||
|
||||
local record, record_error = get_provider_app(owner_resource, app_id, {
|
||||
[providers.quasar] = true,
|
||||
})
|
||||
if not record then
|
||||
return false, record_error
|
||||
end
|
||||
|
||||
local merged = SkyPhoneCompatibility.CopyQuasarData(record.vendor_data)
|
||||
for key, value in pairs(patch) do
|
||||
if key == "iframe" and type(value) == "table" then
|
||||
merged.iframe = merged.iframe or {}
|
||||
for iframe_key, iframe_value in pairs(value) do
|
||||
merged.iframe[iframe_key] = iframe_value
|
||||
end
|
||||
elseif key ~= "id" then
|
||||
merged[key] = value
|
||||
end
|
||||
end
|
||||
merged.id = app_id
|
||||
|
||||
local definition, definition_error = SkyPhoneCompatibility.BuildQuasarDefinition(merged)
|
||||
if not definition then
|
||||
return false, definition_error
|
||||
end
|
||||
|
||||
local success, error_message = core.Update(owner_resource, definition)
|
||||
if success then
|
||||
record.definition = definition
|
||||
record.vendor_data = SkyPhoneCompatibility.CopyQuasarData(merged)
|
||||
end
|
||||
return success, error_message
|
||||
end
|
||||
|
||||
local function remove_quasar_app(app_id)
|
||||
local owner_resource, owner_error = get_calling_resource("removeCustomApp")
|
||||
if not owner_resource then
|
||||
return false, owner_error
|
||||
end
|
||||
return remove_provider_app(owner_resource, app_id, {
|
||||
[providers.quasar] = true,
|
||||
})
|
||||
end
|
||||
|
||||
local function get_quasar_apps()
|
||||
local ids = {}
|
||||
for app_id, record in pairs(provider_apps) do
|
||||
if record.provider == providers.quasar then
|
||||
ids[#ids + 1] = app_id
|
||||
end
|
||||
end
|
||||
table.sort(ids)
|
||||
|
||||
local apps = {}
|
||||
for index = 1, #ids do
|
||||
apps[index] = SkyPhoneCompatibility.CopyQuasarData(provider_apps[ids[index]].vendor_data)
|
||||
end
|
||||
return apps
|
||||
end
|
||||
|
||||
local function open_quasar_app(app_id)
|
||||
local owner_resource, owner_error = get_calling_resource("OpenPhoneApp")
|
||||
if not owner_resource then
|
||||
return false, owner_error
|
||||
end
|
||||
|
||||
local record, record_error = get_provider_app(owner_resource, app_id, {
|
||||
[providers.quasar] = true,
|
||||
})
|
||||
if not record then
|
||||
return false, record_error
|
||||
end
|
||||
return core.Open(owner_resource, app_id)
|
||||
end
|
||||
|
||||
local function open_phone_app(app_id, data)
|
||||
local owner_resource, owner_error = get_calling_resource("OpenApp")
|
||||
if not owner_resource then
|
||||
return false, owner_error
|
||||
end
|
||||
|
||||
local record, record_error = get_provider_app(owner_resource, app_id)
|
||||
if not record then
|
||||
return false, record_error
|
||||
end
|
||||
return core.Open(owner_resource, app_id, data)
|
||||
end
|
||||
|
||||
local function close_phone_app(options)
|
||||
local owner_resource, owner_error = get_calling_resource("CloseApp")
|
||||
if not owner_resource then
|
||||
return false, owner_error
|
||||
end
|
||||
|
||||
local app_id = type(options) == "table" and options.app or nil
|
||||
if app_id then
|
||||
local record, record_error = get_provider_app(owner_resource, app_id, {
|
||||
[providers.lb] = true,
|
||||
[providers.yseries] = true,
|
||||
})
|
||||
if not record then
|
||||
return false, record_error
|
||||
end
|
||||
return core.Close(owner_resource, app_id)
|
||||
end
|
||||
return core.CloseActive(owner_resource)
|
||||
end
|
||||
|
||||
local function normalize_lb_notification_text(value, maximum_length, error_code)
|
||||
if type(value) ~= "string" then
|
||||
return nil, error_code
|
||||
end
|
||||
|
||||
local normalized = value:match("^%s*(.-)%s*$")
|
||||
if normalized == "" or #normalized > maximum_length then
|
||||
return nil, error_code
|
||||
end
|
||||
return normalized
|
||||
end
|
||||
|
||||
local function send_lb_notification(data)
|
||||
local owner_resource, owner_error = get_calling_resource("SendNotification")
|
||||
if not owner_resource then
|
||||
return false, owner_error
|
||||
end
|
||||
if type(data) ~= "table" then
|
||||
return false, "invalid_notification"
|
||||
end
|
||||
|
||||
local app_id = data.app or data.identifier
|
||||
local valid_app_id, app_id_error = SkyPhoneApps.ValidateAppId(app_id)
|
||||
if not valid_app_id then
|
||||
return false, app_id_error
|
||||
end
|
||||
|
||||
local registered_record = provider_apps[app_id]
|
||||
if registered_record then
|
||||
local record, record_error = get_provider_app(owner_resource, app_id, {
|
||||
[providers.lb] = true,
|
||||
})
|
||||
if not record then
|
||||
return false, record_error
|
||||
end
|
||||
elseif not SkyPhoneApps.ReservedAppIds[app_id] then
|
||||
return false, "app_not_found"
|
||||
end
|
||||
|
||||
local title, title_error = normalize_lb_notification_text(
|
||||
data.title,
|
||||
128,
|
||||
"invalid_notification_title"
|
||||
)
|
||||
if not title then
|
||||
return false, title_error
|
||||
end
|
||||
|
||||
local content, content_error = normalize_lb_notification_text(
|
||||
data.content or data.message or data.text,
|
||||
512,
|
||||
"invalid_notification_text"
|
||||
)
|
||||
if not content then
|
||||
return false, content_error
|
||||
end
|
||||
|
||||
SendNUIMessage({
|
||||
type = "notification:show",
|
||||
data = {
|
||||
appId = app_id,
|
||||
route = "/apps/" .. app_id,
|
||||
text = content,
|
||||
title = title,
|
||||
},
|
||||
})
|
||||
return true
|
||||
end
|
||||
|
||||
local function register_high_server_app(owner_resource, definition, revision)
|
||||
if type(owner_resource) ~= "string"
|
||||
or type(definition) ~= "table"
|
||||
or type(definition.id) ~= "string"
|
||||
or type(revision) ~= "number"
|
||||
or revision ~= math.floor(revision)
|
||||
or revision < 1
|
||||
then
|
||||
Bridge.Debug("warn", "[%s] Rejected invalid High Phone server application snapshot.", RESOURCE_NAME)
|
||||
return nil
|
||||
end
|
||||
|
||||
local app_id = definition.id
|
||||
local existing = high_server_apps[app_id]
|
||||
if existing and revision <= existing.revision then
|
||||
if revision < existing.revision or existing.registered then
|
||||
return app_id
|
||||
end
|
||||
if existing.owner_resource ~= owner_resource then
|
||||
Bridge.Debug("warn", "[%s] Rejected conflicting High Phone owner for %s.",
|
||||
RESOURCE_NAME,
|
||||
app_id
|
||||
)
|
||||
return app_id
|
||||
end
|
||||
end
|
||||
|
||||
local provider_record = provider_apps[app_id]
|
||||
local success, error_message
|
||||
local retained_registration = false
|
||||
if provider_record then
|
||||
if provider_record.owner_resource ~= owner_resource or provider_record.provider ~= providers.high then
|
||||
success, error_message = false, "duplicate_app_id"
|
||||
else
|
||||
retained_registration = true
|
||||
success, error_message = core.Update(owner_resource, definition)
|
||||
end
|
||||
else
|
||||
success, error_message = core.Add(owner_resource, definition)
|
||||
end
|
||||
|
||||
high_server_apps[app_id] = {
|
||||
definition = definition,
|
||||
last_error = success and nil or error_message,
|
||||
owner_resource = owner_resource,
|
||||
registered = success or retained_registration,
|
||||
revision = revision,
|
||||
}
|
||||
if success then
|
||||
provider_apps[app_id] = {
|
||||
definition = definition,
|
||||
owner_resource = owner_resource,
|
||||
provider = providers.high,
|
||||
}
|
||||
elseif not existing or existing.revision ~= revision or existing.last_error ~= error_message then
|
||||
Bridge.Debug("error", "[%s] Could not register High Phone server application %s revision %s: %s.",
|
||||
RESOURCE_NAME,
|
||||
app_id,
|
||||
revision,
|
||||
error_message or "unknown_error"
|
||||
)
|
||||
end
|
||||
return app_id
|
||||
end
|
||||
|
||||
local function remove_high_server_app(owner_resource, app_id)
|
||||
local record = high_server_apps[app_id]
|
||||
if not record or record.owner_resource ~= owner_resource then
|
||||
return
|
||||
end
|
||||
|
||||
high_server_apps[app_id] = nil
|
||||
if record.registered then
|
||||
core.Remove(owner_resource, app_id)
|
||||
end
|
||||
|
||||
local client_record = high_client_apps[app_id]
|
||||
if client_record then
|
||||
local success, error_message = register_provider_app(
|
||||
providers.high,
|
||||
client_record.owner_resource,
|
||||
client_record.definition,
|
||||
nil
|
||||
)
|
||||
if not success then
|
||||
Bridge.Debug("error", "[%s] Could not restore High Phone client application %s: %s.",
|
||||
RESOURCE_NAME,
|
||||
app_id,
|
||||
error_message or "unknown_error"
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
RegisterNetEvent("sky_phone:compat:high:client:syncApplication", function(owner_resource, definition, revision)
|
||||
if source ~= 65535 then
|
||||
Bridge.Debug("warn", "[%s] Rejected locally invoked High Phone application sync.", RESOURCE_NAME)
|
||||
return
|
||||
end
|
||||
register_high_server_app(owner_resource, definition, revision)
|
||||
end)
|
||||
|
||||
RegisterNetEvent("sky_phone:compat:high:client:removeApplication", function(owner_resource, app_id)
|
||||
if source ~= 65535 then
|
||||
Bridge.Debug("warn", "[%s] Rejected locally invoked High Phone application removal.", RESOURCE_NAME)
|
||||
return
|
||||
end
|
||||
if type(owner_resource) ~= "string" or type(app_id) ~= "string" then
|
||||
Bridge.Debug("warn", "[%s] Rejected invalid High Phone application removal.", RESOURCE_NAME)
|
||||
return
|
||||
end
|
||||
remove_high_server_app(owner_resource, app_id)
|
||||
end)
|
||||
|
||||
RegisterNetEvent("sky_phone:compat:high:client:replaceSnapshot", function(snapshot)
|
||||
if source ~= 65535 then
|
||||
Bridge.Debug("warn", "[%s] Rejected locally invoked High Phone application snapshot.", RESOURCE_NAME)
|
||||
return
|
||||
end
|
||||
if type(snapshot) ~= "table" then
|
||||
Bridge.Debug("warn", "[%s] Rejected invalid High Phone application snapshot.", RESOURCE_NAME)
|
||||
return
|
||||
end
|
||||
|
||||
local seen = {}
|
||||
for index = 1, #snapshot do
|
||||
local record = snapshot[index]
|
||||
if type(record) == "table" then
|
||||
local app_id = register_high_server_app(
|
||||
record.owner_resource,
|
||||
record.definition,
|
||||
record.revision
|
||||
)
|
||||
if app_id then
|
||||
seen[app_id] = true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local removed = {}
|
||||
for app_id, record in pairs(high_server_apps) do
|
||||
if not seen[app_id] then
|
||||
removed[#removed + 1] = {
|
||||
app_id = app_id,
|
||||
owner_resource = record.owner_resource,
|
||||
}
|
||||
end
|
||||
end
|
||||
for index = 1, #removed do
|
||||
remove_high_server_app(removed[index].owner_resource, removed[index].app_id)
|
||||
end
|
||||
end)
|
||||
|
||||
AddEventHandler("onClientResourceStart", function(resource_name)
|
||||
for _, record in pairs(high_server_apps) do
|
||||
if record.owner_resource == resource_name and not record.registered then
|
||||
register_high_server_app(record.owner_resource, record.definition, record.revision)
|
||||
end
|
||||
end
|
||||
end)
|
||||
|
||||
AddEventHandler("onClientResourceStop", function(resource_name)
|
||||
for app_id, record in pairs(high_client_apps) do
|
||||
if record.owner_resource == resource_name then
|
||||
high_client_apps[app_id] = nil
|
||||
end
|
||||
end
|
||||
end)
|
||||
|
||||
exports("AddApplication", add_17mov_application)
|
||||
exports("RemoveApplication", remove_17mov_application)
|
||||
exports("SendAppMessage", send_app_message)
|
||||
exports("addApplication", add_high_application)
|
||||
exports("sendAppNui", send_high_app_nui)
|
||||
exports("addCustomApp", add_quasar_app)
|
||||
exports("addCustomAppsBatch", add_quasar_apps_batch)
|
||||
exports("updateCustomApp", update_quasar_app)
|
||||
exports("removeCustomApp", remove_quasar_app)
|
||||
exports("getCustomApps", get_quasar_apps)
|
||||
exports("OpenPhoneApp", open_quasar_app)
|
||||
exports("OpenApp", open_phone_app)
|
||||
exports("CloseApp", close_phone_app)
|
||||
exports("SendNotification", send_lb_notification)
|
||||
|
||||
SkyPhoneCompatibility.RegisterExportAlias("lb-phone", "OpenApp", open_phone_app)
|
||||
SkyPhoneCompatibility.RegisterExportAlias("lb-phone", "CloseApp", close_phone_app)
|
||||
SkyPhoneCompatibility.RegisterExportAlias("lb-phone", "SendNotification", send_lb_notification)
|
||||
|
||||
SkyPhoneCompatibility.RegisterExportAlias("17mov_Phone", "AddApplication", add_17mov_application)
|
||||
SkyPhoneCompatibility.RegisterExportAlias("17mov_Phone", "RemoveApplication", remove_17mov_application)
|
||||
SkyPhoneCompatibility.RegisterExportAlias("17mov_Phone", "SendAppMessage", send_app_message)
|
||||
|
||||
SkyPhoneCompatibility.RegisterExportAlias("high-phone", "addApplication", add_high_application)
|
||||
SkyPhoneCompatibility.RegisterExportAlias("high-phone", "sendAppNui", send_high_app_nui)
|
||||
|
||||
SkyPhoneCompatibility.RegisterExportAlias("qs-smartphone", "addCustomApp", add_quasar_app)
|
||||
SkyPhoneCompatibility.RegisterExportAlias("qs-smartphone", "addCustomAppsBatch", add_quasar_apps_batch)
|
||||
SkyPhoneCompatibility.RegisterExportAlias("qs-smartphone", "updateCustomApp", update_quasar_app)
|
||||
SkyPhoneCompatibility.RegisterExportAlias("qs-smartphone", "removeCustomApp", remove_quasar_app)
|
||||
SkyPhoneCompatibility.RegisterExportAlias("qs-smartphone", "getCustomApps", get_quasar_apps)
|
||||
SkyPhoneCompatibility.RegisterExportAlias("qs-smartphone", "OpenPhoneApp", open_quasar_app)
|
||||
|
||||
SkyPhoneCompatibility.RegisterExportAlias("yseries", "SendAppMessage", send_app_message)
|
||||
SkyPhoneCompatibility.RegisterExportAlias("yseries", "CloseApp", close_phone_app)
|
||||
SkyPhoneCompatibility.RegisterExportAlias("yseries", "GetDataLoaded", function()
|
||||
return true
|
||||
end)
|
||||
|
||||
CreateThread(function()
|
||||
debug_custom_app("provider", "requesting provider snapshots and emitting compatibility ready signals")
|
||||
TriggerServerEvent("sky_phone:compat:high:server:requestSnapshot")
|
||||
TriggerEvent("17mov_Phone:Client:Ready")
|
||||
|
||||
local provider_resources = {
|
||||
"lb-phone",
|
||||
"17mov_Phone",
|
||||
"high-phone",
|
||||
"qs-smartphone",
|
||||
"yseries",
|
||||
}
|
||||
for index = 1, #provider_resources do
|
||||
debug_custom_app(
|
||||
"provider",
|
||||
"emitting onResourceStart compatibility signal for %s (state=%s)",
|
||||
provider_resources[index],
|
||||
tostring(GetResourceState(provider_resources[index]))
|
||||
)
|
||||
TriggerEvent("onResourceStart", provider_resources[index])
|
||||
end
|
||||
end)
|
||||
@@ -13,7 +13,6 @@ local NOTIFICATION_SOUNDS = {
|
||||
|
||||
local HOOK_NAMES = {
|
||||
onClose = true,
|
||||
onDelete = true,
|
||||
onInstall = true,
|
||||
onOpen = true,
|
||||
onReady = true,
|
||||
@@ -60,7 +59,7 @@ local function is_callable(value)
|
||||
return type(value_metatable) == "table" and type(value_metatable.__call) == "function"
|
||||
end
|
||||
|
||||
local function validate_owner_resource_name(owner_resource)
|
||||
local function validate_owner_resource(owner_resource, allow_stopping)
|
||||
if type(owner_resource) ~= "string"
|
||||
or #owner_resource == 0
|
||||
or #owner_resource > 64
|
||||
@@ -68,14 +67,7 @@ local function validate_owner_resource_name(owner_resource)
|
||||
then
|
||||
return false, "invalid_owner_resource"
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
local function validate_owner_resource(owner_resource, allow_stopping)
|
||||
local valid_owner, owner_error = validate_owner_resource_name(owner_resource)
|
||||
if not valid_owner then
|
||||
return false, owner_error
|
||||
end
|
||||
local state = GetResourceState(owner_resource)
|
||||
if state == "started" or state == "starting" or (allow_stopping and state == "stopping") then
|
||||
return true
|
||||
@@ -113,6 +105,26 @@ local function resolve_localized_text(value)
|
||||
return value[locales[1]]
|
||||
end
|
||||
|
||||
local function is_allowed_remote_origin(url)
|
||||
local origin = url:match("^(https://[^/%?#]+)")
|
||||
if not origin then
|
||||
return false
|
||||
end
|
||||
|
||||
local allowed_origins = Config.CustomApps.AllowRemoteOrigins
|
||||
if allowed_origins[origin] then
|
||||
return true
|
||||
end
|
||||
|
||||
for index = 1, #allowed_origins do
|
||||
if allowed_origins[index] == origin then
|
||||
return true
|
||||
end
|
||||
end
|
||||
|
||||
return false
|
||||
end
|
||||
|
||||
local function validate_asset_path(path)
|
||||
if #path == 0
|
||||
or #path > 1024
|
||||
@@ -173,6 +185,9 @@ local function normalize_asset_url(value, original_owner, adapter_resource, asse
|
||||
end
|
||||
|
||||
if url:sub(1, 8) == "https://" then
|
||||
if not is_allowed_remote_origin(url) then
|
||||
return nil, "remote_origin_not_allowed"
|
||||
end
|
||||
return url
|
||||
end
|
||||
|
||||
@@ -307,6 +322,10 @@ local function normalize_external_definition_value(owner_resource, adapter_resou
|
||||
if definition.schemaVersion ~= nil and definition.schemaVersion ~= SkyPhoneApps.ProtocolVersion then
|
||||
return nil, "unsupported_schema_version"
|
||||
end
|
||||
if definition.onDelete ~= nil then
|
||||
return nil, "unsupported_on_delete"
|
||||
end
|
||||
|
||||
local valid_id, id_error = SkyPhoneApps.ValidateAppId(definition.id)
|
||||
if not valid_id then
|
||||
return nil, id_error
|
||||
@@ -597,13 +616,13 @@ local function send_catalog()
|
||||
})
|
||||
end
|
||||
|
||||
local function sync_catalog()
|
||||
debug_custom_app(
|
||||
"catalog",
|
||||
"synchronizing catalog phone_open=%s",
|
||||
tostring(phone_open)
|
||||
)
|
||||
send_catalog()
|
||||
local function sync_catalog_if_open()
|
||||
if phone_open then
|
||||
debug_custom_app("catalog", "phone is open; synchronizing catalog")
|
||||
send_catalog()
|
||||
else
|
||||
debug_custom_app("catalog", "phone is closed; catalog sync deferred")
|
||||
end
|
||||
end
|
||||
|
||||
local function add_owner_index(index, resource_name, app_id)
|
||||
@@ -663,7 +682,7 @@ local function register_app(app)
|
||||
tostring(app.catalog.defaultInstalled),
|
||||
tostring(phone_open)
|
||||
)
|
||||
sync_catalog()
|
||||
sync_catalog_if_open()
|
||||
return true
|
||||
end
|
||||
|
||||
@@ -745,9 +764,11 @@ local function remove_registered_app(app_id, invoke_close_hook, synchronize)
|
||||
if app.adapterResource then
|
||||
remove_owner_index(app_ids_by_adapter, app.adapterResource, app_id)
|
||||
end
|
||||
TriggerEvent("sky_phone:client:customAppRemoved", app.ownerResource, app_id)
|
||||
if SkyPhoneApps.OnCompatibilityAppRemoved then
|
||||
SkyPhoneApps.OnCompatibilityAppRemoved(app.ownerResource, app_id)
|
||||
end
|
||||
if synchronize then
|
||||
sync_catalog()
|
||||
sync_catalog_if_open()
|
||||
end
|
||||
debug_custom_app(
|
||||
"remove",
|
||||
@@ -911,45 +932,7 @@ local function add_compatibility_app(owner_resource, definition)
|
||||
return success, register_error
|
||||
end
|
||||
|
||||
local function normalize_server_authorized_app(owner_resource, provider, definition)
|
||||
local valid_owner, owner_error = validate_owner_resource_name(owner_resource)
|
||||
if not valid_owner then
|
||||
return nil, owner_error
|
||||
end
|
||||
if type(provider) ~= "string" or provider == "" or #provider > 64 then
|
||||
return nil, "invalid_provider"
|
||||
end
|
||||
if type(definition) ~= "table"
|
||||
or type(definition.compatibility) ~= "table"
|
||||
or definition.compatibility.provider ~= provider
|
||||
then
|
||||
return nil, "app_provider_mismatch"
|
||||
end
|
||||
|
||||
local app, definition_error = normalize_external_definition(owner_resource, nil, definition)
|
||||
if not app then
|
||||
return nil, definition_error
|
||||
end
|
||||
app.authority = {
|
||||
provider = provider,
|
||||
side = "server",
|
||||
}
|
||||
return app
|
||||
end
|
||||
|
||||
local function add_server_authorized_compatibility_app(owner_resource, provider, definition)
|
||||
if not Config.CustomApps.Enabled or not Config.CustomApps.ExternalApps then
|
||||
return false, "external_apps_disabled"
|
||||
end
|
||||
|
||||
local app, app_error = normalize_server_authorized_app(owner_resource, provider, definition)
|
||||
if not app then
|
||||
return false, app_error
|
||||
end
|
||||
return register_app(app)
|
||||
end
|
||||
|
||||
local function replace_registered_app(app, adapter_resource, allow_server_promotion)
|
||||
local function replace_registered_app(app, adapter_resource)
|
||||
local app_id = app.catalog.id
|
||||
local existing = apps_by_id[app_id]
|
||||
if not existing then
|
||||
@@ -964,24 +947,6 @@ local function replace_registered_app(app, adapter_resource, allow_server_promot
|
||||
if existing.adapterResource ~= adapter_resource then
|
||||
return false, "app_adapter_mismatch"
|
||||
end
|
||||
local existing_authority = existing.authority
|
||||
local next_authority = app.authority
|
||||
local same_authority = type(existing_authority) == "table"
|
||||
and type(next_authority) == "table"
|
||||
and existing_authority.side == next_authority.side
|
||||
and existing_authority.provider == next_authority.provider
|
||||
local server_promotion = allow_server_promotion == true
|
||||
and existing_authority == nil
|
||||
and type(next_authority) == "table"
|
||||
and next_authority.side == "server"
|
||||
and type(existing.catalog.compatibility) == "table"
|
||||
and existing.catalog.compatibility.provider == next_authority.provider
|
||||
if (existing_authority ~= nil or next_authority ~= nil)
|
||||
and not same_authority
|
||||
and not server_promotion
|
||||
then
|
||||
return false, "app_authority_mismatch"
|
||||
end
|
||||
|
||||
apps_by_id[app_id] = app
|
||||
debug_custom_app(
|
||||
@@ -992,7 +957,7 @@ local function replace_registered_app(app, adapter_resource, allow_server_promot
|
||||
tostring(adapter_resource),
|
||||
tostring(app.catalog.defaultInstalled)
|
||||
)
|
||||
sync_catalog()
|
||||
sync_catalog_if_open()
|
||||
return true
|
||||
end
|
||||
|
||||
@@ -1074,18 +1039,6 @@ local function update_compatibility_app(owner_resource, definition)
|
||||
return success, update_error
|
||||
end
|
||||
|
||||
local function update_server_authorized_compatibility_app(owner_resource, provider, definition)
|
||||
if not Config.CustomApps.Enabled or not Config.CustomApps.ExternalApps then
|
||||
return false, "external_apps_disabled"
|
||||
end
|
||||
|
||||
local app, app_error = normalize_server_authorized_app(owner_resource, provider, definition)
|
||||
if not app then
|
||||
return false, app_error
|
||||
end
|
||||
return replace_registered_app(app, nil, true)
|
||||
end
|
||||
|
||||
local function remove_custom_app(app_id)
|
||||
local owner_resource, owner_error = get_direct_owner()
|
||||
if not owner_resource then
|
||||
@@ -1099,9 +1052,6 @@ local function remove_custom_app(app_id)
|
||||
if app.catalog.bundled then
|
||||
return false, "bundled_app"
|
||||
end
|
||||
if app.authority then
|
||||
return false, "app_authority_mismatch"
|
||||
end
|
||||
|
||||
return remove_registered_app(app_id, true, true)
|
||||
end
|
||||
@@ -1133,41 +1083,10 @@ local function remove_compatibility_app(owner_resource, app_id)
|
||||
if app.catalog.bundled then
|
||||
return false, "bundled_app"
|
||||
end
|
||||
if app.authority then
|
||||
return false, "app_authority_mismatch"
|
||||
end
|
||||
|
||||
return remove_registered_app(app_id, true, true)
|
||||
end
|
||||
|
||||
local function remove_server_authorized_compatibility_app(owner_resource, provider, app_id)
|
||||
local valid_owner, owner_error = validate_owner_resource_name(owner_resource)
|
||||
if not valid_owner then
|
||||
return false, owner_error
|
||||
end
|
||||
if type(provider) ~= "string" or provider == "" or #provider > 64 then
|
||||
return false, "invalid_provider"
|
||||
end
|
||||
|
||||
local app, app_error = verify_owned_app(owner_resource, app_id)
|
||||
if not app then
|
||||
return false, app_error
|
||||
end
|
||||
local authority = app.authority
|
||||
if type(authority) ~= "table"
|
||||
or authority.side ~= "server"
|
||||
or authority.provider ~= provider
|
||||
then
|
||||
return false, "app_authority_mismatch"
|
||||
end
|
||||
if type(app.catalog.compatibility) ~= "table"
|
||||
or app.catalog.compatibility.provider ~= provider
|
||||
then
|
||||
return false, "app_provider_mismatch"
|
||||
end
|
||||
return remove_registered_app(app_id, true, true)
|
||||
end
|
||||
|
||||
local function deliver_custom_app_message(app_id, payload)
|
||||
SendNUIMessage({
|
||||
type = "custom-app:message",
|
||||
@@ -1468,7 +1387,7 @@ end
|
||||
local function get_custom_app_capabilities()
|
||||
return {
|
||||
abiVersion = 1,
|
||||
enabled = Config.CustomApps.Enabled == true,
|
||||
enabled = Config.CustomApps.Enabled,
|
||||
bridgeMethods = {
|
||||
"app.close",
|
||||
"app.open",
|
||||
@@ -1490,7 +1409,6 @@ local function get_custom_app_capabilities()
|
||||
"OpenCustomAppFromAdapter",
|
||||
"RemoveCustomApp",
|
||||
"RemoveCustomAppFromAdapter",
|
||||
"SendAppMessage",
|
||||
"SendCustomAppMessage",
|
||||
"SendCustomAppMessageFromAdapter",
|
||||
"SendCustomAppNotification",
|
||||
@@ -1498,13 +1416,10 @@ local function get_custom_app_capabilities()
|
||||
"UpdateCustomApp",
|
||||
"UpdateCustomAppFromAdapter",
|
||||
},
|
||||
externalApps = Config.CustomApps.Enabled == true
|
||||
and Config.CustomApps.ExternalApps == true,
|
||||
maximumMessageBytes = Config.CustomApps.MaximumMessageBytes,
|
||||
maximumStorageBytesPerApp = Config.CustomApps.MaximumStorageBytesPerApp,
|
||||
maximumStorageKeyLength = math.min(Config.CustomApps.MaximumStorageKeyLength, 64),
|
||||
maximumStorageValueBytes = math.min(Config.CustomApps.MaximumStorageValueBytes, 65536),
|
||||
messageDispatch = true,
|
||||
protocolVersion = SkyPhoneApps.ProtocolVersion,
|
||||
}
|
||||
end
|
||||
@@ -1555,14 +1470,6 @@ local function register_bundled_client_hooks(app_id, hooks)
|
||||
return true
|
||||
end
|
||||
|
||||
function SkyPhoneApps.CanReceiveNotification(app_id)
|
||||
local valid_id = SkyPhoneApps.ValidateAppId(app_id)
|
||||
if not valid_id then
|
||||
return false
|
||||
end
|
||||
return SkyPhoneApps.ReservedAppIds[app_id] == true or apps_by_id[app_id] ~= nil
|
||||
end
|
||||
|
||||
SkyPhoneApps.SendCatalog = send_catalog
|
||||
SkyPhoneApps.SetPhoneOpen = set_phone_open
|
||||
SkyPhoneApps.RegisterClientHooks = register_bundled_client_hooks
|
||||
@@ -1580,59 +1487,92 @@ if Config.CustomApps.Enabled and Config.CustomApps.BundledApps then
|
||||
end
|
||||
end
|
||||
|
||||
local function add_custom_app_export(definition)
|
||||
debug_custom_app(
|
||||
"dispatch",
|
||||
"AddCustomApp received type=%s id=%s identifier=%s key=%s invoking_resource=%s",
|
||||
type(definition),
|
||||
type(definition) == "table" and tostring(definition.id) or "nil",
|
||||
type(definition) == "table" and tostring(definition.identifier) or "nil",
|
||||
type(definition) == "table" and tostring(definition.key) or "nil",
|
||||
tostring(GetInvokingResource())
|
||||
)
|
||||
if type(definition) ~= "table" then
|
||||
return add_custom_app(definition)
|
||||
end
|
||||
|
||||
local provider_markers = 0
|
||||
if definition.id ~= nil then
|
||||
provider_markers = provider_markers + 1
|
||||
end
|
||||
if definition.identifier ~= nil then
|
||||
provider_markers = provider_markers + 1
|
||||
end
|
||||
if definition.key ~= nil then
|
||||
provider_markers = provider_markers + 1
|
||||
end
|
||||
if provider_markers > 1 then
|
||||
debug_custom_app("dispatch", "AddCustomApp rejected error=ambiguous_app_provider")
|
||||
return false, "ambiguous_app_provider"
|
||||
end
|
||||
if definition.identifier == nil and definition.key == nil then
|
||||
debug_custom_app("dispatch", "routing AddCustomApp through native sky_phone registration")
|
||||
return add_custom_app(definition)
|
||||
end
|
||||
|
||||
local owner_resource, owner_error = get_direct_owner()
|
||||
if not owner_resource then
|
||||
debug_custom_app(
|
||||
"dispatch",
|
||||
"compatibility AddCustomApp rejected error=%s",
|
||||
tostring(owner_error)
|
||||
)
|
||||
return false, owner_error
|
||||
end
|
||||
if not SkyPhoneApps.RegisterCompatibilityExport then
|
||||
debug_custom_app("dispatch", "compatibility AddCustomApp rejected error=compatibility_not_ready")
|
||||
return false, "compatibility_not_ready"
|
||||
end
|
||||
debug_custom_app(
|
||||
"dispatch",
|
||||
"routing AddCustomApp through compatibility provider owner=%s",
|
||||
owner_resource
|
||||
)
|
||||
return SkyPhoneApps.RegisterCompatibilityExport(owner_resource, definition)
|
||||
end
|
||||
|
||||
SkyPhoneApps.CompatibilityCore = {
|
||||
Add = add_compatibility_app,
|
||||
AddServerAuthorized = add_server_authorized_compatibility_app,
|
||||
Close = close_compatibility_app,
|
||||
CloseActive = close_active_compatibility_app,
|
||||
Open = open_compatibility_app,
|
||||
Remove = remove_compatibility_app,
|
||||
RemoveServerAuthorized = remove_server_authorized_compatibility_app,
|
||||
SendMessage = send_compatibility_app_message,
|
||||
Update = update_compatibility_app,
|
||||
UpdateServerAuthorized = update_server_authorized_compatibility_app,
|
||||
}
|
||||
|
||||
SkyPhoneApps.ClientPublicApi = {
|
||||
AddCustomApp = add_custom_app,
|
||||
AddCustomAppFromAdapter = add_custom_app_from_adapter,
|
||||
CloseActiveCustomAppFromAdapter = close_active_custom_app_from_adapter,
|
||||
CloseCustomApp = close_custom_app,
|
||||
CloseCustomAppFromAdapter = close_custom_app_from_adapter,
|
||||
GetCustomAppCapabilities = get_custom_app_capabilities,
|
||||
OpenCustomApp = open_custom_app,
|
||||
OpenCustomAppFromAdapter = open_custom_app_from_adapter,
|
||||
RemoveCustomApp = remove_custom_app,
|
||||
RemoveCustomAppFromAdapter = remove_custom_app_from_adapter,
|
||||
SendAppMessage = send_custom_app_message,
|
||||
SendCustomAppMessage = send_custom_app_message,
|
||||
SendCustomAppMessageFromAdapter = send_custom_app_message_from_adapter,
|
||||
SendCustomAppNotification = send_custom_app_notification,
|
||||
SendCustomAppNotificationFromAdapter = send_custom_app_notification_from_adapter,
|
||||
UpdateCustomApp = update_custom_app,
|
||||
UpdateCustomAppFromAdapter = update_custom_app_from_adapter,
|
||||
}
|
||||
exports("AddCustomApp", add_custom_app_export)
|
||||
exports("AddCustomAppFromAdapter", add_custom_app_from_adapter)
|
||||
exports("CloseActiveCustomAppFromAdapter", close_active_custom_app_from_adapter)
|
||||
exports("CloseCustomApp", close_custom_app)
|
||||
exports("CloseCustomAppFromAdapter", close_custom_app_from_adapter)
|
||||
exports("GetCustomAppCapabilities", get_custom_app_capabilities)
|
||||
exports("OpenCustomApp", open_custom_app)
|
||||
exports("OpenCustomAppFromAdapter", open_custom_app_from_adapter)
|
||||
exports("RemoveCustomApp", remove_custom_app)
|
||||
exports("RemoveCustomAppFromAdapter", remove_custom_app_from_adapter)
|
||||
exports("SendCustomAppMessage", send_custom_app_message)
|
||||
exports("SendCustomAppMessageFromAdapter", send_custom_app_message_from_adapter)
|
||||
exports("SendCustomAppNotification", send_custom_app_notification)
|
||||
exports("SendCustomAppNotificationFromAdapter", send_custom_app_notification_from_adapter)
|
||||
exports("UpdateCustomApp", update_custom_app)
|
||||
exports("UpdateCustomAppFromAdapter", update_custom_app_from_adapter)
|
||||
|
||||
RegisterNetEvent("sky_phone:custom-app:message", function(owner_resource, app_id, payload)
|
||||
if source ~= 65535 then
|
||||
Bridge.Debug("warn", "[sky_phone] Rejected a client-triggered custom app message.")
|
||||
return
|
||||
end
|
||||
|
||||
local success, message_error = send_compatibility_app_message(
|
||||
owner_resource,
|
||||
app_id,
|
||||
payload
|
||||
)
|
||||
if not success then
|
||||
Bridge.Debug(
|
||||
"warn",
|
||||
"[sky_phone] Server custom app message for %s could not be delivered: %s.",
|
||||
tostring(app_id),
|
||||
tostring(message_error)
|
||||
)
|
||||
end
|
||||
end)
|
||||
SkyPhoneCompatibility.RegisterExportAlias("lb-phone", "AddCustomApp", add_custom_app_export)
|
||||
SkyPhoneCompatibility.RegisterExportAlias("lb-phone", "RemoveCustomApp", remove_custom_app)
|
||||
SkyPhoneCompatibility.RegisterExportAlias("lb-phone", "SendCustomAppMessage", send_custom_app_message)
|
||||
SkyPhoneCompatibility.RegisterExportAlias("yseries", "AddCustomApp", add_custom_app_export)
|
||||
SkyPhoneCompatibility.RegisterExportAlias("yseries", "RemoveCustomApp", remove_custom_app)
|
||||
|
||||
RegisterNUICallback("custom-app:catalog-debug", function(data, cb)
|
||||
local accepted_ids = type(data) == "table" and data.acceptedIds or nil
|
||||
@@ -1699,7 +1639,6 @@ RegisterNUICallback("custom-app:lifecycle", function(data, cb)
|
||||
|
||||
local lifecycle_event = data.event
|
||||
if lifecycle_event ~= "install"
|
||||
and lifecycle_event ~= "delete"
|
||||
and lifecycle_event ~= "open"
|
||||
and lifecycle_event ~= "ready"
|
||||
and lifecycle_event ~= "close"
|
||||
@@ -1737,8 +1676,6 @@ RegisterNUICallback("custom-app:lifecycle", function(data, cb)
|
||||
local hook_success = true
|
||||
if lifecycle_event == "install" then
|
||||
hook_success = invoke_or_defer_hook(app, "onInstall", lifecycle_payload, deferred_hooks)
|
||||
elseif lifecycle_event == "delete" then
|
||||
hook_success = invoke_or_defer_hook(app, "onDelete", lifecycle_payload, deferred_hooks)
|
||||
elseif lifecycle_event == "open" then
|
||||
if active_app_id and active_app_id ~= data.appId then
|
||||
clear_active_app(true, deferred_hooks)
|
||||
@@ -1809,7 +1746,7 @@ AddEventHandler("onClientResourceStop", function(resource_name)
|
||||
removed = success or removed
|
||||
end
|
||||
if removed then
|
||||
sync_catalog()
|
||||
sync_catalog_if_open()
|
||||
end
|
||||
debug_custom_app(
|
||||
"resource",
|
||||
|
||||
@@ -3,24 +3,6 @@ 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 state = {
|
||||
activity_suspended = false,
|
||||
allow_movement = Config.Phone.AllowMovement,
|
||||
call_focus = false,
|
||||
camera_active = false,
|
||||
camera_nui_focused = true,
|
||||
cursor_disabled = false,
|
||||
external_game_input = nil,
|
||||
external_game_input_owner = nil,
|
||||
is_open = false,
|
||||
notification_focus = false,
|
||||
payphone_focus = false,
|
||||
sim_picker_open = false,
|
||||
text_input_focused = false,
|
||||
}
|
||||
local block_game = false
|
||||
local block_look = false
|
||||
local game_input = false
|
||||
|
||||
function SkyPhoneFocus.ApplyFocusedControls()
|
||||
for _, group in ipairs(focused_control_groups) do
|
||||
@@ -51,167 +33,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 state.allow_movement and not state.camera_active
|
||||
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
|
||||
return {
|
||||
block_game = cursor and (not game_input or state.text_input_focused),
|
||||
block_look = game_input and not state.cursor_disabled,
|
||||
block_look = game_input,
|
||||
cursor = cursor,
|
||||
focused = focused,
|
||||
game_input = game_input,
|
||||
keep_input = game_input,
|
||||
}
|
||||
end
|
||||
|
||||
function SkyPhoneFocus.Reapply()
|
||||
local focus = SkyPhoneFocus.Resolve(state)
|
||||
SetNuiFocus(focus.focused, focus.cursor)
|
||||
SetNuiFocusKeepInput(focus.keep_input)
|
||||
block_game = focus.block_game == true
|
||||
block_look = focus.block_look == true
|
||||
game_input = focus.game_input == true
|
||||
TriggerEvent("sky_phone:client:cameraFocusApplied", {
|
||||
active = state.camera_active,
|
||||
cursor = focus.cursor,
|
||||
focused = focus.focused,
|
||||
gameInput = focus.keep_input,
|
||||
})
|
||||
end
|
||||
|
||||
function SkyPhoneFocus.BeginNuiHydration()
|
||||
-- Browser-owned focus claims cannot survive a CEF reload.
|
||||
state.notification_focus = false
|
||||
state.text_input_focused = false
|
||||
end
|
||||
|
||||
function SkyPhoneFocus.SetPhone(open, cursor_disabled)
|
||||
state.is_open = open == true
|
||||
if cursor_disabled ~= nil then
|
||||
state.cursor_disabled = cursor_disabled == true
|
||||
end
|
||||
if state.is_open then
|
||||
state.notification_focus = false
|
||||
state.call_focus = false
|
||||
else
|
||||
state.activity_suspended = false
|
||||
state.call_focus = false
|
||||
state.cursor_disabled = false
|
||||
state.external_game_input = nil
|
||||
state.external_game_input_owner = nil
|
||||
state.text_input_focused = false
|
||||
end
|
||||
SkyPhoneFocus.Reapply()
|
||||
end
|
||||
|
||||
function SkyPhoneFocus.SetCall(active)
|
||||
state.call_focus = active == true
|
||||
SkyPhoneFocus.Reapply()
|
||||
end
|
||||
|
||||
function SkyPhoneFocus.SetSimPicker(active)
|
||||
state.sim_picker_open = active == true
|
||||
SkyPhoneFocus.Reapply()
|
||||
end
|
||||
|
||||
function SkyPhoneFocus.SetTextInputFocused(active)
|
||||
state.text_input_focused = active == true
|
||||
SkyPhoneFocus.Reapply()
|
||||
end
|
||||
|
||||
function SkyPhoneFocus.SetExternalGameInput(owner_resource, allow_game_input)
|
||||
if type(owner_resource) ~= "string" or owner_resource == "" or type(allow_game_input) ~= "boolean" then
|
||||
return false, "invalid_focus_claim"
|
||||
end
|
||||
if not state.is_open then
|
||||
return false, "phone_closed"
|
||||
end
|
||||
|
||||
state.external_game_input = allow_game_input
|
||||
state.external_game_input_owner = owner_resource
|
||||
SkyPhoneFocus.Reapply()
|
||||
return true
|
||||
end
|
||||
|
||||
function SkyPhoneFocus.Reset()
|
||||
state.activity_suspended = false
|
||||
state.call_focus = false
|
||||
state.camera_active = false
|
||||
state.camera_nui_focused = true
|
||||
state.cursor_disabled = false
|
||||
state.external_game_input = nil
|
||||
state.external_game_input_owner = nil
|
||||
state.is_open = false
|
||||
state.notification_focus = false
|
||||
state.payphone_focus = false
|
||||
state.sim_picker_open = false
|
||||
state.text_input_focused = false
|
||||
block_game = false
|
||||
block_look = false
|
||||
game_input = false
|
||||
SetNuiFocusKeepInput(false)
|
||||
SetNuiFocus(false, false)
|
||||
end
|
||||
|
||||
CreateThread(function()
|
||||
while true do
|
||||
if game_input or block_game then
|
||||
if block_game then
|
||||
SkyPhoneFocus.ApplyFocusedControls()
|
||||
else
|
||||
SkyPhoneFocus.ApplyGameInputControls(block_look)
|
||||
end
|
||||
Wait(0)
|
||||
else
|
||||
Wait(250)
|
||||
end
|
||||
end
|
||||
end)
|
||||
|
||||
AddEventHandler("sky_phone:client:setSuspended", function(suspended)
|
||||
state.activity_suspended = suspended == true
|
||||
SkyPhoneFocus.Reapply()
|
||||
end)
|
||||
|
||||
AddEventHandler("sky_phone:client:setPayphoneFocus", function(focused)
|
||||
state.payphone_focus = focused == true
|
||||
SkyPhoneFocus.Reapply()
|
||||
end)
|
||||
|
||||
AddEventHandler("sky_phone:client:setCameraFocus", function(data)
|
||||
if type(data) ~= "table" or type(data.active) ~= "boolean" or type(data.nuiFocused) ~= "boolean" then
|
||||
Bridge.Debug("error", "[sky_phone] Rejected invalid camera focus claim.")
|
||||
return
|
||||
end
|
||||
state.camera_active = data.active
|
||||
state.camera_nui_focused = data.nuiFocused
|
||||
SkyPhoneFocus.Reapply()
|
||||
end)
|
||||
|
||||
AddEventHandler("onClientResourceStop", function(resource_name)
|
||||
if resource_name ~= state.external_game_input_owner then
|
||||
return
|
||||
end
|
||||
|
||||
state.external_game_input = nil
|
||||
state.external_game_input_owner = nil
|
||||
SkyPhoneFocus.Reapply()
|
||||
end)
|
||||
|
||||
RegisterNUICallback("notification:focus", function(data, cb)
|
||||
if type(data) ~= "table" or type(data.active) ~= "boolean" then
|
||||
cb({ success = false, error = "invalid_request" })
|
||||
return
|
||||
end
|
||||
state.notification_focus = data.active == true and not state.is_open
|
||||
SkyPhoneFocus.Reapply()
|
||||
cb({ success = true })
|
||||
end)
|
||||
|
||||
@@ -48,52 +48,6 @@ local vehicle_mods = {
|
||||
local phone_locale = SkyPhoneLocales.Resolve(Config.Bridge.Locale)
|
||||
local garage_locale = phone_locale.Nui.Apps.garage
|
||||
|
||||
local function vehicle_kind(model_hash, fallback)
|
||||
if IsThisModelABoat(model_hash) then
|
||||
return "boat"
|
||||
end
|
||||
if IsThisModelAPlane(model_hash) then
|
||||
return "plane"
|
||||
end
|
||||
if IsThisModelAHeli(model_hash) then
|
||||
return "helicopter"
|
||||
end
|
||||
if IsThisModelABike(model_hash) or IsThisModelABicycle(model_hash) then
|
||||
return "bike"
|
||||
end
|
||||
return fallback
|
||||
end
|
||||
|
||||
RegisterNUICallback("garage:vehicles", function(data, cb)
|
||||
if type(data) ~= "table" then
|
||||
cb({ success = false, error = "invalid_request" })
|
||||
return
|
||||
end
|
||||
|
||||
local result = Bridge.Callbacks.Trigger("sky_phone:garage:vehicles", data)
|
||||
if type(result) ~= "table" or not result.success or type(result.data) ~= "table" then
|
||||
cb(type(result) == "table" and result or { success = false, error = "request_failed" })
|
||||
return
|
||||
end
|
||||
for _, vehicle in ipairs(result.data.vehicles or {}) do
|
||||
local model_hash = tonumber(vehicle.model)
|
||||
if not model_hash and type(vehicle.model) == "string" and vehicle.model ~= "" then
|
||||
model_hash = joaat(vehicle.model)
|
||||
end
|
||||
if model_hash then
|
||||
local display_name = GetDisplayNameFromVehicleModel(model_hash)
|
||||
local label = display_name and GetLabelText(display_name) or nil
|
||||
if label and label ~= "NULL" and label ~= "CARNOTFOUND" then
|
||||
vehicle.name = label
|
||||
elseif type(vehicle.model) == "string" and vehicle.model ~= "" then
|
||||
vehicle.name = vehicle.model
|
||||
end
|
||||
vehicle.kind = vehicle_kind(model_hash, vehicle.kind)
|
||||
end
|
||||
end
|
||||
cb(result)
|
||||
end)
|
||||
|
||||
local function normalized_plate(value)
|
||||
return tostring(value or ""):match("^%s*(.-)%s*$")
|
||||
end
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
local function player_coordinates()
|
||||
local coords = GetEntityCoords(PlayerPedId())
|
||||
return {
|
||||
x = coords.x,
|
||||
y = coords.y,
|
||||
z = coords.z,
|
||||
}
|
||||
end
|
||||
|
||||
RegisterNUICallback("map:getPlayerCoords", function(data, cb)
|
||||
if type(data) ~= "table" then
|
||||
cb({ success = false, error = "invalid_request" })
|
||||
return
|
||||
end
|
||||
|
||||
cb({ success = true, data = { coords = player_coordinates() } })
|
||||
end)
|
||||
|
||||
RegisterNUICallback("map:setWaypoint", function(data, cb)
|
||||
local coords = type(data) == "table" and data.coords or nil
|
||||
local x = type(coords) == "table" and tonumber(coords.x) or nil
|
||||
local y = type(coords) == "table" and tonumber(coords.y) or nil
|
||||
if not x or not y or x ~= x or y ~= y or math.abs(x) > 10000.0 or math.abs(y) > 10000.0 then
|
||||
cb({ success = false, error = "invalid_marker" })
|
||||
return
|
||||
end
|
||||
|
||||
SetNewWaypoint(x, y)
|
||||
cb({ success = true })
|
||||
end)
|
||||
|
||||
RegisterNUICallback("skyride:get-player-coords", function(_, cb)
|
||||
cb({ success = true, data = { coords = player_coordinates() } })
|
||||
end)
|
||||
+876
-119
File diff suppressed because it is too large
Load Diff
@@ -1,161 +0,0 @@
|
||||
SkyPhoneNavigation = {}
|
||||
|
||||
local installed_apps = {}
|
||||
local current_app_id = nil
|
||||
local data_loaded = false
|
||||
|
||||
local function reset_navigation_state()
|
||||
installed_apps = {}
|
||||
current_app_id = nil
|
||||
data_loaded = false
|
||||
end
|
||||
|
||||
local function close_navigation_state()
|
||||
current_app_id = nil
|
||||
end
|
||||
|
||||
local function normalize_app_id(app_id)
|
||||
local valid_id = SkyPhoneApps.ValidateAppId(app_id)
|
||||
if not valid_id then
|
||||
return nil
|
||||
end
|
||||
return app_id
|
||||
end
|
||||
|
||||
local function sync_navigation_state(data)
|
||||
if type(data) ~= "table" or type(data.installedApps) ~= "table" then
|
||||
return false, "invalid_navigation_state"
|
||||
end
|
||||
if #data.installedApps > 256 then
|
||||
return false, "invalid_navigation_state"
|
||||
end
|
||||
|
||||
local next_installed_apps = {}
|
||||
for index = 1, #data.installedApps do
|
||||
local app_id = normalize_app_id(data.installedApps[index])
|
||||
if not app_id then
|
||||
return false, "invalid_navigation_app"
|
||||
end
|
||||
next_installed_apps[app_id] = true
|
||||
end
|
||||
|
||||
local next_current_app_id = data.currentApp
|
||||
if next_current_app_id ~= nil then
|
||||
next_current_app_id = normalize_app_id(next_current_app_id)
|
||||
if not next_current_app_id then
|
||||
return false, "invalid_current_app"
|
||||
end
|
||||
end
|
||||
|
||||
installed_apps = next_installed_apps
|
||||
current_app_id = next_current_app_id
|
||||
data_loaded = true
|
||||
return true
|
||||
end
|
||||
|
||||
function SkyPhoneNavigation.Open(app_id)
|
||||
local normalized_app_id = normalize_app_id(app_id)
|
||||
if not normalized_app_id then
|
||||
return false, "invalid_app_id"
|
||||
end
|
||||
if not SkyPhoneClient.GetState().open then
|
||||
return false, "phone_closed"
|
||||
end
|
||||
if not installed_apps[normalized_app_id] then
|
||||
return false, "app_not_installed"
|
||||
end
|
||||
|
||||
current_app_id = normalized_app_id
|
||||
SendNUIMessage({
|
||||
type = "navigation:open-app",
|
||||
data = { appId = normalized_app_id },
|
||||
})
|
||||
return true
|
||||
end
|
||||
|
||||
function SkyPhoneNavigation.Close(app_id)
|
||||
if not SkyPhoneClient.GetState().open then
|
||||
return false, "phone_closed"
|
||||
end
|
||||
if app_id ~= nil then
|
||||
local normalized_app_id = normalize_app_id(app_id)
|
||||
if not normalized_app_id then
|
||||
return false, "invalid_app_id"
|
||||
end
|
||||
if current_app_id ~= normalized_app_id then
|
||||
return false, "app_not_active"
|
||||
end
|
||||
elseif not current_app_id then
|
||||
return false, "app_not_active"
|
||||
end
|
||||
|
||||
local closing_app_id = current_app_id
|
||||
current_app_id = nil
|
||||
SendNUIMessage({
|
||||
type = "navigation:close-app",
|
||||
data = { appId = closing_app_id },
|
||||
})
|
||||
return true
|
||||
end
|
||||
|
||||
function SkyPhoneNavigation.GetState()
|
||||
local copied_installed_apps = {}
|
||||
for app_id in pairs(installed_apps) do
|
||||
copied_installed_apps[app_id] = true
|
||||
end
|
||||
return {
|
||||
currentApp = current_app_id,
|
||||
dataLoaded = data_loaded,
|
||||
installedApps = copied_installed_apps,
|
||||
}
|
||||
end
|
||||
|
||||
function SkyPhoneNavigation.IsDataLoaded()
|
||||
return data_loaded
|
||||
end
|
||||
|
||||
function SkyPhoneNavigation.IsInstalled(app_id)
|
||||
local normalized_app_id = normalize_app_id(app_id)
|
||||
if not normalized_app_id then
|
||||
return false
|
||||
end
|
||||
return installed_apps[normalized_app_id] == true
|
||||
end
|
||||
|
||||
function SkyPhoneNavigation.GetCurrent(app_id)
|
||||
if not SkyPhoneClient.GetState().open then
|
||||
if app_id == nil then
|
||||
return nil
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local visible_app_id = current_app_id or "home"
|
||||
if app_id == nil then
|
||||
return visible_app_id
|
||||
end
|
||||
|
||||
local normalized_app_id = normalize_app_id(app_id)
|
||||
return normalized_app_id ~= nil and visible_app_id == normalized_app_id
|
||||
end
|
||||
|
||||
function SkyPhoneNavigation.Reset()
|
||||
reset_navigation_state()
|
||||
end
|
||||
|
||||
RegisterNUICallback("navigation:state", function(data, cb)
|
||||
local success, error_code = sync_navigation_state(data)
|
||||
cb(success and { success = true } or { success = false, error = error_code })
|
||||
end)
|
||||
|
||||
AddEventHandler("sky_phone:client:phoneToggled", function(open)
|
||||
if not open then
|
||||
close_navigation_state()
|
||||
end
|
||||
end)
|
||||
|
||||
AddEventHandler("onClientResourceStop", function(resource_name)
|
||||
if resource_name == GetCurrentResourceName() then
|
||||
reset_navigation_state()
|
||||
end
|
||||
end)
|
||||
@@ -1,300 +0,0 @@
|
||||
local NOTIFICATION_TEXT_MAX_LENGTH = 2000
|
||||
local NOTIFICATION_TITLE_MAX_LENGTH = 160
|
||||
local DEVICE_NAME_MAX_LENGTH = 128
|
||||
|
||||
SkyPhoneNotifications = {}
|
||||
|
||||
local function trim(value)
|
||||
return value:match("^%s*(.-)%s*$")
|
||||
end
|
||||
|
||||
local function normalize_text(value, error_code, maximum_length)
|
||||
if type(value) ~= "string" then
|
||||
return nil, error_code
|
||||
end
|
||||
|
||||
local normalized = trim(value)
|
||||
if normalized == "" or #normalized > maximum_length then
|
||||
return nil, error_code
|
||||
end
|
||||
return normalized
|
||||
end
|
||||
|
||||
local function resolve_alias(data, primary_key, alternate_key, error_code)
|
||||
local primary = data[primary_key]
|
||||
local alternate = data[alternate_key]
|
||||
if primary ~= nil and alternate ~= nil and primary ~= alternate then
|
||||
return nil, error_code
|
||||
end
|
||||
return primary ~= nil and primary or alternate
|
||||
end
|
||||
|
||||
local function normalize_notification(notification)
|
||||
if type(notification) ~= "table" then
|
||||
return nil, "invalid_notification"
|
||||
end
|
||||
|
||||
local app_id = resolve_alias(notification, "appId", "app_id", "invalid_app_id")
|
||||
local valid_app_id = SkyPhoneApps.ValidateAppId(app_id)
|
||||
if not valid_app_id or not SkyPhoneApps.CanReceiveNotification(app_id) then
|
||||
return nil, "invalid_app_id"
|
||||
end
|
||||
|
||||
local title, title_error = normalize_text(
|
||||
notification.title,
|
||||
"invalid_title",
|
||||
NOTIFICATION_TITLE_MAX_LENGTH
|
||||
)
|
||||
if not title then
|
||||
return nil, title_error
|
||||
end
|
||||
|
||||
local raw_text = resolve_alias(notification, "text", "body", "invalid_text")
|
||||
local notification_text, text_error = normalize_text(
|
||||
raw_text,
|
||||
"invalid_text",
|
||||
NOTIFICATION_TEXT_MAX_LENGTH
|
||||
)
|
||||
if not notification_text then
|
||||
return nil, text_error
|
||||
end
|
||||
|
||||
return {
|
||||
appId = app_id,
|
||||
route = "/apps/" .. app_id,
|
||||
text = notification_text,
|
||||
title = title,
|
||||
}
|
||||
end
|
||||
|
||||
local function normalize_server_device(device)
|
||||
if type(device) ~= "table"
|
||||
or not SkyPhoneImei.IsValid(device.imei)
|
||||
then
|
||||
return nil, "invalid_device"
|
||||
end
|
||||
|
||||
local name, name_error = normalize_text(
|
||||
device.name,
|
||||
"invalid_device",
|
||||
DEVICE_NAME_MAX_LENGTH
|
||||
)
|
||||
if not name then
|
||||
return nil, name_error
|
||||
end
|
||||
if device.settings ~= nil
|
||||
and type(device.settings) ~= "string"
|
||||
and type(device.settings) ~= "table"
|
||||
then
|
||||
return nil, "invalid_device"
|
||||
end
|
||||
|
||||
return {
|
||||
imei = device.imei,
|
||||
name = name,
|
||||
settings = device.settings,
|
||||
}
|
||||
end
|
||||
|
||||
local function show_normalized(notification)
|
||||
SendNUIMessage({
|
||||
type = "notification:show",
|
||||
data = notification,
|
||||
})
|
||||
end
|
||||
|
||||
function SkyPhoneNotifications.Show(notification)
|
||||
local normalized, error_code = normalize_notification(notification)
|
||||
if not normalized then
|
||||
return false, error_code
|
||||
end
|
||||
|
||||
show_normalized(normalized)
|
||||
return true
|
||||
end
|
||||
|
||||
function SkyPhoneNotifications.Send(notification)
|
||||
local normalized, error_code = normalize_notification(notification)
|
||||
if not normalized then
|
||||
return false, error_code
|
||||
end
|
||||
|
||||
local result = Bridge.Callbacks.Trigger("sky_phone:notifications:self", normalized)
|
||||
if type(result) ~= "table" or result.success ~= true then
|
||||
return false, type(result) == "table" and result.error or "request_failed"
|
||||
end
|
||||
return true, result.data
|
||||
end
|
||||
|
||||
RegisterNetEvent("sky_phone:notifications:show", function(notification)
|
||||
if source ~= 65535 then
|
||||
Bridge.Debug("warn", "[sky_phone] Rejected a client-triggered notification delivery event.")
|
||||
return
|
||||
end
|
||||
|
||||
local normalized, notification_error = normalize_notification(notification)
|
||||
local device
|
||||
local device_error = "invalid_device"
|
||||
if type(notification) == "table" then
|
||||
device, device_error = normalize_server_device(notification.device)
|
||||
end
|
||||
if not normalized or not device then
|
||||
Bridge.Debug(
|
||||
"warn",
|
||||
"[sky_phone] Rejected an invalid server notification: %s.",
|
||||
tostring(notification_error or device_error)
|
||||
)
|
||||
return
|
||||
end
|
||||
|
||||
normalized.device = device
|
||||
show_normalized(normalized)
|
||||
TriggerEvent("sky_phone:client:pushNotification", {
|
||||
appId = normalized.appId,
|
||||
text = normalized.text,
|
||||
title = normalized.title,
|
||||
})
|
||||
end)
|
||||
|
||||
local phone_locale = SkyPhoneLocales.Resolve(Config.Bridge.Locale)
|
||||
local app_locales = phone_locale.Nui.Apps
|
||||
|
||||
RegisterNetEvent("sky_phone:mail:new", function(data)
|
||||
local locale = app_locales.mail
|
||||
data.title = locale.name
|
||||
data.text = locale.newMessage:gsub("{sender}", tostring(data.sender))
|
||||
SendNUIMessage({ type = "mail:new", data = data })
|
||||
end)
|
||||
|
||||
RegisterNetEvent("sky_phone:citywarn:changed", function(data)
|
||||
if type(data) ~= "table" then
|
||||
return
|
||||
end
|
||||
local locale = app_locales.citywarn
|
||||
data.title = locale.name
|
||||
data.text = locale.notifications[data.kind] or locale.notifications.update
|
||||
SendNUIMessage({ type = "citywarn:changed", data = data })
|
||||
end)
|
||||
|
||||
RegisterNetEvent("sky_phone:companies:notification", function(data)
|
||||
local locale = app_locales.companies
|
||||
data.title = locale.name
|
||||
data.text = locale.notifications[data.kind] or locale.notifications.requestUpdated
|
||||
SendNUIMessage({ type = "companies:notification", data = data })
|
||||
end)
|
||||
|
||||
RegisterNetEvent("sky_phone:fliptok:command-feedback", function(data)
|
||||
Bridge.Framework.Notify("FlipTok", data.message, data.notificationType, 5000)
|
||||
end)
|
||||
|
||||
RegisterNetEvent("sky_phone:fliptok:new", function(data)
|
||||
local locale = app_locales.fliptok
|
||||
local notification_text = locale.notifications[data.kind] or locale.notifications.default
|
||||
data.title = locale.name
|
||||
data.text = notification_text:gsub("{actor}", tostring(data.actor or ""))
|
||||
SendNUIMessage({ type = "fliptok:new", data = data })
|
||||
end)
|
||||
|
||||
RegisterNetEvent("sky_phone:picstagram:command-feedback", function(data)
|
||||
Bridge.Framework.Notify("Picstagram", data.message, data.notificationType, 5000)
|
||||
end)
|
||||
|
||||
RegisterNetEvent("sky_phone:picstagram:new", function(data)
|
||||
local locale = app_locales.picstagram
|
||||
local notification_text = locale.notifications[data.kind] or locale.notifications.default
|
||||
data.title = locale.name
|
||||
data.text = notification_text:gsub("{actor}", tostring(data.actor or ""))
|
||||
SendNUIMessage({ type = "picstagram:new", data = data })
|
||||
end)
|
||||
|
||||
RegisterNetEvent("sky_phone:feather:new", function(data)
|
||||
local locale = app_locales.feather
|
||||
local notification_text = locale.notifications[data.kind] or locale.notifications.default
|
||||
data.title = locale.name
|
||||
data.text = notification_text:gsub("{actor}", tostring(data.actor or ""))
|
||||
SendNUIMessage({ type = "feather:new", data = data })
|
||||
end)
|
||||
|
||||
RegisterNetEvent("sky_phone:marketplace:new-message", function(data)
|
||||
local locale = app_locales.citymarkt
|
||||
data.title = locale.name
|
||||
if data.kind == "offer" then
|
||||
data.text = locale.newOffer
|
||||
:gsub("{sender}", tostring(data.sender))
|
||||
:gsub("{price}", tostring(data.amount))
|
||||
elseif data.kind == "offer-response" and data.action == "accepted" then
|
||||
data.text = locale.offerAcceptedNotification
|
||||
:gsub("{sender}", tostring(data.sender))
|
||||
:gsub("{price}", tostring(data.amount))
|
||||
elseif data.kind == "offer-response" and data.action == "rejected" then
|
||||
data.text = locale.offerRejectedNotification
|
||||
:gsub("{sender}", tostring(data.sender))
|
||||
:gsub("{price}", tostring(data.amount))
|
||||
else
|
||||
data.text = locale.newMessage:gsub("{sender}", tostring(data.sender))
|
||||
end
|
||||
SendNUIMessage({ type = "marketplace:new-message", data = data })
|
||||
end)
|
||||
|
||||
RegisterNetEvent("sky_phone:calendar:reminder", function(data)
|
||||
local locale = app_locales.calendar
|
||||
data.title = locale.name
|
||||
data.text = locale.reminder:gsub("{title}", tostring(data.eventTitle))
|
||||
SendNUIMessage({ type = "calendar:reminder", data = data })
|
||||
end)
|
||||
|
||||
RegisterNetEvent("sky_phone:flare:match", function(data)
|
||||
local locale = app_locales.flare
|
||||
data.title = locale.name
|
||||
data.text = locale.newMatchNotification:gsub("{sender}", tostring(data.sender))
|
||||
SendNUIMessage({ type = "flare:new-match", data = data })
|
||||
end)
|
||||
|
||||
RegisterNetEvent("sky_phone:flare:message", function(data)
|
||||
local locale = app_locales.flare
|
||||
data.title = locale.name
|
||||
data.text = locale.newMessageNotification:gsub("{sender}", tostring(data.sender))
|
||||
SendNUIMessage({ type = "flare:new-message", data = data })
|
||||
end)
|
||||
|
||||
RegisterNetEvent("sky_phone:billing:new", function(data)
|
||||
local locale = app_locales.billing
|
||||
data.title = locale.name
|
||||
data.text = locale.notifications.newInvoice
|
||||
:gsub("{issuer}", tostring(data.issuer))
|
||||
:gsub("{amount}", ("%s %s"):format(tostring(data.amount), Config.Billing.Currency))
|
||||
SendNUIMessage({ type = "billing:new", data = data })
|
||||
end)
|
||||
|
||||
RegisterNetEvent("sky_phone:crewlink:notification", function(data)
|
||||
local locale = app_locales.crewlink
|
||||
local notification_text = locale.notifications[data.kind] or locale.notifications.default
|
||||
data.title = locale.name
|
||||
data.text = notification_text
|
||||
:gsub("{actor}", tostring(data.actor or ""))
|
||||
:gsub("{group}", tostring(data.groupName or ""))
|
||||
:gsub("{ping}", tostring(data.pingLabel or ""))
|
||||
SendNUIMessage({ type = "crewlink:notification", data = data })
|
||||
end)
|
||||
|
||||
RegisterNetEvent("sky_phone:messages:new", function(data)
|
||||
local locale = app_locales.messages
|
||||
data.title = locale.name
|
||||
data.text = locale.newMessage:gsub("{sender}", tostring(data.sender))
|
||||
SendNUIMessage({ type = "messages:new", data = data })
|
||||
end)
|
||||
|
||||
RegisterNetEvent("sky_phone:darkchat:new", function(data)
|
||||
local locale = app_locales.darkchat
|
||||
data.title = locale.name
|
||||
if data.notificationMode == "private" then
|
||||
data.sender = nil
|
||||
data.text = locale.privateNotification
|
||||
elseif data.notificationMode == "hidden" then
|
||||
data.sender = nil
|
||||
data.text = ""
|
||||
else
|
||||
data.text = locale.newMessage:gsub("{sender}", tostring(data.sender))
|
||||
end
|
||||
SendNUIMessage({ type = "darkchat:new", data = data })
|
||||
end)
|
||||
@@ -1,40 +0,0 @@
|
||||
local relays_with_data = {
|
||||
["sky_phone:mail:changed"] = "mail:changed",
|
||||
["sky_phone:easyshare:changed"] = "easyshare:changed",
|
||||
["sky_phone:marketplace:changed"] = "marketplace:changed",
|
||||
["sky_phone:companies:changed"] = "companies:changed",
|
||||
["sky_phone:fliptok:verification-changed"] = "fliptok:verification-changed",
|
||||
["sky_phone:picstagram:verification-changed"] = "picstagram:verification-changed",
|
||||
["sky_phone:banking:changed"] = "banking:changed",
|
||||
["sky_phone:crewlink:changed"] = "crewlink:changed",
|
||||
["sky_phone:messages:changed"] = "messages:changed",
|
||||
["sky_phone:darkchat:changed"] = "darkchat:changed",
|
||||
}
|
||||
|
||||
local relays_without_data = {
|
||||
["sky_phone:gallery:changed"] = "gallery:changed",
|
||||
["sky_phone:contacts:changed"] = "contacts:changed",
|
||||
["sky_phone:calls:changed"] = "calls:changed",
|
||||
["sky_phone:crypto:account-changed"] = "crypto:account-changed",
|
||||
["sky_phone:billing:changed"] = "billing:changed",
|
||||
}
|
||||
|
||||
for event_name, nui_type in pairs(relays_with_data) do
|
||||
RegisterNetEvent(event_name, function(data)
|
||||
SendNUIMessage({ type = nui_type, data = data })
|
||||
end)
|
||||
end
|
||||
|
||||
for event_name, nui_type in pairs(relays_without_data) do
|
||||
RegisterNetEvent(event_name, function()
|
||||
SendNUIMessage({ type = nui_type })
|
||||
end)
|
||||
end
|
||||
|
||||
RegisterNetEvent("sky_phone:crypto:changed", function(data)
|
||||
if type(data) ~= "table" or type(data.markets) ~= "table" then
|
||||
Bridge.Debug("error", "[sky_phone] Rejected invalid crypto market data.")
|
||||
return
|
||||
end
|
||||
SendNUIMessage({ type = "crypto:changed", data = data })
|
||||
end)
|
||||
@@ -1,100 +0,0 @@
|
||||
local callback_groups = {
|
||||
account = [[login register logout devices remove-device]],
|
||||
banking = [[overview transfer]],
|
||||
billing = [[overview list detail markRead pay dispute]],
|
||||
calendar = [[list create update delete]],
|
||||
calls = [[recents dial answer set-speaker set-muted decline hangup block]],
|
||||
citywarn = [[bootstrap publish update resolve]],
|
||||
companies = [[
|
||||
list get my-requests get-request work-context work-queue list-members
|
||||
create-request cancel-request send-message claim-request assign-request update-request-status
|
||||
update-availability update-profile update-hours update-services publish-announcement
|
||||
set-call-availability call-customer dial-service-line
|
||||
]],
|
||||
contacts = [[list save delete favorite]],
|
||||
crewlink = [[
|
||||
bootstrap login register logout update-profile create-group update-group delete-group set-active
|
||||
join-code rotate-code nearby invite-nearby respond-invite update-member transfer-owner
|
||||
remove-member leave create-ping remove-ping
|
||||
]],
|
||||
crypto = [[
|
||||
bootstrap register login logout quote execute deposit withdraw update-profile recipient transfer
|
||||
]],
|
||||
["custom-app"] = [[storage:get storage:set]],
|
||||
darkchat = [[
|
||||
bootstrap create-profile delete-profile update-profile start thread send media react
|
||||
message-action update-conversation add-contact remove-contact block report clear
|
||||
]],
|
||||
device = [[save notification-open factory-reset]],
|
||||
easyshare = [[bootstrap own-contact set-visibility request respond cancel]],
|
||||
feather = [[
|
||||
bootstrap create-profile update-profile feed explore network create-post react follow connections
|
||||
remove-connection profile bookmarks thread activities mark-activities delete block report
|
||||
]],
|
||||
flare = [[bootstrap delete-profile save-profile set-discovery swipe rewind unmatch thread send]],
|
||||
fliptok = [[
|
||||
register login logout bootstrap feed video discover music-metadata publish react follow
|
||||
comments comment comment-react view share profile connections update-profile activities
|
||||
mark-activities report admin-reports admin-resolve-report block delete
|
||||
]],
|
||||
gallery = [[list counts favorite]],
|
||||
mail = [[
|
||||
register login logout counts mailboxes create-mailbox delete-mailbox list get get-draft
|
||||
save-draft delete-draft delete-many send set-read move trash restore delete-forever empty-trash
|
||||
]],
|
||||
map = [[markers create-marker delete-marker]],
|
||||
marketplace = [[
|
||||
list profile auth profile-save get list-own create update set-status favorite counts
|
||||
list-inquiries get-inquiry send-message make-offer respond-offer report block
|
||||
]],
|
||||
media = [[config import:sources import:list import:commit import:url]],
|
||||
memos = [[list update delete]],
|
||||
messages = [[conversations thread send media delete gifs]],
|
||||
music = [[
|
||||
bootstrap add-youtube remove-youtube create-playlist rename-playlist delete-playlist
|
||||
add-to-playlist remove-from-playlist
|
||||
]],
|
||||
notes = [[list create update delete]],
|
||||
notifications = [[save]],
|
||||
pages = [[
|
||||
list get profile profile-save list-own create share-citymarkt react delete
|
||||
]],
|
||||
picstagram = [[
|
||||
register login logout bootstrap feed post explore search saved profile connections update-profile
|
||||
publish-post update-post set-post-status react follow respond-follow comments comment comment-react
|
||||
remove-comment publish-story stories view-story story-viewers remove-story activities mark-activities
|
||||
block report admin-reports admin-resolve-report
|
||||
]],
|
||||
security = [[unlock set-passcode change-passcode disable-passcode]],
|
||||
sim = [[insert eject]],
|
||||
["weazel-news"] = [[context list get manage-list create update delete]],
|
||||
}
|
||||
|
||||
for namespace, endpoints in pairs(callback_groups) do
|
||||
for endpoint in endpoints:gmatch("%S+") do
|
||||
local callback_name = namespace .. ":" .. endpoint
|
||||
RegisterNUICallback(callback_name, function(data, cb)
|
||||
if type(data) ~= "table" then
|
||||
cb({ success = false, error = "invalid_request" })
|
||||
return
|
||||
end
|
||||
|
||||
local result = Bridge.Callbacks.Trigger("sky_phone:" .. callback_name, data)
|
||||
if callback_name:match("^media:import:") and (not result or not result.success) then
|
||||
Bridge.Debug(
|
||||
"warn",
|
||||
"[sky_phone] NUI callback '%s' failed: %s.",
|
||||
callback_name,
|
||||
tostring(result and result.error or "no server response"),
|
||||
{ always = true }
|
||||
)
|
||||
end
|
||||
if type(result) == "table" then
|
||||
cb(result)
|
||||
return
|
||||
end
|
||||
|
||||
cb({ success = false, error = "request_failed" })
|
||||
end)
|
||||
end
|
||||
end
|
||||
@@ -1,276 +0,0 @@
|
||||
local required_services = {
|
||||
{ name = "SkyPhoneApps", service = SkyPhoneApps },
|
||||
{ name = "SkyPhoneCalls", service = SkyPhoneCalls },
|
||||
{ name = "SkyPhoneCamera", service = SkyPhoneCamera },
|
||||
{ name = "SkyPhoneClient", service = SkyPhoneClient },
|
||||
{ name = "SkyPhoneFocus", service = SkyPhoneFocus },
|
||||
{ name = "SkyPhoneNavigation", service = SkyPhoneNavigation },
|
||||
{ name = "SkyPhonePublicApi", service = SkyPhonePublicApi },
|
||||
}
|
||||
|
||||
for index = 1, #required_services do
|
||||
local entry = required_services[index]
|
||||
if type(entry.service) ~= "table" then
|
||||
error(("[sky_phone] Client public API initialized before %s."):format(entry.name))
|
||||
end
|
||||
end
|
||||
|
||||
local CLIENT_CAPABILITIES = {
|
||||
apiVersion = SkyPhonePublicApi.Version,
|
||||
customAppProtocolVersion = SkyPhoneApps.ProtocolVersion,
|
||||
features = {
|
||||
calls = {
|
||||
audio = true,
|
||||
company = true,
|
||||
video = false,
|
||||
},
|
||||
camera = true,
|
||||
customApps = {
|
||||
enabled = Config.CustomApps.Enabled == true,
|
||||
external = Config.CustomApps.Enabled == true
|
||||
and Config.CustomApps.ExternalApps == true,
|
||||
},
|
||||
equippedPhoneNumber = true,
|
||||
navigation = true,
|
||||
notifications = {
|
||||
customApps = Config.CustomApps.Enabled == true
|
||||
and Config.CustomApps.ExternalApps == true,
|
||||
system = false,
|
||||
},
|
||||
phoneGameInput = true,
|
||||
phoneState = true,
|
||||
},
|
||||
ready = true,
|
||||
side = "client",
|
||||
}
|
||||
|
||||
local custom_app_api = SkyPhoneApps.ClientPublicApi
|
||||
if type(custom_app_api) ~= "table" then
|
||||
error("[sky_phone] Client public API initialized before the custom app API.")
|
||||
end
|
||||
|
||||
local camera_owner_resource
|
||||
|
||||
local function get_calling_resource(error_code)
|
||||
local owner_resource = GetInvokingResource()
|
||||
if type(owner_resource) ~= "string" or owner_resource == "" then
|
||||
return nil, error_code or "resource_required"
|
||||
end
|
||||
return owner_resource
|
||||
end
|
||||
|
||||
local function claim_camera()
|
||||
local owner_resource, owner_error = get_calling_resource()
|
||||
if not owner_resource then
|
||||
return nil, owner_error
|
||||
end
|
||||
if camera_owner_resource and camera_owner_resource ~= owner_resource then
|
||||
return nil, "camera_claimed"
|
||||
end
|
||||
if not camera_owner_resource and SkyPhoneCamera.GetState().active then
|
||||
return nil, "camera_in_use"
|
||||
end
|
||||
|
||||
camera_owner_resource = owner_resource
|
||||
return owner_resource
|
||||
end
|
||||
|
||||
local function verify_camera_owner()
|
||||
local owner_resource, owner_error = get_calling_resource()
|
||||
if not owner_resource then
|
||||
return nil, owner_error
|
||||
end
|
||||
if camera_owner_resource and camera_owner_resource ~= owner_resource then
|
||||
return nil, "camera_claimed"
|
||||
end
|
||||
return owner_resource
|
||||
end
|
||||
|
||||
local function release_camera_state()
|
||||
SkyPhoneCamera.DisableWalkable()
|
||||
SkyPhoneCamera.SetFlashlight(false)
|
||||
SkyPhoneCamera.SetSelfie(false)
|
||||
camera_owner_resource = nil
|
||||
end
|
||||
|
||||
local function get_api_capabilities()
|
||||
return SkyPhonePublicApi.Copy(CLIENT_CAPABILITIES)
|
||||
end
|
||||
|
||||
local function is_api_ready()
|
||||
return true
|
||||
end
|
||||
|
||||
local function set_phone_game_input_enabled(enabled)
|
||||
if type(enabled) ~= "boolean" then
|
||||
return false, "invalid_focus_claim"
|
||||
end
|
||||
|
||||
local owner_resource = GetInvokingResource()
|
||||
if type(owner_resource) ~= "string" or owner_resource == "" then
|
||||
return false, "resource_required"
|
||||
end
|
||||
return SkyPhoneFocus.SetExternalGameInput(owner_resource, enabled)
|
||||
end
|
||||
|
||||
local function set_flashlight(enabled)
|
||||
if type(enabled) ~= "boolean" then
|
||||
return false, "invalid_state"
|
||||
end
|
||||
local owner_resource, owner_error
|
||||
if enabled then
|
||||
owner_resource, owner_error = claim_camera()
|
||||
else
|
||||
owner_resource, owner_error = verify_camera_owner()
|
||||
end
|
||||
if not owner_resource then
|
||||
return false, owner_error
|
||||
end
|
||||
SkyPhoneCamera.SetFlashlight(enabled)
|
||||
return true
|
||||
end
|
||||
|
||||
local function set_selfie_camera(enabled)
|
||||
if type(enabled) ~= "boolean" then
|
||||
return false, "invalid_state"
|
||||
end
|
||||
local owner_resource, owner_error
|
||||
if enabled then
|
||||
owner_resource, owner_error = claim_camera()
|
||||
else
|
||||
owner_resource, owner_error = verify_camera_owner()
|
||||
end
|
||||
if not owner_resource then
|
||||
return false, owner_error
|
||||
end
|
||||
SkyPhoneCamera.SetSelfie(enabled)
|
||||
return true
|
||||
end
|
||||
|
||||
local function enable_walkable_camera(selfie)
|
||||
if selfie ~= nil and type(selfie) ~= "boolean" then
|
||||
return false, "invalid_state"
|
||||
end
|
||||
local owner_resource, owner_error = claim_camera()
|
||||
if not owner_resource then
|
||||
return false, owner_error
|
||||
end
|
||||
SkyPhoneCamera.EnableWalkable(selfie == true)
|
||||
return true
|
||||
end
|
||||
|
||||
local function disable_walkable_camera()
|
||||
local owner_resource, owner_error = verify_camera_owner()
|
||||
if not owner_resource then
|
||||
return false, owner_error
|
||||
end
|
||||
if camera_owner_resource == owner_resource then
|
||||
release_camera_state()
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
local function toggle_camera_frozen()
|
||||
if not SkyPhoneCamera.GetState().active then
|
||||
return false, "camera_not_active"
|
||||
end
|
||||
local owner_resource, owner_error = claim_camera()
|
||||
if not owner_resource then
|
||||
return false, owner_error
|
||||
end
|
||||
SkyPhoneCamera.ToggleFrozen()
|
||||
return true, SkyPhoneCamera.GetState()
|
||||
end
|
||||
|
||||
local function set_camera_frozen(frozen)
|
||||
if type(frozen) ~= "boolean" then
|
||||
return false, "invalid_state"
|
||||
end
|
||||
local camera_state = SkyPhoneCamera.GetState()
|
||||
if not camera_state.active then
|
||||
return false, "camera_not_active"
|
||||
end
|
||||
local owner_resource, owner_error = claim_camera()
|
||||
if not owner_resource then
|
||||
return false, owner_error
|
||||
end
|
||||
|
||||
if camera_state.frozen ~= frozen then
|
||||
SkyPhoneCamera.ToggleFrozen()
|
||||
camera_state = SkyPhoneCamera.GetState()
|
||||
end
|
||||
return true, camera_state
|
||||
end
|
||||
|
||||
local function release_camera()
|
||||
local owner_resource, owner_error = verify_camera_owner()
|
||||
if not owner_resource then
|
||||
return false, owner_error
|
||||
end
|
||||
if camera_owner_resource == owner_resource then
|
||||
release_camera_state()
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
exports("GetApiCapabilities", get_api_capabilities)
|
||||
exports("IsApiReady", is_api_ready)
|
||||
|
||||
exports("TogglePhone", SkyPhoneClient.Toggle)
|
||||
exports("GetPhoneState", SkyPhoneClient.GetState)
|
||||
exports("GetEquippedPhoneNumber", SkyPhoneClient.GetEquippedPhoneNumber)
|
||||
|
||||
exports("OpenApp", SkyPhoneNavigation.Open)
|
||||
exports("CloseApp", SkyPhoneNavigation.Close)
|
||||
exports("GetNavigationState", SkyPhoneNavigation.GetState)
|
||||
exports("GetCurrentApp", SkyPhoneNavigation.GetCurrent)
|
||||
exports("IsAppDataLoaded", SkyPhoneNavigation.IsDataLoaded)
|
||||
exports("IsAppInstalled", SkyPhoneNavigation.IsInstalled)
|
||||
|
||||
exports("Dial", SkyPhoneCalls.Dial)
|
||||
exports("AnswerCall", SkyPhoneCalls.Answer)
|
||||
exports("DeclineCall", SkyPhoneCalls.Decline)
|
||||
exports("HangupCall", SkyPhoneCalls.Hangup)
|
||||
exports("TerminateCall", SkyPhoneCalls.Terminate)
|
||||
exports("GetActiveCall", SkyPhoneCalls.GetActive)
|
||||
exports("IsInCall", SkyPhoneCalls.IsActive)
|
||||
|
||||
exports("GetCameraState", SkyPhoneCamera.GetState)
|
||||
exports("SetFlashlight", set_flashlight)
|
||||
exports("SetSelfieCamera", set_selfie_camera)
|
||||
exports("EnableWalkableCamera", enable_walkable_camera)
|
||||
exports("DisableWalkableCamera", disable_walkable_camera)
|
||||
exports("SetCameraFrozen", set_camera_frozen)
|
||||
exports("ToggleCameraFrozen", toggle_camera_frozen)
|
||||
exports("ReleaseCamera", release_camera)
|
||||
|
||||
exports("SetPhoneGameInputEnabled", set_phone_game_input_enabled)
|
||||
|
||||
exports("AddCustomApp", custom_app_api.AddCustomApp)
|
||||
exports("AddCustomAppFromAdapter", custom_app_api.AddCustomAppFromAdapter)
|
||||
exports("CloseActiveCustomAppFromAdapter", custom_app_api.CloseActiveCustomAppFromAdapter)
|
||||
exports("CloseCustomApp", custom_app_api.CloseCustomApp)
|
||||
exports("CloseCustomAppFromAdapter", custom_app_api.CloseCustomAppFromAdapter)
|
||||
exports("GetCustomAppCapabilities", custom_app_api.GetCustomAppCapabilities)
|
||||
exports("OpenCustomApp", custom_app_api.OpenCustomApp)
|
||||
exports("OpenCustomAppFromAdapter", custom_app_api.OpenCustomAppFromAdapter)
|
||||
exports("RemoveCustomApp", custom_app_api.RemoveCustomApp)
|
||||
exports("RemoveCustomAppFromAdapter", custom_app_api.RemoveCustomAppFromAdapter)
|
||||
exports("SendAppMessage", custom_app_api.SendAppMessage)
|
||||
exports("SendCustomAppMessage", custom_app_api.SendCustomAppMessage)
|
||||
exports("SendCustomAppMessageFromAdapter", custom_app_api.SendCustomAppMessageFromAdapter)
|
||||
exports("SendCustomAppNotification", custom_app_api.SendCustomAppNotification)
|
||||
exports(
|
||||
"SendCustomAppNotificationFromAdapter",
|
||||
custom_app_api.SendCustomAppNotificationFromAdapter
|
||||
)
|
||||
exports("UpdateCustomApp", custom_app_api.UpdateCustomApp)
|
||||
exports("UpdateCustomAppFromAdapter", custom_app_api.UpdateCustomAppFromAdapter)
|
||||
|
||||
AddEventHandler("onClientResourceStop", function(resource_name)
|
||||
if resource_name == camera_owner_resource then
|
||||
release_camera_state()
|
||||
end
|
||||
end)
|
||||
|
||||
TriggerEvent("sky_phone:client:apiReady", SkyPhonePublicApi.Version)
|
||||
@@ -1,52 +0,0 @@
|
||||
SkyPhoneSimPicker = {}
|
||||
|
||||
local is_open = false
|
||||
local payload = nil
|
||||
|
||||
local function close_picker(notify_server)
|
||||
is_open = false
|
||||
payload = nil
|
||||
SkyPhoneFocus.SetSimPicker(false)
|
||||
SendNUIMessage({ type = "sim:picker-close" })
|
||||
if notify_server then
|
||||
return Bridge.Callbacks.Trigger("sky_phone:sim:picker-close", {})
|
||||
end
|
||||
end
|
||||
|
||||
function SkyPhoneSimPicker.ReplayNui()
|
||||
if is_open and payload then
|
||||
SendNUIMessage({ type = "sim:picker", data = payload })
|
||||
return
|
||||
end
|
||||
SendNUIMessage({ type = "sim:picker-close" })
|
||||
end
|
||||
|
||||
function SkyPhoneSimPicker.Reset()
|
||||
is_open = false
|
||||
payload = nil
|
||||
SkyPhoneFocus.SetSimPicker(false)
|
||||
end
|
||||
|
||||
RegisterNUICallback("sim:picker-close", function(data, cb)
|
||||
if type(data) ~= "table" then
|
||||
cb({ success = false, error = "invalid_request" })
|
||||
return
|
||||
end
|
||||
local result = close_picker(true)
|
||||
cb(type(result) == "table" and result or { success = false, error = "request_failed" })
|
||||
end)
|
||||
|
||||
RegisterNetEvent("sky_phone:sim:picker", function(data)
|
||||
if type(data) ~= "table" or type(data.number) ~= "string" or type(data.choices) ~= "table" then
|
||||
Bridge.Debug("error", "[sky_phone] Rejected invalid SIM picker data.")
|
||||
return
|
||||
end
|
||||
is_open = true
|
||||
payload = data
|
||||
SkyPhoneFocus.SetSimPicker(true)
|
||||
SendNUIMessage({ type = "sim:picker", data = data })
|
||||
end)
|
||||
|
||||
RegisterNetEvent("sky_phone:sim:picker-close", function()
|
||||
close_picker(false)
|
||||
end)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user