Compare commits

..

20 Commits

Author SHA1 Message Date
DerEchteAlec 3314fa5355 Merge branch 'dev' into feat/refine-phone-setup 2026-08-20 18:25:45 +02:00
Leon.Schmidt 30559048fd FIX - complete LB custom app compatibility (#11)
* FIX - preserve LB custom app lifecycle

* FIX - reset LB export aliases before startup

* FIX - report competing custom app providers

* FIX - identify LB app frames as live NUI

* FIX - complete LB custom app compatibility

* FIX - provide LB PicChat runtime exports

* FIX - route LB PicChat through Sky Phone

* FIX - return cached LB equipped phone number

* FIX - harden PicChat compatibility migration

* ENH - complete modular phone provider and Creator APIs

* DOC - document the Sky Phone Creator API

---------

Co-authored-by: DerEchteAlec <bycraky@gmail.com>
Co-authored-by: DerEchteAlec <alec.schitzkat@luwan.io>
2026-08-20 18:19:16 +02:00
DerEchteAlec 637168c616 Merge branch 'dev' into feat/refine-phone-setup 2026-08-20 17:26:47 +02:00
DerEchteAlec 9aec3eefef FIX - resolve CodeQL security alerts (#16) 2026-08-20 17:26:26 +02:00
DerEchteAlec 39fe171961 Merge branch 'dev' into feat/refine-phone-setup 2026-08-20 17:17:17 +02:00
Leon.Schmidt 5b5e9fe914 FIX - allow movement outside phone text fields (#14) 2026-08-20 15:47:39 +02:00
smx.pusha c2fe8b5e23 FIX - correct messages GIF picker layout 2026-08-20 12:52:14 +02:00
smx.pusha a24cda989a ENH - apply liquid glass across app controls 2026-08-20 12:38:46 +02:00
smx.pusha b9f8930f00 ENH - refine phone liquid glass controls 2026-08-20 11:50:41 +02:00
smx.pusha 5efd806e43 ENH - refine notes header and compose controls 2026-08-20 11:02:44 +02:00
smx.pusha 65a0846eea FIX - align memo player content 2026-08-20 10:46:09 +02:00
smx.pusha 0a0053c3af ENH - align gallery filter navigation 2026-08-20 10:03:18 +02:00
smx.pusha 2f0faa8206 ENH - fade empty music widget content 2026-08-20 09:48:56 +02:00
smx.pusha 76b0378649 FIX - refine compact weather layout 2026-08-20 09:25:34 +02:00
smx.pusha daa67cfb1e ENH - refine phone setup experience (#12) 2026-08-20 08:55:03 +02:00
smx.pusha 07b348a01a FIX - refine settings header and search icon 2026-08-20 08:45:58 +02:00
smx.pusha 9abf53b140 ENH - refine phone setup experience 2026-08-20 08:25:54 +02:00
DerEchteAlec 3eae2b8d30 ADD - check GitHub releases on startup (#10)
Compare the fxmanifest version against the latest published sky_phone release tag and report update, current, ahead, and failure states without blocking resource startup.

Document the startup check and cover the HTTP and version comparison behavior with a focused Lua test.
2026-08-19 20:57:02 +02:00
Dominik 734f4651cb ADD - add housing provider bridges (#6)
Co-authored-by: DerEchteAlec <alec.schitzkat@luwan.io>
2026-08-19 20:11:36 +02:00
DerEchteAlec 98373e8bd8 FIX - await setup persistence before completion (#9) 2026-08-19 20:08:33 +02:00
168 changed files with 17285 additions and 3891 deletions
+750
View File
@@ -0,0 +1,750 @@
# 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.
+11 -2
View File
@@ -89,7 +89,7 @@ Sky Phone is built to be the **free FiveM phone you can choose without accepting
| **Inventories** | ox_inventory, qb-inventory, lj-inventory, qs-inventory, codem-inventory, core_inventory, mf-inventory, smx-inventory, hex_4_inventory, and native ESX inventory |
| **Calls** | YACA, PMA Voice, SaltyChat |
| **Radio** | YACA, PMA Voice, SaltyChat |
| **Housing** | ESX Property, qbx_properties |
| **Housing** | RTX Housing, Quasar Housing, VMS Housing, RX Housing, NoLag Properties, SN Properties, ESX Property, qbx_properties |
| **Garages** | Built-in/custom data and a broad set of popular garage providers configured through the bridge |
| **Custom app contracts** | Sky Phone, LB Phone, 17Movement, High Phone, Quasar Smartphone, YSeries |
| **Languages** | English, German |
@@ -249,6 +249,11 @@ When enabled, Sky Phone prints debug and informational messages. Warnings and er
The short LB Phone detection notice also remains visible when debug mode is disabled.
On every resource start, Sky Phone compares the `version` in `fxmanifest.lua` with the tag of the
latest published [GitHub release](https://github.com/sky-systems/sky_phone/releases/latest). The
server console reports whether the installed version is current and shows the release link when an
update is available. A failed GitHub request is reported but does not prevent the phone from starting.
## Security values
Sky Phone ships with stable generated defaults in `Config.Server`:
@@ -525,7 +530,7 @@ Select the provider under `Config.Garage.System`. Vehicle images use the configu
### Housing
Select the provider under `Config.Housing.System`. Automatic mode supports the configured provider priority.
Select `rtx`, `quasar`, `vms`, `rx`, `nolag`, `sn`, `esx_property`, or `qbx_properties` under `Config.Housing.System`. Automatic mode uses `Config.Housing.AutoPriority` and keeps the existing `esx_property` and `qbx_properties` defaults ahead of newly supported providers. Select a provider explicitly when multiple housing resources are running. Each bridge exposes only the capabilities supported by the documented provider API.
### Companies
@@ -548,6 +553,8 @@ 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
@@ -560,6 +567,8 @@ 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.
+107 -9
View File
@@ -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 } from '@/config/apps'
import { isPhoneAppId, PHONE_APPS } from '@/config/apps'
import { useNotesStore } from '@/stores/notes'
import { useMemosStore } from '@/stores/memos'
import { useWeatherStore } from '@/stores/weather'
@@ -77,6 +77,7 @@ import { nuiCall } from '@/utils/nui'
import { formatTimer } from '@/utils/clock'
import { parsePhonePreferences } from '@/utils/preferences'
import { getHairlinePixelStyle } from '@/utils/rendering'
import { isTextInputElement } from '@/utils/textInputFocus'
import { isTrustedRootMessageSource } from '@/utils/windowMessages'
import SpringboardView from '@/views/SpringboardView.vue'
@@ -105,6 +106,7 @@ type AppMessage = {
| PhoneOpenPayload
| CustomAppCatalogEventData
| CustomAppEventData
| NavigationEventData
}
type CustomAppCatalogEventData = {
@@ -117,6 +119,10 @@ type CustomAppEventData = {
payload?: unknown
}
type NavigationEventData = {
appId?: unknown
}
type SimPickerPayload = {
choices: SimPhoneChoice[]
number: string
@@ -271,12 +277,14 @@ const MIN_PRODUCTION_PHONE_ZOOM = 260 / PHONE_PORTRAIT_WIDTH
const developmentParameters = new URLSearchParams(window.location.search)
const isBrowserPreview =
developmentParameters.has('browserPreview') &&
developmentParameters.get('apiBase')?.startsWith('/') === true
(import.meta.env.DEV ||
developmentParameters.get('apiBase')?.startsWith('/') === true)
const isDevelopment =
import.meta.env.DEV ||
developmentParameters.get('apiBase')?.startsWith('/') === true
const developmentLockScreenPreview =
isDevelopment && developmentParameters.has('lockScreenPreview')
let textInputFocused = false
const phone = usePhoneStore()
const account = useAccountStore()
@@ -346,6 +354,7 @@ const previousHardwareAlertVolume = ref(75)
const hardwareVolumeHudVisible = ref(false)
const setupPreviewDismissed = ref(false)
const setupDevelopmentSkipped = ref(false)
const setupAppearanceSelected = ref(false)
const pendingUnlockRoute = ref<string | null>(null)
const unlockedServicesLoaded = ref(false)
const controlCenterOpened = ref(false)
@@ -359,6 +368,13 @@ const setupRequired = computed(
developmentParameters.has('setupPreview') &&
!setupPreviewDismissed.value)),
)
const displayedDarkMode = computed(
() =>
phone.isDarkMode &&
(!setupRequired.value ||
setupAppearanceSelected.value ||
phone.preferences.settings.setupStep > 4),
)
const hardwareAlertVolume = computed(() =>
Math.round(
(phone.preferences.settings.notificationVolume +
@@ -483,6 +499,23 @@ 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') {
@@ -572,6 +605,7 @@ function loadUnlockedPhoneData(): void {
function completePhoneSetup(): void {
setupPreviewDismissed.value = true
setupAppearanceSelected.value = false
isLocked.value = false
isUnlocking.value = false
passcodeVisible.value = false
@@ -699,9 +733,33 @@ 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 nuiCall('ui:opened')
void syncNavigationState().then(() => nuiCall('ui:opened'))
} else if (event.data?.type === 'device:updated') {
hydratePhone(event.data.data as PhoneOpenPayload)
} else if (event.data?.type === 'app:close') {
@@ -1394,7 +1452,29 @@ function unlockCamera(): void {
window.setTimeout(() => void router.push('/apps/camera'), 0)
}
function updateTextInputFocus(active: boolean): void {
if (textInputFocused === active) return
textInputFocused = active
void nuiCall('ui:input-focus', { active })
}
function onFocusIn(event: FocusEvent): void {
const target = event.target
updateTextInputFocus(
target instanceof HTMLElement && isTextInputElement(target),
)
}
function onFocusOut(event: FocusEvent): void {
const nextTarget = event.relatedTarget
updateTextInputFocus(
nextTarget instanceof HTMLElement && isTextInputElement(nextTarget),
)
}
onMounted(() => {
document.addEventListener('focusin', onFocusIn)
document.addEventListener('focusout', onFocusOut)
window.addEventListener('message', onMessage)
window.addEventListener('keydown', onKeydown)
window.addEventListener('resize', updateViewportScale)
@@ -1488,6 +1568,18 @@ watch(
},
)
watch(
() => ({
appIds: getInstalledNavigationAppIds(),
currentApp: activeAppId.value,
open: phone.isOpen,
}),
() => {
if (phone.isOpen && appStore.hydrated) void syncNavigationState()
},
{ deep: true },
)
watch(
() => notifications.requiresAttention,
(requiresAttention) => {
@@ -1500,6 +1592,7 @@ watch(
(isOpen) => {
if (unlockTimer !== undefined) window.clearTimeout(unlockTimer)
if (!isOpen) {
updateTextInputFocus(false)
cancelUnlockedPhoneDataLoad()
appStore.cancelPendingInstalls()
activitySuspended.value = false
@@ -1557,6 +1650,7 @@ watch(
)
onBeforeUnmount(() => {
updateTextInputFocus(false)
cancelUnlockedPhoneDataLoad()
weather.stop()
if (clockTicker) clearInterval(clockTicker)
@@ -1571,6 +1665,8 @@ onBeforeUnmount(() => {
window.removeEventListener('message', onMessage)
window.removeEventListener('keydown', onKeydown)
window.removeEventListener('resize', updateViewportScale)
document.removeEventListener('focusin', onFocusIn)
document.removeEventListener('focusout', onFocusOut)
systemColorScheme.removeEventListener('change', onSystemColorSchemeChange)
})
</script>
@@ -1624,7 +1720,7 @@ onBeforeUnmount(() => {
<section
class="phone-device"
:class="{
'phone-app--light': !phone.isDarkMode,
'phone-app--light': !displayedDarkMode,
[`phone-app--${phone.preferences.settings.graphicsMode}`]: true,
}"
:aria-label="phone.t('Common.phone')"
@@ -1674,7 +1770,7 @@ onBeforeUnmount(() => {
class="phone-screen"
:class="{
'phone-screen--app': isAppRoute || isDevelopmentRoute,
'phone-app--light': !phone.isDarkMode,
'phone-app--light': !displayedDarkMode,
[`phone-app--${phone.preferences.settings.graphicsMode}`]: true,
}"
>
@@ -1711,18 +1807,19 @@ onBeforeUnmount(() => {
</Transition>
<k-app
theme="ios"
:dark="phone.isDarkMode"
:dark="displayedDarkMode"
safe-areas
class="phone-app"
:style="phoneDisplayStyle"
:class="{
dark: phone.isDarkMode,
'phone-app--light': !phone.isDarkMode,
dark: displayedDarkMode,
'phone-app--light': !displayedDarkMode,
'phone-app--messages': route.params.appId === 'messages',
'phone-app--status-light':
WHITE_STATUS_BAR_APP_IDS.has(activeAppId),
'phone-app--status-dark':
DARK_STATUS_BAR_APP_IDS.has(activeAppId),
'phone-app--setup': setupRequired,
[`phone-app--${phone.preferences.settings.graphicsMode}`]: true,
'phone-app--unlocking': isUnlocking,
}"
@@ -1742,7 +1839,7 @@ onBeforeUnmount(() => {
/>
<SkyProvider
class="phone-app-theme"
:dark="phone.isDarkMode"
:dark="displayedDarkMode"
safe-areas
>
<RouterView v-slot="{ Component }">
@@ -1801,6 +1898,7 @@ onBeforeUnmount(() => {
</Transition>
<PhoneSetupAssistant
v-if="setupRequired"
@appearance-selected="setupAppearanceSelected = $event"
@complete="completePhoneSetup"
@skip="skipPhoneSetupForDevelopment"
/>
@@ -9,6 +9,17 @@ const mainCss = readFileSync(
)
describe('browser development preview contract', () => {
it('keeps setup light until the appearance choice has been confirmed', () => {
expect(source).toContain('const displayedDarkMode = computed(')
expect(source).toContain('phone.preferences.settings.setupStep > 4')
expect(source).toContain('setupAppearanceSelected.value')
expect(source).toContain(
'@appearance-selected="setupAppearanceSelected = $event"',
)
expect(source).toContain(':dark="displayedDarkMode"')
expect(source).toContain('dark: displayedDarkMode')
})
it('starts unlocked while preserving an explicit lock screen preview', () => {
expect(source).toContain("developmentParameters.has('lockScreenPreview')")
expect(source).toContain(': !isDevelopment || developmentLockScreenPreview')
@@ -87,6 +98,7 @@ describe('browser development preview contract', () => {
it('fills the dedicated browser embed without clipping the hardware controls', () => {
expect(source).toContain("developmentParameters.has('browserPreview')")
expect(source).toContain('import.meta.env.DEV ||')
expect(source).toContain("'phone-stage--browser-preview': isBrowserPreview")
expect(source).toContain('return (availableScale * 0.94) / PHONE_BASE_SCALE')
expect(mainCss).toMatch(
+91 -334
View File
@@ -904,361 +904,63 @@ button {
}
.wallpaper--midnight {
background:
radial-gradient(circle at 76% 17%, #ffffff 0 0.7px, transparent 1.2px),
radial-gradient(circle at 28% 8%, #9fceff 0 0.6px, transparent 1px),
radial-gradient(circle at 82% 48%, #d8c8ff 0 0.8px, transparent 1.3px),
radial-gradient(
circle at 69% 19%,
#faf7ff 0 3%,
#a57be74d 8%,
transparent 23%
),
conic-gradient(
from 214deg at 63% 35%,
transparent 0 29%,
#7352a51f 36%,
transparent 44%
),
radial-gradient(ellipse at 10% 84%, #1767999e 0, transparent 45%),
linear-gradient(158deg, #050511 0%, #11132d 42%, #120a25 68%, #02040c 100%);
background-size:
43px 47px,
67px 71px,
89px 83px,
auto,
auto,
auto,
auto;
radial-gradient(circle at 78% 12%, rgb(87 103 160 / 18%), transparent 38%),
linear-gradient(160deg, #141724 0%, #090b12 100%);
}
.wallpaper--aurora {
background:
radial-gradient(circle at 17% 14%, #dfffff 0 0.6px, transparent 1.1px),
radial-gradient(circle at 78% 9%, #c6e7ff 0 0.7px, transparent 1.2px),
conic-gradient(
from 218deg at 84% 8%,
transparent 0 18%,
#4effbf70 27%,
#6bf5e220 35%,
transparent 43%
),
conic-gradient(
from 224deg at 62% 26%,
transparent 0 20%,
#27cfd46b 28%,
#695eff42 38%,
transparent 47%
),
radial-gradient(ellipse at 12% 80%, #1860bb9c 0%, transparent 48%),
radial-gradient(ellipse at 76% 20%, #2adfbb6e 0%, transparent 38%),
linear-gradient(155deg, #031715 0%, #08293a 43%, #151643 68%, #040914 100%);
background-size:
59px 61px,
83px 79px,
auto,
auto,
auto,
auto,
auto;
radial-gradient(circle at 74% 18%, rgb(80 188 166 / 22%), transparent 42%),
linear-gradient(155deg, #163c3c 0%, #17263c 56%, #0b101b 100%);
}
.wallpaper--ember {
background:
radial-gradient(
circle at 80% 18%,
#fff4ca 0 1.7%,
#ffbd67 5%,
#ff62335c 17%,
transparent 31%
),
repeating-radial-gradient(
ellipse at 78% 21%,
transparent 0 16px,
#ffb15e12 18px 20px
),
conic-gradient(
from 196deg at 12% 78%,
transparent 0 18%,
#ff3d4c66 28%,
#f3943a29 38%,
transparent 48%
),
conic-gradient(
from 22deg at 78% 90%,
transparent 0 22%,
#b52a755e 31%,
transparent 44%
),
radial-gradient(ellipse at 8% 71%, #c8205c91 0, transparent 46%),
linear-gradient(152deg, #20080a 0%, #4b171d 37%, #3c1537 66%, #090710 100%);
radial-gradient(circle at 82% 14%, rgb(237 142 96 / 28%), transparent 40%),
linear-gradient(155deg, #5b2925 0%, #321b25 58%, #17131b 100%);
}
.wallpaper--ocean {
background:
linear-gradient(116deg, transparent 43%, #c7fbff1c 47%, transparent 51%),
linear-gradient(64deg, transparent 44%, #8defff17 48%, transparent 52%),
radial-gradient(
ellipse at 18% 12%,
#d7ffff 0 1%,
#70e7ff85 12%,
transparent 34%
),
repeating-radial-gradient(
ellipse at 48% 107%,
transparent 0 24px,
#73e8ff2e 27px 30px,
transparent 33px 49px
),
radial-gradient(ellipse at 87% 70%, #056bd1ce 0, transparent 48%),
linear-gradient(
172deg,
#b5f4f8 0%,
#148eb0 22%,
#075b91 54%,
#03284d 78%,
#020c20 100%
);
background-size:
82px 82px,
82px 82px,
auto,
auto,
auto,
auto;
radial-gradient(circle at 22% 10%, rgb(119 203 222 / 28%), transparent 40%),
linear-gradient(175deg, #327e98 0%, #185273 46%, #0b2944 100%);
}
.wallpaper--sunrise {
background:
linear-gradient(18deg, #281c46 0 13%, transparent 13.3%),
linear-gradient(-22deg, #51304a 0 18%, transparent 18.3%),
radial-gradient(
circle at 58% 69%,
#fffbd7 0 5.5%,
#ffd28d 6%,
#ff9f666e 18%,
transparent 35%
),
repeating-linear-gradient(0deg, transparent 0 16px, #ffe2ae0d 17px 18px),
radial-gradient(ellipse at 20% 44%, #f888a360 0, transparent 42%),
linear-gradient(
178deg,
#342b78 0%,
#8a477f 35%,
#ed706f 57%,
#ffc277 72%,
#672e50 100%
);
radial-gradient(circle at 68% 68%, rgb(255 205 153 / 30%), transparent 42%),
linear-gradient(175deg, #6b6488 0%, #b46f78 52%, #d29673 100%);
}
.wallpaper--violet {
background:
radial-gradient(
circle at 78% 14%,
transparent 0 9%,
#e0b6ff35 9.5% 10.3%,
transparent 11%
),
radial-gradient(
circle at 18% 74%,
transparent 0 16%,
#a1a2ff29 16.5% 17.2%,
transparent 18%
),
conic-gradient(
from 202deg at 83% 25%,
transparent 0 17%,
#e48dff91 25%,
#8d63ff32 35%,
transparent 45%
),
linear-gradient(122deg, transparent 36%, #f0c9ff13 39%, transparent 42%),
radial-gradient(circle at 13% 81%, #5555e5c4 0, transparent 46%),
radial-gradient(circle at 72% 28%, #a53dcc70 0, transparent 41%),
linear-gradient(148deg, #11072b 0%, #391164 48%, #20103f 72%, #080519 100%);
radial-gradient(circle at 74% 18%, rgb(164 129 199 / 24%), transparent 42%),
linear-gradient(150deg, #513d69 0%, #302b50 56%, #171828 100%);
}
.wallpaper--forest {
background:
radial-gradient(
ellipse at 82% 12%,
transparent 0 7%,
#c8f49c2c 7.5% 8.2%,
transparent 9%
),
repeating-radial-gradient(
ellipse at -8% 108%,
transparent 0 34px,
#c1e6a218 36px 39px
),
linear-gradient(
135deg,
transparent 42%,
#bfe8a31c 45% 47%,
transparent 50%
),
conic-gradient(
from 55deg at 85% 24%,
transparent 0 24%,
#8dcf7361 31%,
transparent 39%
),
radial-gradient(ellipse at 76% 16%, #b7df8675 0, transparent 36%),
radial-gradient(ellipse at 10% 78%, #0b8b70b8 0, transparent 49%),
linear-gradient(162deg, #061713 0%, #144833 45%, #123326 68%, #040d0b 100%);
background-size:
auto,
auto,
auto,
auto,
auto,
auto,
100% 100%;
radial-gradient(circle at 76% 14%, rgb(126 161 121 / 24%), transparent 42%),
linear-gradient(160deg, #395c4c 0%, #243e35 54%, #13231f 100%);
}
.wallpaper--cobalt {
background:
linear-gradient(
145deg,
transparent 0 47%,
#c4ddff18 48% 49%,
transparent 50%
),
linear-gradient(
35deg,
transparent 0 47%,
#70a5ff12 48% 49%,
transparent 50%
),
radial-gradient(
circle at 76% 25%,
transparent 0 12%,
#62b8ff45 12.5% 13.2%,
transparent 14%
),
conic-gradient(
from 215deg at 80% 28%,
transparent 0 22%,
#52b5ff85 30%,
#5457d62e 41%,
transparent 48%
),
radial-gradient(circle at 14% 78%, #134ed1a3 0, transparent 43%),
linear-gradient(152deg, #041438 0%, #0b3182 43%, #172d82 64%, #030a25 100%);
background-size:
58px 58px,
58px 58px,
auto,
auto,
auto,
auto;
radial-gradient(circle at 72% 16%, rgb(91 142 218 / 28%), transparent 40%),
linear-gradient(155deg, #264f8b 0%, #1b3768 54%, #101e3b 100%);
}
.wallpaper--rose {
background:
radial-gradient(
circle at 16% 18%,
#fff6f3 0 1.5%,
#ffd9df9e 8%,
transparent 27%
),
repeating-radial-gradient(
ellipse at 108% 8%,
transparent 0 24px,
#ffd5e121 26px 28px
),
conic-gradient(
from 214deg at 86% 72%,
transparent 0 18%,
#f078aa8c 27%,
#8d3d7b38 39%,
transparent 48%
),
linear-gradient(138deg, transparent 38%, #fff0f217 43%, transparent 48%),
radial-gradient(circle at 87% 77%, #e84e92a8 0, transparent 43%),
linear-gradient(148deg, #40132f 0%, #8e3159 39%, #b64b70 59%, #351128 100%);
radial-gradient(circle at 24% 12%, rgb(224 154 172 / 26%), transparent 42%),
linear-gradient(155deg, #9a596c 0%, #724354 54%, #412936 100%);
}
.wallpaper--sand {
background:
radial-gradient(
circle at 76% 16%,
#fffce7 0 2.5%,
#ffe5a5a8 9%,
transparent 25%
),
repeating-radial-gradient(
ellipse at -2% 110%,
transparent 0 28px,
#fff3d12b 31px 34px,
transparent 37px 52px
),
conic-gradient(
from 82deg at 110% 76%,
transparent 0 24%,
#ecc37e52 32%,
transparent 43%
),
linear-gradient(115deg, transparent 41%, #fff6d21a 44%, transparent 47%),
radial-gradient(ellipse at 15% 78%, #9f654b9e 0, transparent 48%),
linear-gradient(163deg, #8b5c45 0%, #d4a262 43%, #b87950 68%, #4f302b 100%);
radial-gradient(circle at 76% 14%, rgb(222 190 145 / 25%), transparent 42%),
linear-gradient(160deg, #a17d61 0%, #80604e 56%, #513e36 100%);
}
.wallpaper--graphite {
background:
radial-gradient(circle at 77% 17%, #ffffff42 0 0.8%, transparent 12%),
linear-gradient(
125deg,
transparent 39%,
#ffffff12 40% 42%,
transparent 43%
),
linear-gradient(35deg, transparent 39%, #ffffff0a 40% 42%, transparent 43%),
repeating-linear-gradient(
92deg,
transparent 0 3px,
#ffffff09 4px,
transparent 5px 9px
),
conic-gradient(
from 215deg at 79% 24%,
transparent 0 25%,
#b7c1d045 32%,
transparent 43%
),
radial-gradient(circle at 17% 78%, #51596c73 0, transparent 44%),
linear-gradient(157deg, #06070a 0%, #242832 43%, #171920 67%, #030405 100%);
background-size:
auto,
54px 54px,
54px 54px,
100% 100%,
auto,
auto,
auto;
radial-gradient(circle at 76% 14%, rgb(133 140 151 / 18%), transparent 40%),
linear-gradient(160deg, #363a42 0%, #23262c 54%, #121417 100%);
}
.wallpaper--prism {
background:
linear-gradient(
132deg,
transparent 0 32%,
#ffffff36 32.5% 33.2%,
transparent 34%
),
linear-gradient(
42deg,
transparent 0 56%,
#bffcff25 56.5% 57.2%,
transparent 58%
),
conic-gradient(
from 218deg at 70% 29%,
#ff527ca1,
#ffc75f91,
#4cead2a6,
#4f72ffae,
#c64deaa3,
#ff527ca1
),
conic-gradient(
from 38deg at 16% 78%,
transparent 0 19%,
#4ae3d8a1 28%,
#596cff48 39%,
transparent 48%
),
radial-gradient(circle at 16% 76%, #35d8d399 0, transparent 38%),
linear-gradient(152deg, #120d35 0%, #47205f 46%, #10205a 70%, #041321 100%);
background-blend-mode: screen, screen, screen, screen, screen, normal;
radial-gradient(circle at 72% 16%, rgb(165 151 218 / 24%), transparent 42%),
linear-gradient(150deg, #655b86 0%, #42516f 52%, #293545 100%);
}
.wallpaper--custom {
background-color: #090a0d;
@@ -4476,8 +4178,8 @@ button {
display: flex;
align-items: center;
flex-direction: column;
min-height: 218px;
padding: 1px 0 16px;
min-height: 206px;
padding: 1px 0 10px;
text-align: center;
}
.weather-location {
@@ -4529,7 +4231,7 @@ button {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--sky-space-2);
margin-bottom: var(--sky-space-4);
margin-bottom: var(--sky-space-3);
}
.weather-detail-card,
.weather-panel {
@@ -4540,18 +4242,21 @@ button {
0 8px 22px rgba(5, 16, 32, 0.14);
color: #fff;
}
.weather-detail-card {
.weather-details > .weather-detail-card {
display: grid;
grid-template-columns: 21px 1fr;
gap: 4px 7px;
min-height: 94px;
grid-template-rows: auto auto;
align-content: center;
gap: 7px;
min-height: 76px;
margin: 0;
padding: 14px;
padding: 11px 12px;
border-radius: calc(var(--sky-radius-card) - 6px);
}
.weather-detail-card svg {
grid-row: span 2;
margin-top: 2px;
align-self: center;
margin-top: 0;
color: #b7d9ec;
}
.weather-detail-card:nth-child(1) svg {
@@ -4571,7 +4276,7 @@ button {
text-transform: uppercase;
}
.weather-detail-card strong {
align-self: end;
align-self: auto;
font-size: 18px;
font-weight: 680;
line-height: 1.1;
@@ -4579,7 +4284,7 @@ button {
.weather-detail-card:nth-child(4) strong {
color: var(--weather-accent-cyan);
}
.weather-panel {
.weather-scroll > .weather-panel {
margin: 0;
overflow: hidden;
border-radius: var(--sky-radius-card);
@@ -4613,10 +4318,11 @@ button {
min-height: 108px;
padding: 7px 2px 4px;
border-left: 1px solid rgba(255, 255, 255, 0.06);
border-radius: var(--sky-radius-control);
border-radius: 0;
}
.weather-hour:first-child {
border-left: 0;
border-radius: var(--sky-radius-control);
background: rgba(255, 255, 255, 0.07);
}
.weather-hour span {
@@ -5475,6 +5181,13 @@ button {
color: #111;
text-shadow: 0 1px 3px #fff8;
}
.phone-app--setup .phone-status-bar {
color: #1d1d1f;
text-shadow: none;
}
.phone-app.dark.phone-app--setup .phone-status-bar {
color: #f5f5f7;
}
* {
scrollbar-width: none;
}
@@ -6902,9 +6615,47 @@ 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;
grid-column: 1 / -1;
flex: 0 0 auto;
border: 0;
background: transparent;
box-shadow: none;
color: var(--ios-blue);
font-size: 12px;
font-weight: 650;
@@ -7226,6 +6977,12 @@ 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/main.lua', import.meta.url),
new URL('../../sky_phone/source/client/nui_server_bridge.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).toContain(`${quote}companies:dial-service-line${quote}`)
expect(client).toMatch(/companies\s*=\s*\[\[[^\]]*dial-service-line/)
})
it('accepts only a target number and derives the company from the live server member', () => {
@@ -0,0 +1,28 @@
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>')
})
})
+114 -13
View File
@@ -10,6 +10,8 @@ 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 {
@@ -33,13 +35,18 @@ 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
@@ -48,6 +55,8 @@ 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()
@@ -128,19 +137,28 @@ const frameReady = computed(
frameLoaded.value &&
(props.app.bridgeMode === 'legacy' || skyBridgeReady.value),
)
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 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 lbSettings = computed(() =>
createLbPhoneHostSettings({
deviceName: phone.device?.name ?? '',
@@ -191,6 +209,16 @@ 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,
@@ -201,6 +229,7 @@ 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,
@@ -282,6 +311,55 @@ 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) {
@@ -308,6 +386,29 @@ 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)
@@ -11,13 +11,18 @@ describe('PhoneSetupAssistant contract', () => {
it('uses first-party controls and exposes the complete setup journey', () => {
expect(source).not.toContain("from 'konsta/vue'")
expect(source).toContain('PhonePasscode')
expect(source).toContain("step === 1")
expect(source).toContain("step === 8")
expect(source).toContain('step === 1')
expect(source).toContain('step === 8')
expect(source).toContain("['performance', 'ultimate']")
expect(source).toContain('setup-mode-preview__card--front')
expect(source).toContain('setup-mode-preview__card--back')
expect(source).toContain('WALLPAPER_IDS')
expect(source).toContain('setAllAppNotifications')
expect(source).toContain('appStore.claimApp')
expect(source).toContain('await phone.completeSetup()')
expect(source).toMatch(
/if \(step\.value === 8\) \{[\s\S]*void finish\(\)[\s\S]*return/,
)
expect(source).toContain(':disabled="setupCompleteBusy"')
expect(source).toContain("phone.t('Setup.ready.saveFailed')")
})
@@ -27,4 +32,30 @@ describe('PhoneSetupAssistant contract', () => {
expect(source).toContain('phone.setSetupStep(step.value)')
expect(source).toContain('@click="moveTo(step - 1)"')
})
it('keeps the development skip control away from setup progress', () => {
expect(source).toContain('v-if="showDevelopmentSkip && step === 0"')
})
it('follows the selected system appearance throughout setup', () => {
expect(source).toContain(
'(appearanceSelected || step > 4) && phone.isDarkMode',
)
expect(source).toContain("emit('appearanceSelected', true)")
expect(source).toContain("phone.setPreference('appearanceMode', 'light')")
expect(source).toContain('.setup-assistant--dark.setup-assistant--step-0')
expect(source).toContain('--setup-background: #000000')
expect(source).toContain('background: var(--setup-background)')
})
it('applies the selected graphics mode to the setup experience immediately', () => {
expect(source).toContain("'setup-assistant--performance':")
expect(source).toContain("'setup-assistant--ultimate':")
expect(source).toContain(
'.setup-assistant--performance .setup-forward-enter-active',
)
expect(source).toContain(
'.setup-assistant--ultimate .setup-mode-stack button',
)
})
})
+505 -27
View File
@@ -9,6 +9,7 @@ import {
Palette,
ShieldCheck,
Signal,
Smartphone,
Sparkles,
Wifi,
} from 'lucide-vue-next'
@@ -30,7 +31,11 @@ import {
type WallpaperId,
} from '@/utils/preferences'
const emit = defineEmits<{ complete: []; skip: [] }>()
const emit = defineEmits<{
appearanceSelected: [selected: boolean]
complete: []
skip: []
}>()
const phone = usePhoneStore()
const account = useAccountStore()
@@ -38,6 +43,10 @@ const appStore = useAppStoreStore()
const step = ref(
Math.min(PHONE_SETUP_LAST_STEP, phone.preferences.settings.setupStep),
)
const appearanceSelected = ref(step.value > 4)
if (step.value === 4) {
phone.setPreference('appearanceMode', 'light')
}
const direction = ref<'back' | 'forward'>('forward')
const accountMode = ref<'login' | 'register'>('login')
const email = ref('')
@@ -93,6 +102,13 @@ const showDevelopmentSkip = import.meta.env.DEV
function moveTo(nextStep: number): void {
direction.value = nextStep < step.value ? 'back' : 'forward'
step.value = Math.min(PHONE_SETUP_LAST_STEP, Math.max(0, nextStep))
if (step.value < 4) {
appearanceSelected.value = false
emit('appearanceSelected', false)
}
if (step.value === 4 && !appearanceSelected.value) {
phone.setPreference('appearanceMode', 'light')
}
phone.setSetupStep(step.value)
}
@@ -105,12 +121,16 @@ function continueSetup(): void {
}
if (step.value === 8) {
for (const appId of selectedApps.value) appStore.claimApp(appId)
void finish()
return
}
moveTo(step.value + 1)
}
function chooseAppearance(mode: AppearanceMode): void {
appearanceSelected.value = true
phone.setPreference('appearanceMode', mode)
emit('appearanceSelected', true)
}
function choosePerformance(mode: GraphicsMode): void {
@@ -227,13 +247,22 @@ function skipSetupForDevelopment(): void {
<template>
<section
class="setup-assistant"
:class="`setup-assistant--step-${step}`"
:class="[
`setup-assistant--step-${step}`,
{
'setup-assistant--dark':
(appearanceSelected || step > 4) && phone.isDarkMode,
'setup-assistant--performance':
phone.preferences.settings.graphicsMode === 'performance',
'setup-assistant--ultimate':
phone.preferences.settings.graphicsMode === 'ultimate',
},
]"
:style="currentWallpaperStyle"
:aria-label="phone.t('Setup.title')"
>
<div class="setup-assistant__aurora" aria-hidden="true"></div>
<button
v-if="showDevelopmentSkip"
v-if="showDevelopmentSkip && step === 0"
type="button"
class="setup-assistant__development-skip"
@click="skipSetupForDevelopment"
@@ -262,15 +291,8 @@ function skipSetupForDevelopment(): void {
<main :key="step" class="setup-assistant__page">
<template v-if="step === 0">
<div class="setup-welcome__hero" aria-hidden="true">
<div class="setup-welcome__greetings">
<span>{{ phone.t('Setup.welcome.hello') }}</span>
<span>{{ phone.t('Setup.welcome.hallo') }}</span>
<span>{{ phone.t('Setup.welcome.bonjour') }}</span>
</div>
<div class="setup-welcome__signature">
<span></span>
<span></span>
<span></span>
<div class="setup-welcome__device">
<Smartphone :size="43" :stroke-width="1.55" />
</div>
</div>
<div class="setup-welcome__copy">
@@ -283,19 +305,6 @@ function skipSetupForDevelopment(): void {
</p>
</div>
<footer class="setup-welcome__footer">
<div class="setup-welcome__privacy">
<span
><ShieldCheck :size="14" />{{
phone.t('Setup.welcome.private')
}}</span
>
<i aria-hidden="true"></i>
<span
><Sparkles :size="14" />{{
phone.t('Setup.welcome.personal')
}}</span
>
</div>
<SkyButton class="setup-assistant__primary" @click="continueSetup">
{{ phone.t('Setup.getStarted') }}
</SkyButton>
@@ -632,7 +641,20 @@ function skipSetupForDevelopment(): void {
<span
class="setup-mode-stack__orb"
:class="`setup-mode-stack__orb--${mode}`"
></span>
aria-hidden="true"
>
<i class="setup-mode-preview__backdrop"></i>
<i
class="setup-mode-preview__card setup-mode-preview__card--back"
></i>
<i
class="setup-mode-preview__card setup-mode-preview__card--front"
></i>
<i
class="setup-mode-preview__line setup-mode-preview__line--wide"
></i>
<i class="setup-mode-preview__line"></i>
</span>
<span
><strong>{{ phone.t(`Apps.settings.${mode}Mode`) }}</strong
><small>{{ phone.t(`Setup.performance.${mode}`) }}</small></span
@@ -2126,6 +2148,462 @@ function skipSetupForDevelopment(): void {
opacity: 0;
transform: translateX(16px);
}
/* Minimal system setup language, matching the restrained iOS setup hierarchy. */
.setup-assistant,
.setup-assistant--step-0 {
--setup-background: #ffffff;
--setup-text: #1d1d1f;
--setup-secondary: #6e6e73;
--setup-tertiary: #8e8e93;
--setup-muted: #aeaeb2;
--setup-surface: #f2f2f7;
--setup-selected-surface: #eef6ff;
--setup-control: #e9e9eb;
--setup-control-fill: #ffffff;
--setup-separator: #d1d1d6;
--setup-blue: #007aff;
--setup-green: #34c759;
--setup-red: #ff3b30;
--setup-orb: #dcecff;
--setup-orb-ultimate: #e7e2ff;
--setup-home-indicator: #1d1d1f;
color: var(--setup-text);
background: var(--setup-background);
}
.setup-assistant--dark,
.setup-assistant--dark.setup-assistant--step-0 {
--setup-background: #000000;
--setup-text: #f5f5f7;
--setup-secondary: #98989d;
--setup-tertiary: #8e8e93;
--setup-muted: #636366;
--setup-surface: #1c1c1e;
--setup-selected-surface: #102a43;
--setup-control: #2c2c2e;
--setup-control-fill: #636366;
--setup-separator: #38383a;
--setup-blue: #0a84ff;
--setup-green: #30d158;
--setup-red: #ff453a;
--setup-orb: #102a43;
--setup-orb-ultimate: #28203d;
--setup-home-indicator: #ffffff;
}
.setup-assistant__development-skip {
top: 48px;
right: 13px;
border: 0;
color: var(--setup-blue);
background: transparent;
font-size: 11px;
font-weight: 600;
}
.setup-assistant__chrome {
gap: 10px;
}
.setup-assistant__back {
border: 0;
color: var(--setup-blue);
background: transparent;
backdrop-filter: none;
}
.setup-assistant__progress {
height: 3px;
background: var(--setup-separator);
}
.setup-assistant__progress span {
background: var(--setup-blue);
}
.setup-assistant__counter {
color: var(--setup-tertiary);
font-weight: 600;
}
.setup-assistant__page h1 {
margin: 12px 0 10px;
color: var(--setup-text);
font-size: 31px;
font-weight: 700;
line-height: 1.08;
letter-spacing: -0.04em;
}
.setup-assistant__lead {
color: var(--setup-secondary);
font-size: 14px;
line-height: 1.42;
}
.setup-assistant__eyebrow,
.setup-welcome__eyebrow {
display: none;
}
.setup-assistant__icon {
width: 72px;
height: 72px;
border: 0;
border-radius: 50%;
color: var(--setup-blue);
background: var(--setup-surface);
box-shadow: none;
backdrop-filter: none;
}
.setup-assistant__icon--signal,
.setup-assistant__icon--cloud,
.setup-assistant__icon--security,
.setup-assistant__icon--appearance,
.setup-assistant__icon--performance,
.setup-assistant__icon--wallpaper,
.setup-assistant__icon--notifications,
.setup-assistant__icon--apps {
color: var(--setup-blue);
}
.setup-assistant__primary {
min-height: 50px;
border-radius: 14px !important;
background: var(--setup-blue) !important;
box-shadow: none !important;
font-weight: 650;
}
.setup-assistant__later {
color: var(--setup-blue);
font-weight: 500;
}
.setup-assistant__notice {
padding: 5px 4px;
border: 0;
color: var(--setup-blue);
background: transparent;
}
.setup-assistant__notice p {
color: var(--setup-secondary);
}
.setup-assistant__error {
color: var(--setup-red);
}
.setup-assistant--step-0 .setup-assistant__page {
padding: 105px 28px 0;
}
.setup-welcome__hero {
width: 94px;
height: 94px;
border-radius: 50%;
background: var(--setup-surface);
}
.setup-welcome__hero::before,
.setup-welcome__hero::after {
content: none;
}
.setup-welcome__device {
display: grid;
width: 94px;
height: 94px;
color: var(--setup-blue);
place-items: center;
}
.setup-welcome__copy {
margin-top: 31px;
}
.setup-welcome__copy h1 {
margin-top: 0;
font-size: 32px;
}
.setup-welcome__footer {
width: calc(100% + 56px);
margin-right: -28px;
margin-left: -28px;
padding: 0 28px 13px;
border: 0;
background: var(--setup-background);
backdrop-filter: none;
}
.setup-welcome__footer .setup-assistant__primary {
margin-top: 0;
box-shadow: none;
}
.setup-welcome__home-indicator {
background: var(--setup-home-indicator);
}
.setup-connectivity-card {
min-height: 142px;
border: 0;
border-radius: 22px;
color: var(--setup-text);
background: var(--setup-surface);
box-shadow: none;
}
.setup-connectivity-card__waves {
display: none;
}
.setup-connectivity-card__label,
.setup-connectivity-card svg {
color: var(--setup-blue);
}
.setup-connectivity-card > span:last-of-type {
color: var(--setup-tertiary);
}
.setup-cloud-hero {
width: 72px;
height: 72px;
border-radius: 50%;
color: var(--setup-blue);
background: var(--setup-surface);
}
.setup-cloud-hero__orbit,
.setup-cloud-hero__glow,
.setup-cloud-hero i {
display: none;
}
.setup-cloud-hero svg {
filter: none;
}
.setup-cloud-identity {
border: 0;
background: var(--setup-surface);
}
.setup-cloud-identity > span {
border: 0;
background: var(--setup-blue);
box-shadow: none;
}
.setup-cloud-identity small,
.setup-cloud-strength,
.setup-cloud-security-note {
color: var(--setup-tertiary);
}
.setup-cloud-identity strong,
.setup-cloud-connected strong {
color: var(--setup-text);
}
.setup-cloud-identity em,
.setup-cloud-suffix {
color: var(--setup-blue);
}
.setup-cloud-fields {
border: 0;
background: var(--setup-surface);
}
.setup-cloud-fields :deep(.sky-field + .sky-field) {
border-top-color: var(--setup-separator);
}
.setup-cloud-fields :deep(.sky-field__label) {
color: var(--setup-tertiary);
}
.setup-cloud-fields :deep(.sky-field__input) {
color: var(--setup-text);
caret-color: var(--setup-blue);
}
.setup-cloud-fields :deep(.sky-field__input::placeholder) {
color: var(--setup-muted);
}
.setup-cloud-strength span {
background: var(--setup-separator);
}
.setup-cloud-strength span.active {
background: var(--setup-blue);
box-shadow: none;
}
.setup-assistant__selector {
background: var(--setup-control);
}
.setup-assistant__selector-indicator {
border: 0;
background: var(--setup-control-fill);
box-shadow: 0 1px 4px rgb(0 0 0 / 14%);
}
.setup-assistant__selector button {
color: var(--setup-secondary);
}
.setup-assistant__selector button.active {
color: var(--setup-text);
}
.setup-cloud-connected {
color: var(--setup-green);
}
.setup-cloud-connected span {
color: var(--setup-secondary);
}
.setup-security-visual {
border: 0;
background: var(--setup-surface);
}
.setup-security-visual span {
background: var(--setup-text);
box-shadow: none;
}
.setup-security-visual svg {
color: var(--setup-muted);
}
.setup-passcode-length button,
.setup-choice-grid button,
.setup-mode-stack button,
.setup-toggle-card,
.setup-app-list button,
.setup-ready__summary span {
border-color: transparent;
color: var(--setup-text);
background: var(--setup-surface);
box-shadow: none;
}
.setup-passcode-length button.selected,
.setup-choice-grid button.selected,
.setup-mode-stack button.selected,
.setup-app-list button.selected {
border-color: var(--setup-blue);
background: var(--setup-selected-surface);
box-shadow: none;
}
.setup-passcode-length small,
.setup-mode-stack small,
.setup-toggle-card small,
.setup-app-list small {
color: var(--setup-secondary);
}
.setup-mode-stack__orb {
position: relative;
overflow: hidden;
isolation: isolate;
border-radius: 18px;
background: var(--setup-orb);
box-shadow: none;
}
.setup-mode-stack__orb--ultimate {
background: var(--setup-orb-ultimate);
box-shadow: none;
}
.setup-mode-preview__backdrop,
.setup-mode-preview__card,
.setup-mode-preview__line {
position: absolute;
display: block;
pointer-events: none;
}
.setup-mode-preview__backdrop {
inset: 0;
background: linear-gradient(145deg, #d9eaff 0%, #b9d8ff 100%);
}
.setup-mode-preview__card {
width: 37px;
height: 26px;
border-radius: 8px;
}
.setup-mode-preview__card--back {
top: 11px;
left: 9px;
background: #ffffff;
}
.setup-mode-preview__card--front {
right: 8px;
bottom: 10px;
background: #e7f1ff;
}
.setup-mode-preview__line {
z-index: 2;
right: 15px;
bottom: 17px;
width: 17px;
height: 3px;
border-radius: 999px;
background: #5d80ad;
}
.setup-mode-preview__line--wide {
bottom: 23px;
width: 24px;
}
.setup-mode-stack__orb--ultimate .setup-mode-preview__backdrop {
background:
radial-gradient(circle at 20% 25%, #f4c7ff 0 12%, transparent 35%),
linear-gradient(145deg, #776de4 0%, #342760 100%);
}
.setup-mode-stack__orb--ultimate .setup-mode-preview__card {
border: 1px solid rgb(255 255 255 / 38%);
background: rgb(255 255 255 / 25%);
box-shadow: 0 7px 14px rgb(31 18 70 / 24%);
backdrop-filter: blur(5px);
}
.setup-mode-stack__orb--ultimate .setup-mode-preview__card--back {
transform: rotate(-8deg);
}
.setup-mode-stack__orb--ultimate .setup-mode-preview__card--front {
background: rgb(255 255 255 / 34%);
transform: rotate(5deg);
}
.setup-mode-stack__orb--ultimate .setup-mode-preview__line {
background: rgb(255 255 255 / 72%);
}
.setup-assistant--dark
.setup-mode-stack__orb--performance
.setup-mode-preview__backdrop {
background: linear-gradient(145deg, #203147 0%, #142133 100%);
}
.setup-assistant--dark
.setup-mode-stack__orb--performance
.setup-mode-preview__card--back {
background: #334965;
}
.setup-assistant--dark
.setup-mode-stack__orb--performance
.setup-mode-preview__card--front {
background: #29405d;
}
.setup-assistant--dark
.setup-mode-stack__orb--performance
.setup-mode-preview__line {
background: #a9c7e9;
}
.setup-mode-stack__check,
.setup-app-list button > i {
border-color: var(--setup-muted);
}
.selected .setup-mode-stack__check,
.setup-app-list button.selected > i {
border-color: var(--setup-blue);
background: var(--setup-blue);
}
.setup-wallpapers button.selected {
border-color: var(--setup-background);
box-shadow: 0 0 0 2px var(--setup-blue);
}
.setup-toggle-card {
border: 0;
}
.setup-toggle-card button {
color: var(--setup-text);
}
.setup-toggle-card button + button {
border-top-color: var(--setup-separator);
}
.setup-toggle-card i {
background: var(--setup-separator);
}
.setup-ready__halo {
width: 112px;
height: 112px;
border: 0;
color: var(--setup-blue);
background: var(--setup-surface);
box-shadow: none;
}
.setup-ready__summary span {
color: var(--setup-blue);
}
.setup-ready__summary b {
color: var(--setup-text);
}
.setup-assistant--performance .setup-forward-enter-active,
.setup-assistant--performance .setup-forward-leave-active,
.setup-assistant--performance .setup-back-enter-active,
.setup-assistant--performance .setup-back-leave-active,
.setup-assistant--performance .setup-assistant__progress span,
.setup-assistant--performance .setup-mode-stack button,
.setup-assistant--performance .setup-mode-stack__orb {
transition-duration: 0.01ms;
}
.setup-assistant--ultimate .setup-mode-stack button,
.setup-assistant--ultimate .setup-mode-stack__orb {
transition:
border-color 0.2s ease,
background-color 0.2s ease,
transform 0.2s cubic-bezier(0.2, 0.8, 0.2, 1);
}
@media (prefers-reduced-motion: reduce) {
.setup-assistant,
.setup-assistant *,
@@ -34,6 +34,15 @@ 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',
+36 -3
View File
@@ -1076,6 +1076,39 @@ 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;
@@ -1168,12 +1201,12 @@ onBeforeUnmount(() => {
.widget-music-empty {
position: absolute;
right: 15px;
bottom: 11px;
bottom: 14px;
left: 15px;
z-index: 0;
z-index: 2;
margin: 0;
color: rgb(255 255 255 / 72%);
font-size: 13px;
font-size: 14px;
font-weight: 650;
line-height: 17px;
text-align: center;
@@ -0,0 +1,62 @@
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)
})
})
+158 -2
View File
@@ -4,6 +4,8 @@ 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'],
@@ -52,11 +54,165 @@ 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(phoneClient).toContain(
expect(phoneBridge).toContain(
'SkyPhoneCompatibility.RegisterExportAlias("lb-phone", "IsOpen"',
)
expect(phoneClient).toContain('return is_open')
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"')
})
it('opens from a configurable F1 mapping without client-provided device identity', () => {
@@ -0,0 +1,32 @@
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')
})
})
+12
View File
@@ -494,6 +494,18 @@ 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
},
+3 -3
View File
@@ -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: {
+2 -1
View File
@@ -3,6 +3,7 @@ export type HousingAccess = 'owner' | 'keyholder'
export type HousingCapabilities = {
cctv: boolean
garageStatus: boolean
keyGrant?: boolean
keys: boolean
lock: boolean
waypoint: boolean
@@ -24,7 +25,7 @@ export type HousingProperty = {
access: HousingAccess
capabilities: HousingCapabilities
cctv: { enabled: boolean }
entrance: { x: number; y: number; z: number }
entrance?: { x: number; y: number; z: number }
garage: { enabled: boolean; storedVehicles: number } | null
id: string
keys?: HousingKey[]
@@ -169,6 +169,9 @@ describe('Sky UI Konsta 5.3 parity contracts', () => {
expect(html).toContain('id="directory-search"')
expect(html).toContain('sky-glass')
expect(html).not.toContain('sky-glass--highlight')
expect(html).toContain('<svg class="sky-searchbar__icon"')
expect(html).toContain('fill-rule="evenodd"')
expect(html).not.toContain('<circle')
expect(html).toContain('sky-searchbar__clear')
expect(html).toContain('aria-label="Clear query"')
expect(html).toContain('sky-searchbar__disable')
+37 -19
View File
@@ -111,6 +111,18 @@
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);
}
@@ -1465,26 +1477,13 @@ label.sky-list-item__row {
}
.sky-searchbar__icon {
width: 14px;
height: 14px;
position: relative;
width: 18px;
height: 18px;
display: block;
flex: none;
border: 1.6px solid var(--sky-muted, rgba(0, 0, 0, 0.55));
border-radius: 50%;
opacity: 0.8;
}
.sky-searchbar__icon::after {
width: 6px;
height: 1.6px;
position: absolute;
right: -5px;
bottom: -2px;
content: '';
border-radius: 2px;
background: var(--sky-muted, rgba(0, 0, 0, 0.55));
transform: rotate(45deg);
transform-origin: left center;
color: var(--sky-muted, rgba(0, 0, 0, 0.55));
fill: currentColor;
opacity: 0.72;
}
.sky-searchbar__input {
@@ -2746,6 +2745,9 @@ 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;
}
@@ -2754,6 +2756,16 @@ 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 {
@@ -2768,6 +2780,10 @@ 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;
@@ -2838,6 +2854,8 @@ 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,6 +28,30 @@ 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')
+36 -15
View File
@@ -1,6 +1,8 @@
<script setup lang="ts">
import { computed } from 'vue'
import SkyGlass from './SkyGlass.vue'
defineOptions({ inheritAttrs: false })
const props = withDefaults(
@@ -9,6 +11,7 @@ const props = withDefaults(
clear?: boolean
component?: 'a' | 'button'
disabled?: boolean
glass?: boolean
href?: string
iconOnly?: boolean
inline?: boolean
@@ -26,6 +29,7 @@ const props = withDefaults(
clear: false,
component: 'button',
disabled: false,
glass: false,
href: undefined,
iconOnly: false,
inline: false,
@@ -44,6 +48,23 @@ 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 {
@@ -71,25 +92,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="[
`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,
},
]"
:class="buttonClasses"
@click="handleClick"
>
<slot />
+29
View File
@@ -73,4 +73,33 @@ 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,
)
})
})
+2 -1
View File
@@ -14,7 +14,7 @@ const props = withDefaults(
text?: string
textPosition?: 'after' | 'before'
type?: 'button' | 'reset' | 'submit'
variant?: 'neutral' | 'primary'
variant?: 'glass' | 'neutral' | 'primary'
}>(),
{
ariaLabel: '',
@@ -72,6 +72,7 @@ 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,
+7 -1
View File
@@ -141,7 +141,13 @@ function disable(event: MouseEvent): void {
<label v-if="label" class="sky-visually-hidden" :for="resolvedInputId">
{{ label }}
</label>
<span class="sky-searchbar__icon" aria-hidden="true" />
<svg class="sky-searchbar__icon" aria-hidden="true" viewBox="0 0 24 24">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M9.5 3a6.5 6.5 0 1 0 3.98 11.64l4.44 4.44a1 1 0 0 0 1.42-1.42l-4.44-4.44A6.5 6.5 0 0 0 9.5 3Zm0 2a4.5 4.5 0 1 1 0 9 4.5 4.5 0 0 1 0-9Z"
/>
</svg>
<input
:id="resolvedInputId"
ref="input"
+6 -1
View File
@@ -10,6 +10,10 @@ 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(
@@ -56,10 +60,11 @@ 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(
`(?:["']${appId}["']\\]?|${appId.replace(/-/g, '\\-')})\\s*[:=]\\s*\\{\\s*first\\s*[:=]`,
`(?:["']${escapedAppId}["']\\]?|${escapedAppId})\\s*[:=]\\s*\\{\\s*first\\s*[:=]`,
),
)
}
+49 -4
View File
@@ -5,7 +5,10 @@ import {
createLbPhoneFrameDocument,
createLbPhoneHostSettings,
getLbPhoneCallbackResource,
getLbPhoneStorageKey,
readLbPhoneStorage,
usesLbPhoneHostRuntime,
writeLbPhoneStorage,
} from '@/utils/lbPhoneAppBridge'
import { DEFAULT_PHONE_PREFERENCES } from '@/utils/preferences'
@@ -90,9 +93,10 @@ describe('LB Phone app bridge', () => {
it('injects the LB runtime and asset base before the vendor bundle', () => {
const html =
'<!doctype html><html><head><script type="module" src="/ui/dist/assets/index.js"></script></head><body></body></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>'
const document = createLbPhoneFrameDocument(html, {
appName: 'snake-game',
localStorage: { theme: 'dark' },
resourceName: 'snake_app',
settings: createLbPhoneHostSettings({
deviceName: '</script><script>window.injected=true</script>',
@@ -109,11 +113,52 @@ 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 runtime = /<script>([\s\S]*?)<\/script>/.exec(document)?.[1]
expect(runtime).toBeTruthy()
expect(() => new Function(runtime ?? '')).not.toThrow()
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,
)
})
})
+140
View File
@@ -4,6 +4,15 @@ 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
@@ -41,6 +50,7 @@ export type LbPhoneHostSettings = {
type LbPhoneFrameDocumentOptions = {
appName: string
localStorage: LbPhoneStorageSnapshot
resourceName: string
settings: LbPhoneHostSettings
ui: string
@@ -60,6 +70,59 @@ 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';
@@ -70,7 +133,22 @@ 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');
@@ -150,6 +228,67 @@ 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 {
@@ -228,6 +367,7 @@ 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,
})
+8
View File
@@ -47,4 +47,12 @@ describe('notes rich text', () => {
'Briefing\nMeet outside.\n• Radio\n• Vest',
)
})
it('keeps encoded markup as literal preview text', () => {
const body = serializeRichNoteBody(
'<p>&lt;script&gt;literal&lt;/script&gt;</p>',
)
expect(noteBodyToPlainText(body)).toBe('<script>literal</script>')
})
})
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest'
import { isTextInputElement } from '@/utils/textInputFocus'
function element(
tagName: string,
attributes: Record<string, string> = {},
isContentEditable = false,
) {
return {
getAttribute(name: string) {
return attributes[name] ?? null
},
isContentEditable,
tagName,
}
}
describe('text input focus', () => {
it('recognizes fields that accept typed text', () => {
expect(isTextInputElement(element('INPUT'))).toBe(true)
expect(isTextInputElement(element('INPUT', { type: 'number' }))).toBe(true)
expect(isTextInputElement(element('TEXTAREA'))).toBe(true)
expect(isTextInputElement(element('DIV', {}, true))).toBe(true)
expect(isTextInputElement(element('DIV', { role: 'textbox' }))).toBe(true)
})
it('ignores non-text and read-only controls', () => {
expect(isTextInputElement(element('INPUT', { type: 'checkbox' }))).toBe(
false,
)
expect(isTextInputElement(element('INPUT', { type: 'range' }))).toBe(false)
expect(isTextInputElement(element('INPUT', { readonly: '' }))).toBe(false)
expect(isTextInputElement(element('BUTTON'))).toBe(false)
})
})
+34
View File
@@ -0,0 +1,34 @@
const nonTextInputTypes = new Set([
'button',
'checkbox',
'color',
'file',
'hidden',
'image',
'radio',
'range',
'reset',
'submit',
])
type FocusableElement = Pick<
HTMLElement,
'getAttribute' | 'isContentEditable' | 'tagName'
>
export function isTextInputElement(element: FocusableElement): boolean {
if (
element.getAttribute('readonly') !== null ||
element.getAttribute('aria-readonly') === 'true'
) {
return false
}
if (element.isContentEditable || element.getAttribute('role') === 'textbox') {
return true
}
if (element.tagName === 'TEXTAREA') return true
if (element.tagName !== 'INPUT') return false
const inputType = element.getAttribute('type')?.toLowerCase() ?? 'text'
return !nonTextInputTypes.has(inputType)
}
@@ -19,7 +19,17 @@ const mainCss = readFileSync(
new URL('../assets/main.css', import.meta.url),
'utf8',
)
const builtInWallpaperCss = mainCss.slice(
mainCss.indexOf('.wallpaper--midnight'),
mainCss.indexOf('.wallpaper--custom'),
)
describe('Springboard page swipe contract', () => {
it('keeps the built-in wallpapers visually restrained', () => {
expect(builtInWallpaperCss).not.toMatch(/(?:conic|repeating-\w+)-gradient/)
expect(builtInWallpaperCss.match(/radial-gradient/g)).toHaveLength(12)
expect(builtInWallpaperCss.match(/linear-gradient/g)).toHaveLength(12)
})
it('replaces the home status row with the edit controls', () => {
expect(viewSource).toContain("emit('editModeChange', editing)")
expect(viewSource).toContain("emit('editModeChange', false)")
@@ -48,13 +48,11 @@ 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')
@@ -71,6 +69,16 @@ 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')
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,
)
})
})
+37 -27
View File
@@ -1,15 +1,11 @@
<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'
@@ -126,20 +122,28 @@ function updateActivePreview(): void {
<template>
<section class="store-detail" :style="detailStyle">
<header class="store-detail__toolbar">
<button
<SkyButton
glass
icon-only
rounded
class="store-detail__toolbar-button"
type="button"
:aria-label="phone.t('Common.back')"
@click="emit('back')"
>
<ChevronLeft :size="26" :stroke-width="2.2" aria-hidden="true" />
</button>
<button
</SkyButton>
<SkyButton
glass
icon-only
rounded
class="store-detail__toolbar-button"
type="button"
:aria-label="phone.t('Apps.appStore.details.share')"
@click="emit('share')"
>
<Share2 :size="21" :stroke-width="2" aria-hidden="true" />
</button>
</SkyButton>
</header>
<section class="store-detail__hero">
@@ -212,22 +216,30 @@ function updateActivePreview(): void {
<h2>{{ phone.t('Apps.appStore.details.preview') }}</h2>
<div class="store-detail__preview-navigation">
<span>{{ activePreviewIndex + 1 }} / {{ previewCount }}</span>
<button
<SkyButton
glass
icon-only
rounded
class="store-detail__preview-control"
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" />
</button>
<button
</SkyButton>
<SkyButton
glass
icon-only
rounded
class="store-detail__preview-control"
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" />
</button>
</SkyButton>
</div>
</header>
<div
@@ -286,7 +298,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;
@@ -294,7 +306,6 @@ 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,
@@ -302,7 +313,7 @@ function updateActivePreview(): void {
transform 100ms ease;
}
.store-detail__toolbar button:active {
.store-detail__toolbar-button:active {
transform: scale(0.94);
}
@@ -481,27 +492,28 @@ function updateActivePreview(): void {
text-align: center;
}
.store-detail__preview-navigation button {
width: 30px;
height: 30px;
.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);
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-navigation button:disabled {
.store-detail__preview-control:disabled {
opacity: 0.34;
}
.store-detail__preview-navigation button:active:not(:disabled) {
.store-detail__preview-control:active:not(:disabled) {
transform: scale(0.92);
}
@@ -520,16 +532,14 @@ 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-navigation button:hover:not(:disabled) {
.store-detail__preview-control:hover:not(:disabled) {
color: var(--sky-app-accent);
background: var(--sky-surface-tint);
transform: translateY(-1px);
}
}
@@ -22,7 +22,7 @@ describe('phone app theme contract', () => {
it('provides the reactive phone theme to every routed app', () => {
expect(appShellSource).toContain('import { SkyProvider }')
expect(appShellSource).toContain('class="phone-app-theme"')
expect(appShellSource).toContain(':dark="phone.isDarkMode"')
expect(appShellSource).toContain(':dark="displayedDarkMode"')
expect(appShellSource).toMatch(
/<SkyProvider[\s\S]*?<RouterView[\s\S]*?<component/,
)
@@ -15,7 +15,7 @@ const appSource = readFileSync(
'utf8',
)
const clientSource = readFileSync(
new URL('../../../../sky_phone/source/client/main.lua', import.meta.url),
new URL('../../../../sky_phone/source/client/nui_events.lua', import.meta.url),
'utf8',
)
const serverSource = readFileSync(
@@ -69,7 +69,10 @@ describe('Banking app Sky UI migration', () => {
expect(serverSource).toContain('kind = "transfer_in"')
expect(serverSource).toContain('currency = Config.Banking.Currency')
expect(clientSource).toContain(
'SendNUIMessage({ type = "banking:changed", data = data })',
'["sky_phone:banking:changed"] = "banking:changed"',
)
expect(clientSource).toContain(
'SendNUIMessage({ type = nui_type, data = data })',
)
expect(appSource).toContain("data?.kind === 'transfer_in'")
expect(appSource).toContain("appId: 'banking'")
@@ -52,7 +52,8 @@ describe('CalculatorApp layout contract', () => {
)
expect(source).toContain('calculator-history__nav-button--edit')
expect(source).toContain('calculator-history__nav-button--close')
expect(source).toContain('background: rgb(255 255 255 / 9%);')
expect(source.match(/<SkyButton\s+glass/g)).toHaveLength(2)
expect(source).toContain('--sky-glass: rgb(44 44 46 / 62%);')
expect(source).toContain('-webkit-backdrop-filter: none;')
expect(source).toContain('backdrop-filter: none;')
expect(source).toContain('min-width: 106px;')
@@ -62,7 +63,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\)[^}]*background: rgb\(255 255 255 \/ 15%\);[^}]*transform: none;[^}]*filter: none;/s,
/\.calculator-history__nav-button:hover:not\(:disabled\)[^}]*transform: none;[^}]*filter: brightness\(1\.08\);/s,
)
expect(source).not.toContain('class="calculator-history__edit"')
expect(source).not.toContain('class="calculator-history__close"')
+6 -6
View File
@@ -378,6 +378,7 @@ watch([() => calculator.display, expression], async () => {
>
<template #left>
<SkyButton
glass
class="calculator-history__nav-button calculator-history__nav-button--edit"
inline
rounded
@@ -397,6 +398,7 @@ watch([() => calculator.display, expression], async () => {
</template>
<template #right>
<SkyButton
glass
class="calculator-history__nav-button calculator-history__nav-button--close"
icon-only
rounded
@@ -721,16 +723,15 @@ 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) {
background: rgb(255 255 255 / 14%);
filter: none;
filter: brightness(0.94);
}
.calculator-history__navbar :deep(.calculator-history__nav-button--edit) {
@@ -757,9 +758,8 @@ 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: none;
filter: brightness(1.08);
}
}
@@ -31,6 +31,14 @@ 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"')
+42 -51
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { SkyFab, SkyAppPage } from '@/ui'
import { SkyAppPage, SkyButton, SkyFab, SkyGlass } from '@/ui'
import {
ArrowLeft,
Images,
@@ -421,13 +421,11 @@ 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()
})
@@ -486,22 +484,24 @@ onBeforeUnmount(() => {
<header class="camera-topbar">
<div class="camera-topbar-actions">
<button
<sky-fab
v-if="requestedMessageMedia"
class="camera-picker-back"
component="button"
class="camera-control camera-picker-back"
type="button"
variant="glass"
:aria-label="phone.t('Common.back')"
@click="cancelMediaSelection"
>
<ArrowLeft :size="20" />
</button>
<template #icon><ArrowLeft :size="20" /></template>
</sky-fab>
<sky-fab
v-else
component="button"
type="button"
class="camera-control"
:class="{ 'camera-control--flash-active': flashEnabled }"
variant="neutral"
variant="glass"
: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="neutral"
variant="glass"
:disabled="recording || savingVideo"
:aria-label="
phone.t(
@@ -543,8 +543,10 @@ onBeforeUnmount(() => {
<span v-else-if="pendingCount" class="camera-upload-pill">
{{ phone.t('Apps.camera.uploading', { count: String(pendingCount) }) }}
</span>
<button
<SkyButton
v-else
glass
rounded
class="camera-focus-pill camera-lock-control"
:class="{ 'camera-lock-control--active': cameraLocked }"
type="button"
@@ -561,12 +563,12 @@ onBeforeUnmount(() => {
<LockKeyhole v-if="cameraLocked" :size="12" />
<LockOpen v-else :size="12" />
<kbd>{{ phone.t('Apps.camera.spaceKey') }}</kbd>
</button>
</SkyButton>
<sky-fab
component="button"
type="button"
class="camera-control"
variant="neutral"
variant="glass"
:disabled="recording || savingVideo"
:aria-label="
phone.t(
@@ -588,9 +590,11 @@ onBeforeUnmount(() => {
<div class="camera-zoom-control">
<div class="camera-zoom-row">
<button
<SkyButton
v-for="zoom in zoomLevels"
:key="zoom"
glass
rounded
class="camera-zoom-pill"
:class="{ active: Math.abs(selectedZoom - zoom) < 0.03 }"
type="button"
@@ -599,13 +603,14 @@ onBeforeUnmount(() => {
@click="setZoom(zoom)"
>
{{ zoom }}x
</button>
</SkyButton>
</div>
</div>
<footer class="camera-controls">
<div class="camera-capture-row">
<button
<sky-glass
component="button"
class="camera-latest"
type="button"
:aria-label="phone.t('Apps.camera.openGallery')"
@@ -625,7 +630,7 @@ onBeforeUnmount(() => {
/>
<Video v-else-if="latestMedia" :size="22" />
<Images v-else :size="22" />
</button>
</sky-glass>
<button
class="camera-shutter"
@@ -650,7 +655,7 @@ onBeforeUnmount(() => {
component="button"
type="button"
class="camera-control camera-selfie"
variant="neutral"
variant="glass"
:aria-label="phone.t('Apps.camera.flip')"
@click="toggleFacing"
>
@@ -799,8 +804,9 @@ onBeforeUnmount(() => {
height: 44px;
}
.camera-control {
--sky-glass-solid: rgb(28 28 30 / 80%);
color: rgb(255 255 255 / 86%);
--sky-glass: rgb(28 28 30 / 58%);
--sky-hairline: rgb(255 255 255 / 16%);
color: rgb(255 255 255 / 92%) !important;
}
.camera-control--flash-active {
color: #ffd60a !important;
@@ -811,13 +817,6 @@ 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;
@@ -831,7 +830,7 @@ onBeforeUnmount(() => {
.camera-page--landscape .camera-latest svg {
transform: rotate(90deg);
}
.camera-focus-pill,
.camera-focus-pill:not(.sky-button--glass),
.camera-upload-pill {
min-width: 0;
padding: 7px 10px;
@@ -853,7 +852,7 @@ onBeforeUnmount(() => {
}
.camera-lock-control {
min-height: 44px;
border: 0;
padding: 7px 10px;
display: inline-flex;
align-items: center;
justify-content: center;
@@ -902,27 +901,20 @@ onBeforeUnmount(() => {
z-index: 4;
bottom: 196px;
left: 50%;
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);
width: auto;
transform: translateX(-50%);
}
.camera-zoom-row {
display: flex;
justify-content: space-between;
gap: 6px;
gap: 8px;
}
.camera-zoom-pill {
width: 30px;
height: 26px;
width: 44px;
min-width: 44px;
height: 44px;
min-height: 44px;
padding: 0;
border: 1px solid transparent;
border-radius: 999px;
background: transparent;
color: #fff;
font-size: 10px;
text-align: center;
@@ -933,9 +925,6 @@ 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 {
@@ -956,15 +945,17 @@ 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: #111b;
background: var(--sky-glass);
color: #fff;
display: grid;
place-items: center;
box-shadow: var(--sky-shadow-glass);
}
.camera-selfie {
justify-self: end;
@@ -26,6 +26,12 @@ 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)",
@@ -143,7 +149,9 @@ 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,
)
@@ -193,9 +201,7 @@ 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,
)
+10 -25
View File
@@ -2174,6 +2174,7 @@ onMounted(async () => {
component="button"
type="button"
class="feather-compose-fab"
variant="glass"
:aria-label="t('newPost')"
@click="openComposer()"
>
@@ -2331,7 +2332,8 @@ onMounted(async () => {
/>
<template v-if="mediaPreview.items.length > 1">
<SkyButton
clear
glass
icon-only
rounded
class="feather-media-preview__arrow feather-media-preview__arrow--left"
:aria-label="t('previousImage')"
@@ -2340,7 +2342,8 @@ onMounted(async () => {
<ChevronLeft :size="20" :stroke-width="2.8" />
</SkyButton>
<SkyButton
clear
glass
icon-only
rounded
class="feather-media-preview__arrow feather-media-preview__arrow--right"
:aria-label="t('nextImage')"
@@ -2389,22 +2392,15 @@ 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: 34px !important;
min-width: 34px;
height: 34px;
min-height: 34px;
border: 1.5px solid rgb(255 255 255 / 58%);
width: 44px !important;
min-width: 44px;
height: 44px;
min-height: 44px;
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 {
@@ -4486,7 +4482,6 @@ onMounted(async () => {
place-items: center;
}
.feather-compose-fab {
--sky-app-accent: #58a6ff;
position: absolute;
z-index: 12;
right: 14px;
@@ -4494,23 +4489,14 @@ onMounted(async () => {
width: 46px;
height: 46px;
min-width: 46px;
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%);
color: var(--feather-blue);
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;
@@ -5415,7 +5401,6 @@ 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/main.lua', import.meta.url),
new URL('../../../../sky_phone/source/client/nui_server_bridge.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).toContain('"flare:delete-profile"')
expect(clientSource).toMatch(/flare\s*=\s*\[\[[^\]]*delete-profile/)
expect(serverSource).toContain(
'Bridge.Callbacks.Register("sky_phone:flare:delete-profile"',
)
+9 -7
View File
@@ -2037,14 +2037,17 @@ onBeforeUnmount(() => {
role="dialog"
aria-modal="true"
>
<button
<SkyButton
glass
icon-only
rounded
type="button"
class="flare-match-reveal__close"
:aria-label="phone.t('Common.close')"
@click="matchReveal = null"
>
<X />
</button>
</SkyButton>
<Flame class="flare-match-reveal__flame" fill="currentColor" />
<h2>{{ phone.t('Apps.flare.itsAMatch') }}</h2>
<p>
@@ -3419,14 +3422,13 @@ onBeforeUnmount(() => {
position: absolute;
top: 24px;
right: 18px;
width: 42px;
height: 42px;
width: 44px;
min-width: 44px;
height: 44px;
min-height: 44px;
display: grid;
place-items: center;
border: 0;
border-radius: 50%;
color: #fff;
background: rgb(0 0 0 / 18%);
}
.flare-match-reveal__flame {
width: 58px;
+10 -9
View File
@@ -2408,7 +2408,10 @@ onBeforeUnmount(() => {
alt=""
/>
<template v-if="selectedMediaItems.length > 1">
<button
<SkyButton
glass
icon-only
rounded
type="button"
class="compose-photo-preview__arrow compose-photo-preview__arrow--previous"
:disabled="composerPhotoIndex === 0"
@@ -2416,8 +2419,11 @@ onBeforeUnmount(() => {
@click="moveComposerPhoto(-1)"
>
<ChevronLeft />
</button>
<button
</SkyButton>
<SkyButton
glass
icon-only
rounded
type="button"
class="compose-photo-preview__arrow compose-photo-preview__arrow--next"
:disabled="composerPhotoIndex === selectedMediaItems.length - 1"
@@ -2425,7 +2431,7 @@ onBeforeUnmount(() => {
@click="moveComposerPhoto(1)"
>
<ChevronRight />
</button>
</SkyButton>
<span class="compose-photo-preview__count">
{{ composerPhotoIndex + 1 }} / {{ selectedMediaItems.length }}
</span>
@@ -5872,12 +5878,7 @@ 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,6 +57,21 @@ 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"')
+32 -35
View File
@@ -9,8 +9,6 @@ import {
SkyNavbarBackLink,
SkyAppPage,
SkySpinner,
SkySegmented,
SkySegmentedButton,
SkyNotification,
} from '@/ui'
import {
@@ -19,11 +17,14 @@ 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'
@@ -35,6 +36,8 @@ import {
SkyButton,
SkyDropdown,
SkyNavbar,
SkyTabBar,
SkyTabButton,
SkyToolbar,
SkyToolbarPane,
} from '@/ui'
@@ -68,11 +71,6 @@ 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()
@@ -1378,28 +1376,35 @@ onBeforeUnmount(() => {
</div>
</div>
<sky-navbar
<SkyTabBar
v-if="!requestedMessageMedia && !selectionMode"
component="nav"
class="gallery-filter-navbar"
:aria-label="phone.t('Apps.photos.name')"
icons
labels
class="gallery-filter-tabbar"
:label="phone.t('Apps.photos.name')"
>
<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>
<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>
<SkyToolbar
v-if="selectionMode"
@@ -1694,14 +1699,6 @@ 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;
@@ -44,6 +44,15 @@ describe('House app sheets', () => {
)
})
it('can expose key revocation without advertising key grants', () => {
expect(source).toContain(
'v-if="selectedProperty.capabilities.keyGrant !== false"',
)
expect(source).toContain(
'<section v-if="selectedProperty.capabilities.keys" class="house-keys">',
)
})
it('centers the resident icon above the Give a Key title', () => {
expect(source).toMatch(
/\.house-candidates > svg\s*\{[^}]*display:\s*block;[^}]*margin:\s*0 auto;/s,
+1
View File
@@ -464,6 +464,7 @@ onBeforeUnmount(() => {
><strong>{{ phone.t('Apps.house.keys') }}</strong></span
>
<sky-button
v-if="selectedProperty.capabilities.keyGrant !== false"
rounded
small
inline
@@ -19,7 +19,7 @@ const migrationSource = readFileSync(
'utf8',
)
const clientSource = readFileSync(
new URL('../../../../sky_phone/source/client/main.lua', import.meta.url),
new URL('../../../../sky_phone/source/client/nui_server_bridge.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="neutral"')
expect(toolbar).toContain('variant="glass"')
expect(toolbar).toContain('@click="beginCompose()"')
expect(source).not.toContain('class="mail-search"')
expect(source).not.toContain('class="mail-compose-fab"')
@@ -121,6 +121,11 @@ 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)
@@ -295,7 +300,10 @@ describe('Mail custom mailbox server contract', () => {
'mail:delete-mailbox',
'mail:move',
]) {
expect(clientSource).toContain(`"${endpoint}"`)
const callback = endpoint.slice('mail:'.length)
expect(clientSource).toMatch(
new RegExp(`mail\\s*=\\s*\\[\\[[^\\]]*(?:^|\\s)${callback}(?:\\s|\\]\\])`),
)
}
})
})
+5 -5
View File
@@ -1246,7 +1246,7 @@ onBeforeUnmount(() => {
v-if="editingMailboxes"
component="button"
type="button"
variant="neutral"
variant="glass"
: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="neutral"
variant="glass"
: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="neutral"
variant="glass"
:disabled="!canFilterFolder"
:aria-label="filterButtonLabel"
:aria-pressed="true"
@@ -1448,7 +1448,7 @@ onBeforeUnmount(() => {
component="button"
type="button"
class="mail-filter-fab"
variant="neutral"
variant="glass"
:disabled="!canFilterFolder"
:aria-label="filterButtonLabel"
:aria-pressed="false"
@@ -1467,7 +1467,7 @@ onBeforeUnmount(() => {
<sky-fab
component="button"
type="button"
variant="neutral"
variant="glass"
:aria-label="phone.t('Apps.mail.compose')"
@click="beginCompose()"
>
@@ -15,6 +15,17 @@ 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)')
+9 -7
View File
@@ -667,7 +667,7 @@ onBeforeUnmount(() => {
component="button"
type="button"
class="map-control map-control--share"
variant="primary"
variant="glass"
:aria-label="phone.t('Apps.easyShare.name')"
@click="shareCurrentLocation"
>
@@ -679,7 +679,7 @@ onBeforeUnmount(() => {
component="button"
type="button"
class="map-control"
variant="neutral"
variant="glass"
: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="neutral"
variant="glass"
: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="neutral"
variant="glass"
:disabled="locating"
:aria-label="phone.t('Apps.map.currentLocation')"
@click="loadCurrentLocation(true)"
@@ -1015,14 +1015,16 @@ onBeforeUnmount(() => {
}
.map-control {
--sky-glass-solid: rgb(247 247 248 / 92%);
--sky-glass: rgb(247 247 248 / 72%);
--sky-hairline: rgb(0 0 0 / 16%);
color: #151515;
}
.map-control--share {
color: #fff;
color: #007aff;
}
.sky-app-page--dark .map-control {
--sky-glass-solid: rgb(44 44 46 / 88%);
--sky-glass: rgb(44 44 46 / 62%);
--sky-hairline: rgb(255 255 255 / 16%);
color: #fff;
}
+11 -9
View File
@@ -24,6 +24,7 @@ import type {
MemoryDifficulty,
} from '@/features/games/memory/types'
import { usePhoneStore } from '@/stores/phone'
import { SkyButton } from '@/ui'
const phone = usePhoneStore()
const memory = useMemoryStore()
@@ -135,7 +136,7 @@ onBeforeUnmount(() => {
</div>
<div class="memory-header__actions">
<Sparkles :size="23" aria-hidden="true" />
<button
<SkyButton glass icon-only rounded
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')"
@@ -143,7 +144,7 @@ onBeforeUnmount(() => {
>
<Volume2 v-if="memory.soundEnabled" :size="18" aria-hidden="true" />
<VolumeX v-else :size="18" aria-hidden="true" />
</button>
</SkyButton>
</div>
</header>
@@ -182,7 +183,7 @@ onBeforeUnmount(() => {
<section v-else class="memory-game">
<div class="memory-stats">
<button
<SkyButton glass icon-only rounded
type="button"
class="memory-menu-button"
:aria-label="phone.t('Apps.memory.backToMenu')"
@@ -190,7 +191,7 @@ onBeforeUnmount(() => {
@click="returnToMenu"
>
<ChevronLeft :size="18" :stroke-width="2.6" aria-hidden="true" />
</button>
</SkyButton>
<div>
<span>{{ phone.t('Apps.memory.time') }}</span>
<strong>{{ formatTime(memory.elapsedMs) }}</strong>
@@ -316,7 +317,7 @@ onBeforeUnmount(() => {
}
.memory-header__actions > svg,
.memory-header__actions button {
.memory-header__actions button:not(.sky-button--glass) {
box-sizing: content-box;
padding: 9px;
border: 0;
@@ -325,6 +326,8 @@ onBeforeUnmount(() => {
background: rgb(255 255 255 / 54%);
}
.memory-header__actions .sky-button--glass { color: #7658c7; }
.memory-header__actions button {
display: grid;
place-items: center;
@@ -441,17 +444,16 @@ 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 { justify-self: end; border: 0; color: #7052bf; background: transparent; font-size: 13px; font-weight: 800; }
.memory-stats button:not(.sky-button--glass) { 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;
border: 0;
border-radius: 50%;
background: rgb(255 255 255 / 48%);
color: #7052bf;
cursor: pointer;
}
@@ -0,0 +1,29 @@
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')
})
})
+32 -9
View File
@@ -457,7 +457,7 @@ onBeforeUnmount(() => {
:title="phone.t('Apps.memos.name')"
/>
<SkyScrollArea class="memos-page__content">
<SkyScrollArea padded 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 class="memo-detail-scroll">
<SkyScrollArea padded class="memo-detail-scroll">
<SkyGlass class="memo-fields-glass" :highlight="false">
<SkyList nested :dividers="false">
<SkyField
@@ -648,8 +648,10 @@ onBeforeUnmount(() => {
:aria-label="phone.t('Apps.memos.skipBack')"
@click="skipPlayback(-15)"
>
<RotateCcw :size="22" aria-hidden="true" />
<small>15</small>
<span class="memo-skip-icon" aria-hidden="true">
<RotateCcw :size="24" />
<small>15</small>
</span>
</SkyButton>
<SkyButton
rounded
@@ -678,8 +680,10 @@ onBeforeUnmount(() => {
:aria-label="phone.t('Apps.memos.skipForward')"
@click="skipPlayback(15)"
>
<RotateCw :size="22" aria-hidden="true" />
<small>15</small>
<span class="memo-skip-icon" aria-hidden="true">
<RotateCw :size="24" />
<small>15</small>
</span>
</SkyButton>
</SkyGlass>
@@ -699,7 +703,7 @@ onBeforeUnmount(() => {
:active="playbackRate === rate"
@click="setPlaybackRate(rate)"
>
{{ rate }}×
<span class="memo-speed-label">{{ rate }}×</span>
</SkySegmentedButton>
</SkySegmented>
</SkyGlass>
@@ -1062,10 +1066,25 @@ onBeforeUnmount(() => {
padding: 0;
}
.memo-player-control small {
.memo-skip-icon {
position: relative;
width: 24px;
height: 24px;
display: grid;
flex: none;
place-items: center;
}
.memo-skip-icon small {
position: absolute;
font-size: 8px;
inset: 0;
display: grid;
padding-top: 1px;
font-size: 7px;
font-weight: 800;
font-variant-numeric: tabular-nums;
line-height: 1;
place-items: center;
}
.memo-player-control--main {
@@ -1090,6 +1109,10 @@ 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="neutral"')
expect(toolbar).toContain('variant="glass"')
expect(toolbar).toContain('@click="beginCompose"')
expect(toolbar).toContain('<SquarePen :size="21" />')
expect(inbox).not.toContain('messages-sky-compose-navigation')
@@ -186,6 +186,13 @@ 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', () => {
+39 -14
View File
@@ -126,6 +126,19 @@ 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)
@@ -1235,7 +1248,7 @@ onBeforeUnmount(() => {
:clear-label="phone.t('Common.clear')"
/>
<SkyFab
variant="neutral"
variant="glass"
:aria-label="phone.t('Apps.messages.compose')"
@click="beginCompose"
>
@@ -1714,7 +1727,10 @@ onBeforeUnmount(() => {
{{ phone.t('Apps.messages.noContactsToShare') }}
</p>
</SkyList>
<div v-else class="messages-media-picker__gifs">
<div
v-else
class="messages-media-picker__gifs messages-media-picker__gifs--masonry"
>
<SkySearchbar
v-model="gifQuery"
class="messages-gif-search"
@@ -1724,18 +1740,27 @@ onBeforeUnmount(() => {
@input="queueGifSearch"
@clear="queueGifSearch"
/>
<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>
<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-if="gifResults.length && gifHasMore && !gifLoading"
type="button"
+12 -8
View File
@@ -23,6 +23,7 @@ import type {
MinesweeperDifficulty,
} from '@/features/games/minesweeper/types'
import { usePhoneStore } from '@/stores/phone'
import { SkyButton } from '@/ui'
const phone = usePhoneStore()
const minesweeper = useMinesweeperStore()
@@ -208,7 +209,7 @@ onBeforeUnmount(() => {
<span>{{ phone.t('Apps.minesweeper.eyebrow') }}</span>
<h1>{{ phone.t('Apps.minesweeper.name') }}</h1>
</div>
<button
<SkyButton glass icon-only rounded
type="button"
:aria-label="
phone.t(
@@ -221,7 +222,7 @@ onBeforeUnmount(() => {
>
<Volume2 v-if="minesweeper.soundEnabled" :size="18" aria-hidden="true" />
<VolumeX v-else :size="18" aria-hidden="true" />
</button>
</SkyButton>
</header>
<section v-if="minesweeper.menuOpen" class="minesweeper-menu">
@@ -275,7 +276,7 @@ onBeforeUnmount(() => {
}"
>
<div class="minesweeper-toolbar">
<button
<SkyButton glass icon-only rounded
type="button"
class="minesweeper-toolbar__icon"
:aria-label="phone.t('Apps.minesweeper.backToMenu')"
@@ -283,7 +284,7 @@ onBeforeUnmount(() => {
@click.stop="minesweeper.showMenu()"
>
<ChevronLeft :size="19" :stroke-width="2.7" aria-hidden="true" />
</button>
</SkyButton>
<div>
<span>{{ phone.t('Apps.minesweeper.mines') }}</span>
<strong>{{ minesRemaining }}</strong>
@@ -292,14 +293,14 @@ onBeforeUnmount(() => {
<span>{{ phone.t('Apps.minesweeper.time') }}</span>
<strong>{{ formatTime(minesweeper.elapsedMs) }}</strong>
</div>
<button
<SkyButton glass icon-only rounded
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" />
</button>
</SkyButton>
</div>
<div
@@ -438,8 +439,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,
.minesweeper-toolbar__icon {
.minesweeper-header button:not(.sky-button--glass),
.minesweeper-toolbar__icon:not(.sky-button--glass) {
width: 36px;
height: 36px;
display: grid;
@@ -452,6 +453,9 @@ 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;
+23 -11
View File
@@ -25,6 +25,7 @@ import type {
NeonDropPieceKind,
} from '@/features/games/neon-drop/types'
import { usePhoneStore } from '@/stores/phone'
import { SkyButton } from '@/ui'
type RenderCell = {
active: boolean
@@ -206,7 +207,10 @@ onBeforeUnmount(() => {
<span>{{ phone.t('Apps.neonDrop.eyebrow') }}</span>
<h1>{{ phone.t('Apps.neonDrop.name') }}</h1>
</div>
<button
<SkyButton
glass
icon-only
rounded
type="button"
:aria-label="
phone.t(
@@ -219,7 +223,7 @@ onBeforeUnmount(() => {
v-else
:size="18"
/>
</button>
</SkyButton>
</header>
<section v-if="neon.menuOpen" class="neon-menu">
@@ -274,13 +278,16 @@ onBeforeUnmount(() => {
<section v-else-if="game" class="neon-game">
<div class="neon-toolbar">
<button
<SkyButton
glass
icon-only
rounded
type="button"
:aria-label="phone.t('Apps.neonDrop.backToMenu')"
@click="neon.showMenu()"
>
<ChevronLeft :size="19" />
</button>
</SkyButton>
<div>
<span>{{ phone.t('Apps.neonDrop.score') }}</span
><strong>{{ game.score }}</strong>
@@ -289,7 +296,10 @@ onBeforeUnmount(() => {
<span>{{ phone.t('Apps.neonDrop.lines') }}</span
><strong>{{ game.lines }}</strong>
</div>
<button
<SkyButton
glass
icon-only
rounded
type="button"
:aria-label="phone.t('Apps.neonDrop.pause')"
@click="togglePause"
@@ -299,7 +309,7 @@ onBeforeUnmount(() => {
:size="16"
fill="currentColor"
/><Play v-else :size="16" fill="currentColor" />
</button>
</SkyButton>
</div>
<div class="neon-play-area">
@@ -403,8 +413,8 @@ onBeforeUnmount(() => {
font-size: 32px;
line-height: 1;
}
.neon-header button,
.neon-toolbar button {
.neon-header button:not(.sky-button--glass),
.neon-toolbar button:not(.sky-button--glass) {
width: 35px;
height: 35px;
display: grid;
@@ -415,6 +425,10 @@ 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;
@@ -614,11 +628,9 @@ 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,10 +10,11 @@ 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 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 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', () => {
@@ -27,7 +28,7 @@ describe('NotesApp list controls', () => {
expect(composerSource).toContain('<SkySearchbar')
expect(composerSource).toContain('v-model="searchQuery"')
expect(composerSource).toContain('<SkyFab')
expect(composerSource).toContain('variant="neutral"')
expect(composerSource).toContain('variant="glass"')
expect(composerSource).toContain('@click="createNote"')
expect(composerSource).not.toContain('notes-search')
expect(composerSource).not.toContain('notes-create-fab')
@@ -37,6 +38,20 @@ 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(
+13 -2
View File
@@ -206,6 +206,7 @@ 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')"
@@ -273,7 +274,7 @@ function shareNote(): void {
/>
<SkyFab
:aria-label="phone.t('Apps.notes.newNote')"
variant="neutral"
variant="glass"
@click="createNote"
>
<template #icon>
@@ -305,7 +306,7 @@ function shareNote(): void {
</SkyDialog>
</sky-app-page>
<sky-app-page v-else class="notes-editor-page !pt-[44px] !pb-0">
<sky-app-page v-else class="notes-editor-page !pb-0">
<sky-navbar :title="phone.t('Apps.notes.note')">
<template #left>
<sky-navbar-back-link
@@ -386,6 +387,16 @@ 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);
+12 -8
View File
@@ -14,6 +14,7 @@ import type {
NumberMergeTile,
} from '@/features/games/number-merge/types'
import { usePhoneStore } from '@/stores/phone'
import { SkyButton } from '@/ui'
const phone = usePhoneStore()
const numberMerge = useNumberMergeStore()
@@ -135,7 +136,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
<span>{{ phone.t('Apps.numberMerge.eyebrow') }}</span>
<h1>{{ phone.t('Apps.numberMerge.name') }}</h1>
</div>
<button
<SkyButton glass icon-only rounded
type="button"
:aria-label="
phone.t(
@@ -155,7 +156,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
>
<Volume2 v-if="numberMerge.soundEnabled" :size="18" aria-hidden="true" />
<VolumeX v-else :size="18" aria-hidden="true" />
</button>
</SkyButton>
</header>
<section v-if="numberMerge.menuOpen" class="number-merge-menu">
@@ -210,7 +211,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
<section v-else-if="game" class="number-merge-game">
<div class="number-merge-toolbar">
<button
<SkyButton glass icon-only rounded
type="button"
class="number-merge-toolbar__icon"
:aria-label="phone.t('Apps.numberMerge.backToMenu')"
@@ -218,7 +219,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
@click="numberMerge.showMenu"
>
<ChevronLeft :size="19" :stroke-width="2.7" aria-hidden="true" />
</button>
</SkyButton>
<div>
<span>{{ phone.t('Apps.numberMerge.score') }}</span>
<strong>{{ formatScore(game.score) }}</strong>
@@ -227,7 +228,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
<span>{{ phone.t('Apps.numberMerge.best') }}</span>
<strong>{{ formatScore(numberMerge.bestScore) }}</strong>
</div>
<button
<SkyButton glass icon-only rounded
type="button"
class="number-merge-toolbar__icon number-merge-toolbar__restart"
:aria-label="phone.t('Apps.numberMerge.newGame')"
@@ -235,7 +236,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
@click="requestNewGame"
>
<RotateCcw :size="17" :stroke-width="2.5" aria-hidden="true" />
</button>
</SkyButton>
</div>
<div
@@ -373,8 +374,8 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
overflow-wrap: anywhere;
}
.number-merge-header button,
.number-merge-toolbar__icon {
.number-merge-header button:not(.sky-button--glass),
.number-merge-toolbar__icon:not(.sky-button--glass) {
width: 36px;
height: 36px;
display: grid;
@@ -388,6 +389,9 @@ 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,7 +16,45 @@ 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')
expect(source).not.toContain('<sky-segmented')
})
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')
})
it('opens contact deep links only after contacts bootstrap and consumes the query', () => {
File diff suppressed because it is too large Load Diff
+38 -22
View File
@@ -1062,14 +1062,20 @@ onBeforeUnmount(() => {
selectedPost.media.length
}}</span
>
<button
<SkyButton
glass
icon-only
rounded
class="ps-carousel-button ps-carousel-button--left"
:disabled="(carouselIndexes[selectedPost.id] ?? 0) === 0"
@click="moveCarousel(selectedPost, -1)"
>
<ChevronLeft />
</button>
<button
</SkyButton>
<SkyButton
glass
icon-only
rounded
class="ps-carousel-button ps-carousel-button--right"
:disabled="
(carouselIndexes[selectedPost.id] ?? 0) ===
@@ -1078,7 +1084,7 @@ onBeforeUnmount(() => {
@click="moveCarousel(selectedPost, 1)"
>
<ChevronRight />
</button>
</SkyButton>
<div class="ps-dots">
<span
v-for="(_, index) in selectedPost.media"
@@ -1271,14 +1277,20 @@ onBeforeUnmount(() => {
post.media.length
}}</span
>
<button
<SkyButton
glass
icon-only
rounded
class="ps-carousel-button ps-carousel-button--left"
:disabled="(carouselIndexes[post.id] ?? 0) === 0"
@click="moveCarousel(post, -1)"
>
<ChevronLeft />
</button>
<button
</SkyButton>
<SkyButton
glass
icon-only
rounded
class="ps-carousel-button ps-carousel-button--right"
:disabled="
(carouselIndexes[post.id] ?? 0) === post.media.length - 1
@@ -1286,7 +1298,7 @@ onBeforeUnmount(() => {
@click="moveCarousel(post, 1)"
>
<ChevronRight />
</button>
</SkyButton>
<div class="ps-dots">
<span
v-for="(_, index) in post.media"
@@ -1487,22 +1499,28 @@ onBeforeUnmount(() => {
</button>
</div>
<template v-if="selectedMedia.length > 1">
<button
<SkyButton
glass
icon-only
rounded
class="ps-selection-arrow ps-selection-arrow--left"
:aria-label="t('previousPhoto')"
:disabled="composePreviewIndex === 0"
@click="moveComposePreview(-1)"
>
<ChevronLeft />
</button>
<button
</SkyButton>
<SkyButton
glass
icon-only
rounded
class="ps-selection-arrow ps-selection-arrow--right"
:aria-label="t('nextPhoto')"
:disabled="composePreviewIndex === selectedMedia.length - 1"
@click="moveComposePreview(1)"
>
<ChevronRight />
</button>
</SkyButton>
<span class="ps-selection-counter">
{{ composePreviewIndex + 1 }}/{{ selectedMedia.length }}
</span>
@@ -2858,12 +2876,11 @@ button {
top: 50%;
display: grid;
place-items: center;
width: 34px;
height: 34px;
width: 44px;
min-width: 44px;
height: 44px;
min-height: 44px;
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%);
}
@@ -3191,12 +3208,11 @@ button {
top: 50%;
display: grid;
place-items: center;
width: 34px;
height: 34px;
width: 44px;
min-width: 44px;
height: 44px;
min-height: 44px;
padding: 0;
border: 0;
border-radius: 50%;
background: rgba(0, 0, 0, 0.62);
color: white;
transform: translateY(-50%);
}
@@ -13,6 +13,10 @@ describe('SettingsApp Sky UI contract', () => {
expect(source).not.toMatch(/<\/?k-[a-z]/)
expect(source).toContain('<SkyAppPage')
expect(source).toContain('<SkyNavbar')
expect(source).toContain('class="settings-navbar"')
expect(source).toContain(
'.settings-navbar.sky-navbar--large.sky-navbar--no-navigation',
)
expect(source).toContain(
":variant=\"activeView === 'root' ? 'large' : 'compact'\"",
)
@@ -64,4 +68,10 @@ describe('SettingsApp Sky UI contract', () => {
expect(source).toContain("phone.t('Apps.settings.keepCloudData')")
expect(source).toContain('phone.resetAfterFactoryReset()')
})
it('provides a non-destructive development preview for the reset progress screen', () => {
expect(source).toContain('import.meta.env.DEV')
expect(source).toContain("has('factoryResetPreview')")
expect(source).toContain('factoryResetProgress.value = 46')
})
})
+116 -10
View File
@@ -740,6 +740,14 @@ async function confirmSimEject(): Promise<void> {
}
onMounted(() => {
if (
import.meta.env.DEV &&
new URLSearchParams(window.location.search).has('factoryResetPreview')
) {
factoryResetting.value = true
factoryResetProgress.value = 46
}
if (route.query.wallpaper === '1') {
activeView.value = 'wallpaper'
wallpaperTarget.value =
@@ -771,6 +779,7 @@ onBeforeUnmount(() => {
:label="phone.t('Apps.settings.name')"
>
<SkyNavbar
class="settings-navbar"
:title="
activeView === 'root' ? phone.t('Apps.settings.name') : activeTitle
"
@@ -1699,17 +1708,8 @@ onBeforeUnmount(() => {
>
<div class="settings-reset-content">
<div class="settings-reset-mark" aria-hidden="true">
<span
class="settings-reset-mark__layer settings-reset-mark__layer--back"
></span>
<span
class="settings-reset-mark__layer settings-reset-mark__layer--middle"
></span>
<div class="settings-reset-mark__face">
<Smartphone :size="35" :stroke-width="1.45" />
<span class="settings-reset-mark__erase">
<i></i><i></i><i></i>
</span>
<Smartphone :size="38" :stroke-width="1.45" />
</div>
</div>
@@ -1872,6 +1872,16 @@ onBeforeUnmount(() => {
</template>
<style scoped>
:deep(.settings-navbar.sky-navbar--large) {
min-height: calc(
var(--sky-navbar-safe-area-top) + var(--sky-navbar-large-title-height)
);
}
:deep(.settings-navbar.sky-navbar--large.sky-navbar--no-navigation) {
padding-top: calc(var(--sky-navbar-safe-area-top) + var(--sky-space-3));
}
.settings-search {
margin-bottom: var(--sky-space-4);
}
@@ -2510,6 +2520,102 @@ onBeforeUnmount(() => {
line-height: 14px;
}
/* Factory reset stays intentionally quiet: white, direct and system-like. */
.settings-reset-hero__icon {
border: 0;
border-radius: 50%;
color: #ff3b30;
background: #f2f2f7;
box-shadow: none;
}
.settings-reset-overlay {
padding: 58px 30px 34px;
color: #1d1d1f;
background: #ffffff;
}
.settings-reset-overlay::before,
.settings-reset-overlay::after {
content: none;
}
.settings-reset-content {
max-width: 300px;
}
.settings-reset-mark {
display: grid;
width: 88px;
height: 88px;
place-items: center;
border-radius: 50%;
background: #f2f2f7;
}
.settings-reset-mark__face {
position: static;
display: grid;
width: 88px;
height: 88px;
border: 0;
border-radius: 50%;
color: #ff3b30;
background: transparent;
box-shadow: none;
place-items: center;
}
.settings-reset-heading {
margin-top: 28px;
}
.settings-reset-heading h2 {
color: #1d1d1f;
font-size: 24px;
font-weight: 700;
letter-spacing: -0.04em;
}
.settings-reset-heading p {
color: #6e6e73;
}
.settings-reset-progress-copy {
margin-top: 40px;
}
.settings-reset-progress-copy strong {
color: #1d1d1f;
font-size: 15px;
}
.settings-reset-progress-copy span {
color: #8e8e93;
}
.settings-reset-progress {
height: 5px;
background: #e5e5ea;
}
.settings-reset-progress > span {
background: #007aff;
box-shadow: none;
}
.settings-reset-detail {
color: #8e8e93;
}
.settings-reset-assurance {
margin-top: 34px;
padding: 14px;
border: 0;
border-radius: 16px;
background: #f2f2f7;
box-shadow: none;
}
.settings-reset-assurance__icon {
border-radius: 50%;
color: #007aff;
background: #e4f1ff;
}
.settings-reset-assurance strong {
color: #1d1d1f;
}
.settings-reset-assurance small {
color: #6e6e73;
}
.settings-reset-warning {
color: #8e8e93;
}
.settings-dialog-button--danger:not(:disabled) {
background: var(--sky-danger);
color: #ffffff;
+7 -6
View File
@@ -9,6 +9,7 @@ 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()
@@ -116,9 +117,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>
<button type="button" :aria-label="phone.t(flappy.soundEnabled ? 'Apps.skyFlappy.mute' : 'Apps.skyFlappy.unmute')" @click="toggleSound">
<SkyButton glass icon-only rounded 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" />
</button>
</SkyButton>
</header>
<section v-if="flappy.menuOpen" class="flappy-menu">
@@ -141,10 +142,10 @@ onBeforeUnmount(() => {
<section v-else-if="game" class="flappy-game">
<div class="flappy-toolbar">
<button type="button" :aria-label="phone.t('Apps.skyFlappy.backToMenu')" @pointerdown.stop="flappy.showMenu()" @click.stop="flappy.showMenu()"><ChevronLeft :size="19" /></button>
<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>
<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>
<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>
<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>
</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">
@@ -169,7 +170,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,.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-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-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)}
@@ -190,7 +191,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{width:32px;height:32px}
.flappy-toolbar button{--sky-touch-target:32px;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,4 +90,31 @@ 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)
}
})
})
+9 -7
View File
@@ -17,6 +17,7 @@ import type {
SnakeSpeed,
} from '@/features/games/snake/types'
import { usePhoneStore } from '@/stores/phone'
import { SkyButton } from '@/ui'
const phone = usePhoneStore()
const snake = useSnakeStore()
@@ -205,7 +206,7 @@ onBeforeUnmount(() => {
<section v-else class="snake-game">
<div class="snake-game__meta">
<button
<SkyButton glass icon-only rounded
type="button"
class="snake-game__back"
:aria-label="phone.t('Apps.snake.backToMenu')"
@@ -213,13 +214,16 @@ onBeforeUnmount(() => {
@click="returnToMenu"
>
<ChevronLeft :size="18" :stroke-width="2.7" aria-hidden="true" />
</button>
</SkyButton>
<div>
<span>{{ phone.t('Apps.snake.score') }}</span>
<strong>{{ game.score }}</strong>
</div>
<button
<SkyButton
v-if="game.status !== 'game-over'"
glass
icon-only
rounded
type="button"
class="snake-game__pause"
:aria-label="
@@ -233,7 +237,7 @@ onBeforeUnmount(() => {
>
<Play v-if="game.status === 'paused'" :size="18" fill="currentColor" />
<Pause v-else :size="18" fill="currentColor" />
</button>
</SkyButton>
</div>
<div
@@ -517,14 +521,12 @@ 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; }
+16 -8
View File
@@ -23,6 +23,7 @@ import type {
TowerBlock,
} from '@/features/games/tower-stack/types'
import { usePhoneStore } from '@/stores/phone'
import { SkyButton } from '@/ui'
const phone = usePhoneStore()
const tower = useTowerStackStore()
@@ -185,14 +186,17 @@ onBeforeUnmount(() => {
<span>{{ phone.t('Apps.towerStack.eyebrow') }}</span>
<h1>{{ phone.t('Apps.towerStack.name') }}</h1>
</div>
<button
<SkyButton
glass
icon-only
rounded
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" />
</button>
</SkyButton>
</header>
<section v-if="tower.menuOpen" class="tower-menu">
@@ -232,18 +236,21 @@ onBeforeUnmount(() => {
<section v-else-if="game" class="tower-game">
<div class="tower-toolbar">
<button
<SkyButton
glass
icon-only
rounded
type="button"
:aria-label="phone.t('Apps.towerStack.backToMenu')"
@pointerdown.stop="tower.showMenu()"
@click.stop="tower.showMenu()"
><ChevronLeft :size="19" /></button>
><ChevronLeft :size="19" /></SkyButton>
<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>
<button type="button" :aria-label="phone.t('Apps.towerStack.pause')" @click="togglePause">
<SkyButton glass icon-only rounded 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" />
</button>
</SkyButton>
</div>
<button
@@ -319,7 +326,8 @@ 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, .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-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-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); }
@@ -341,7 +349,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 { width: 32px; height: 32px; border: 0; border-radius: 50%; box-shadow: none; }
.tower-toolbar button { --sky-touch-target: 32px; width: 32px; height: 32px; }
.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,6 +6,10 @@ 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', () => {
@@ -19,4 +23,22 @@ 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\);/,
)
})
})
+2 -3
View File
@@ -1193,7 +1193,7 @@ onBeforeUnmount(() => {
<sky-button
icon-only
rounded
tonal
glass
class="weazel-detail-gallery-control is-previous"
:aria-label="t('accessibility.previousPhoto')"
@click="showPreviousDetailImage"
@@ -1203,7 +1203,7 @@ onBeforeUnmount(() => {
<sky-button
icon-only
rounded
tonal
glass
class="weazel-detail-gallery-control is-next"
:aria-label="t('accessibility.nextPhoto')"
@click="showNextDetailImage"
@@ -2112,7 +2112,6 @@ 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;
}
+4 -4
View File
@@ -18,8 +18,8 @@ const clientCalls = readFileSync(
new URL('../../sky_phone/source/bridge/client/calls.lua', import.meta.url),
'utf8',
)
const clientMain = readFileSync(
new URL('../../sky_phone/source/client/main.lua', import.meta.url),
const clientNuiBridge = readFileSync(
new URL('../../sky_phone/source/client/nui_server_bridge.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(clientMain).toContain('"calls:set-speaker"')
expect(clientNuiBridge).toMatch(/calls\s*=\s*\[\[[^\]]*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(clientMain).toContain('"calls:set-muted"')
expect(clientNuiBridge).toMatch(/calls\s*=\s*\[\[[^\]]*set-muted/)
expect(phoneApp).toContain('@click="toggleCallMute"')
expect(phoneApp).not.toContain('callMuted = !callMuted')
})
+21 -10
View File
@@ -22,6 +22,7 @@ const lifecycleEndpoints = new Set([
'device:notification-open',
'notification:focus',
'sim:picker-close',
'ui:input-focus',
'ui:opened',
'ui:ready',
])
@@ -2205,12 +2206,12 @@ const attachmentAssets = {
video: new Set(['city-loop', 'ocean-loop', 'sunset-loop']),
}
const gifMocks = [
['ICOgUNjpvO0PC', 'Cat reaction'],
['MDJ9IbxxvDUQM', 'Happy dog'],
['l0HlPystfePnAI3G8', 'Celebrate'],
['26ufdipQqU2lhNA4g', 'Wow'],
['3o7abKhOpu0NwenH3O', 'Perfect'],
['xT0xeJpnrWC4XWblEk', 'Party'],
['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],
['111ebonMs90YLu', 'Thumbs up'],
['5GoVLqeAOo6PK', 'Excited'],
['TdfyKrN7HGTIY', 'Happy dance'],
@@ -4656,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
@@ -9647,13 +9648,13 @@ app.post('/api/:endpoint', (request, response) => {
const pageSize = 6
const results = gifMocks
.slice(offset, offset + pageSize)
.map(([id, title]) => ({
height: 200,
.map(([id, title, width, height]) => ({
height: height ?? 200,
id,
previewUrl: `https://media.giphy.com/media/${id}/200w.gif`,
title,
url: `https://media.giphy.com/media/${id}/giphy.gif`,
width: 200,
width: width ?? 200,
}))
response.json({
success: true,
@@ -10407,6 +10408,16 @@ app.post('/api/:endpoint', (request, response) => {
mockPasscode = ''
mockSecurity = { enabled: false, length: null, lockedUntil: 0 }
for (const key of Object.keys(deviceData)) delete deviceData[key]
Object.assign(deviceData, {
apps: { payload: { claimedApps: [] }, revision: 0 },
settings: {
payload: {
settings: { setupCompleted: false, setupStep: 0 },
version: 1,
},
revision: 0,
},
})
response.json({ success: true })
return
}
+26
View File
@@ -1156,6 +1156,7 @@ async function main() {
'device:notification-open',
'notification:focus',
'sim:picker-close',
'ui:input-focus',
'ui:opened',
'ui:ready',
]
@@ -1163,6 +1164,31 @@ async function main() {
await expectSuccess(baseUrl, endpoint)
}
await expectSuccess(baseUrl, 'device:factory-reset')
const resetBootstrap = await expectSuccess(
baseUrl,
'development:bootstrap',
{ _testScenario: 'setupPreview' },
true,
)
assert.equal(
resetBootstrap.device.data.settings.payload.settings.setupCompleted,
false,
'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',
+4 -2
View File
@@ -91,7 +91,7 @@ Sky Phone is built to be the **free FiveM phone you can choose without accepting
| **Inventories** | ox_inventory, qb-inventory, lj-inventory, qs-inventory, codem-inventory, core_inventory, mf-inventory, smx-inventory, hex_4_inventory, and native ESX inventory |
| **Calls** | YACA, PMA Voice, SaltyChat |
| **Radio** | YACA, PMA Voice, SaltyChat |
| **Housing** | ESX Property, qbx_properties |
| **Housing** | RTX Housing, Quasar Housing, VMS Housing, RX Housing, NoLag Properties, SN Properties, ESX Property, qbx_properties |
| **Garages** | Built-in/custom data and a broad set of popular garage providers configured through the bridge |
| **Custom app contracts** | Sky Phone, LB Phone, 17Movement, High Phone, Quasar Smartphone, YSeries |
| **Languages** | English, German |
@@ -525,7 +525,7 @@ Select the provider under `Config.Garage.System`. Vehicle images use the configu
### Housing
Select the provider under `Config.Housing.System`. Automatic mode supports the configured provider priority.
Select `rtx`, `quasar`, `vms`, `rx`, `nolag`, `sn`, `esx_property`, or `qbx_properties` under `Config.Housing.System`. Automatic mode uses `Config.Housing.AutoPriority` and keeps the existing `esx_property` and `qbx_properties` defaults ahead of newly supported providers. Select a provider explicitly when multiple housing resources are running. Each bridge exposes only the capabilities supported by the documented provider API.
### Companies
@@ -560,6 +560,8 @@ 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 -5
View File
@@ -52,9 +52,6 @@ Config.CustomApps = {
MaximumStorageKeyLength = 64, -- Bridge v1 ceiling; lower values tighten the server policy.
MaximumStorageKeysPerApp = 128,
StorageRequestsPerMinute = 120,
AllowRemoteOrigins = {
-- ["https://apps.example.com"] = true,
},
TrustedAdapters = {},
}
@@ -368,8 +365,8 @@ Config.Garage = {
}
Config.Housing = {
System = "auto", -- auto, esx_property, qbx_properties
AutoPriority = { "esx_property", "qbx_properties" },
System = "auto", -- auto, rtx, quasar, vms, rx, nolag, sn, esx_property, qbx_properties
AutoPriority = { "esx_property", "qbx_properties", "rtx", "quasar", "vms", "rx", "nolag", "sn" },
MaximumProperties = 50,
OverviewRequestsPerMinute = 30,
ActionsPerMinute = 12,
+39 -3
View File
@@ -20,7 +20,12 @@ shared_scripts {
'source/shared/imei.lua',
'source/shared/sim_number.lua',
'source/shared/custom_apps.lua',
'source/shared/custom_app_compat.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',
}
client_scripts {
@@ -34,7 +39,11 @@ 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',
@@ -43,9 +52,21 @@ client_scripts {
'source/bridge/client/radio.lua',
'source/client/payphones.lua',
'source/client/custom_apps.lua',
'source/client/custom_app_compat.lua',
'source/client/nui_server_bridge.lua',
'source/client/nui_events.lua',
'source/client/notifications.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 {
@@ -55,6 +76,7 @@ server_scripts {
'config/media.lua',
'config/locales/en.lua',
'config/locales/de.lua',
'source/server/update_check.lua',
'source/bridge/server/database.lua',
'source/bridge/server/migrations.lua',
'source/bridge/server/callbacks.lua',
@@ -66,18 +88,24 @@ 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',
@@ -107,6 +135,14 @@ 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 {
@@ -5,10 +5,67 @@ Bridge.Housing = {
function Bridge.Housing.RegisterClientProvider(name, provider)
assert(type(name) == "string" and name ~= "", "Housing provider name must be a non-empty string")
assert(type(provider) == "table" and type(provider.execute) == "function", "Housing client provider must implement execute")
assert(
provider.enrich_overview == nil or type(provider.enrich_overview) == "function",
"Housing client provider enrich_overview must be a function"
)
assert(not Bridge.Housing.ClientProviders[name], ("Housing client provider '%s' is already registered"):format(name))
Bridge.Housing.ClientProviders[name] = provider
end
function Bridge.Housing.EnrichOverview(provider_name, properties)
if type(properties) ~= "table" then
return nil, "invalid_overview"
end
local provider = Bridge.Housing.ClientProviders[provider_name]
if not provider or type(provider.enrich_overview) ~= "function" then
return properties
end
local success, enriched, error_code = pcall(provider.enrich_overview, properties)
if not success then
Bridge.Debug(
"error",
"[sky_phone] Housing provider '%s' overview enrichment failed: %s",
tostring(provider_name),
tostring(enriched)
)
return properties
end
if type(enriched) ~= "table" then
Bridge.Debug(
"error",
"[sky_phone] Housing provider '%s' returned invalid overview enrichment: %s",
tostring(provider_name),
tostring(error_code or enriched)
)
return properties
end
local entrances = {}
for _, property in ipairs(enriched) do
if type(property) == "table" and property.id ~= nil and entrances[property.id] == nil then
entrances[property.id] = Bridge.Normalize.Coordinates(property.entrance)
end
end
local result = {}
for _, property in ipairs(properties) do
local entrance = type(property) == "table" and entrances[property.id] or nil
if entrance then
local normalized = {}
for key, value in pairs(property) do
normalized[key] = value
end
normalized.entrance = entrance
result[#result + 1] = normalized
else
result[#result + 1] = property
end
end
return result
end
function Bridge.Housing.Execute(provider_name, action, data)
local provider = Bridge.Housing.ClientProviders[provider_name]
if not provider then
@@ -0,0 +1,27 @@
local resource_name = "nolag_properties"
Bridge.Housing.RegisterClientProvider("nolag", {
execute = function(action, data)
if action ~= "grant_key" and action ~= "revoke_key" and action ~= "toggle_lock" then
return false, "capability_unavailable"
end
if GetResourceState(resource_name) ~= "started" then
return false, "provider_unavailable"
end
if type(data) ~= "table" then
return false, "invalid_request"
end
local result = Bridge.Callbacks.Trigger("sky_phone:housing:nolag:execute", {
action = action,
propertyId = data.propertyId,
providerId = data.providerId,
target = data.target,
identifier = data.identifier,
})
if type(result) == "table" and result.success then
return true
end
return false, type(result) == "table" and result.error or "provider_rejected"
end,
})
@@ -0,0 +1,156 @@
local provider_name = "quasar"
local resource_name = "qs-housing"
local function table_like(value)
local value_type = type(value)
return value_type == "table" or value_type == "vector3" or value_type == "vector4"
end
local function field(value, key)
if not table_like(value) then
return nil
end
local success, result = pcall(function()
return value[key]
end)
return success and result or nil
end
local function decode_object(value)
if table_like(value) then
return value
end
if type(value) ~= "string" or value == "" then
return nil
end
local success, decoded = pcall(json.decode, value)
return success and table_like(decoded) and decoded or nil
end
local function normalized_coords(value)
value = decode_object(value)
return value and Bridge.Normalize.Coordinates(value) or nil
end
local function property_tables(value, house)
value = decode_object(value)
if not value then
return {}
end
local result = { value }
local named = house and decode_object(field(value, house)) or nil
if named and named ~= value then
result[#result + 1] = named
end
for _, key in ipairs({ "data", "houseData", "house_data", "propertyData", "property_data" }) do
local nested = decode_object(field(value, key))
if nested and nested ~= value and nested ~= named then
result[#result + 1] = nested
end
end
return result
end
local function entrance_for(value, house)
for _, candidate in ipairs(property_tables(value, house)) do
for _, key in ipairs({ "entrance", "Entrance", "entry", "Entry", "enter", "Enter" }) do
local coords = normalized_coords(field(candidate, key))
if coords then
return coords
end
end
for _, key in ipairs({ "coords", "coordinates", "location", "position", "points" }) do
local container = decode_object(field(candidate, key))
if container then
for _, nested_key in ipairs({
"entrance", "Entrance", "entry", "Entry", "enter", "Enter", "door", "frontDoor",
}) do
local coords = normalized_coords(field(container, nested_key))
if coords then
return coords
end
end
local coords = normalized_coords(container)
if coords then
return coords
end
end
end
end
return nil
end
local function house_reference(value)
if type(value) ~= "string" and type(value) ~= "number" then
return nil
end
local house = tostring(value):match("^%s*(.-)%s*$")
return house ~= "" and house or nil
end
local function house_entrance(house)
local success, house_data = pcall(function()
return exports["qs-housing"]:getHouseData(house)
end)
if not success then
Bridge.Debug(
"error",
"[sky_phone] qs-housing:getHouseData failed for '%s': %s",
house,
tostring(house_data)
)
return nil, "provider_error"
end
local entrance = entrance_for(house_data, house)
if not entrance then
return nil, "invalid_coordinates"
end
return entrance
end
local function enrich_overview(properties)
local result = {}
if GetResourceState(resource_name) ~= "started" or type(properties) ~= "table" then
return result
end
for _, property in ipairs(properties) do
local house = type(property) == "table" and house_reference(property.providerId) or nil
if house and property.id == provider_name .. ":" .. house then
local entrance = normalized_coords(property.entrance)
if not entrance then
entrance = house_entrance(house)
end
if entrance then
result[#result + 1] = {
id = property.id,
entrance = entrance,
}
end
end
end
return result
end
Bridge.Housing.RegisterClientProvider(provider_name, {
enrich_overview = enrich_overview,
execute = function(action, data)
if GetResourceState(resource_name) ~= "started" then
return false, "provider_unavailable"
end
if action ~= "set_waypoint" then
return false, "capability_unavailable"
end
local house = type(data) == "table" and house_reference(data.providerId) or nil
if not house then
return false, "invalid_property"
end
local entrance, error_code = house_entrance(house)
if not entrance then
return false, error_code
end
SetNewWaypoint(entrance.x + 0.0, entrance.y + 0.0)
return true
end,
})
@@ -0,0 +1,25 @@
local provider_name = "rtx"
local resource_name = "rtx_housing"
Bridge.Housing.RegisterClientProvider(provider_name, {
execute = function(action, data)
if action ~= "toggle_lock" then
return false, "capability_unavailable"
end
if GetResourceState(resource_name) ~= "started" then
return false, "provider_unavailable"
end
if type(data) ~= "table" or type(data.providerId) ~= "string" then
return false, "invalid_request"
end
local result = Bridge.Callbacks.Trigger("sky_phone:housing:rtx:execute", {
action = action,
providerId = data.providerId,
})
if result and result.success then
return true
end
return false, result and result.error or "provider_rejected"
end,
})
@@ -0,0 +1,27 @@
local provider_name = "rx"
local resource_name = "RxHousing"
Bridge.Housing.RegisterClientProvider(provider_name, {
execute = function(action, data)
if action ~= "grant_key" and action ~= "revoke_key" then
return false, "capability_unavailable"
end
if GetResourceState(resource_name) ~= "started" then
return false, "provider_unavailable"
end
if type(data) ~= "table" then
return false, "invalid_request"
end
local result = Bridge.Callbacks.Trigger("sky_phone:housing:rx:execute", {
action = action,
providerId = data.providerId,
target = data.target,
identifier = data.identifier,
})
if result and result.success then
return true
end
return false, result and result.error or "provider_rejected"
end,
})
@@ -0,0 +1,26 @@
local resource_name = "sn_properties"
Bridge.Housing.RegisterClientProvider("sn", {
execute = function(action, data)
if action ~= "grant_key" and action ~= "revoke_key" then
return false, "capability_unavailable"
end
if GetResourceState(resource_name) ~= "started" then
return false, "provider_unavailable"
end
if type(data) ~= "table" then
return false, "invalid_request"
end
local result = Bridge.Callbacks.Trigger("sky_phone:housing:sn:execute", {
action = action,
propertyId = data.propertyId,
providerId = data.providerId,
identifier = data.identifier,
})
if type(result) == "table" and result.success then
return true
end
return false, type(result) == "table" and result.error or "provider_rejected"
end,
})
@@ -0,0 +1,11 @@
local provider_name = "vms"
local resource_name = "vms_housing"
Bridge.Housing.RegisterClientProvider(provider_name, {
execute = function()
if GetResourceState(resource_name) ~= "started" then
return false, "provider_unavailable"
end
return false, "capability_unavailable"
end,
})
@@ -0,0 +1,175 @@
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)
@@ -0,0 +1,340 @@
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)
@@ -0,0 +1,263 @@
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)
@@ -0,0 +1,81 @@
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)
@@ -0,0 +1,177 @@
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)
@@ -0,0 +1,136 @@
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)
@@ -0,0 +1,124 @@
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
)
@@ -0,0 +1,159 @@
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
@@ -0,0 +1,253 @@
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)
@@ -0,0 +1,20 @@
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)
@@ -0,0 +1,39 @@
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)
@@ -0,0 +1,101 @@
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)
@@ -0,0 +1,352 @@
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)

Some files were not shown because too many files have changed in this diff Show More