mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-30 17:59:02 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4879d4569b |
@@ -17,8 +17,6 @@ for (const requiredFragment of [
|
||||
"fx_version 'cerulean'",
|
||||
"node_version '22'",
|
||||
"use_experimental_fxv2_oal 'yes'",
|
||||
"'source/server/nui_build_check.lua'",
|
||||
"'source/html/sounds/**'",
|
||||
"ui_page 'source/html/index.html'",
|
||||
]) {
|
||||
if (!manifest.includes(requiredFragment)) {
|
||||
|
||||
@@ -119,5 +119,5 @@ jobs:
|
||||
"${archive}.sha256" \
|
||||
--verify-tag \
|
||||
--generate-notes \
|
||||
--title "$GITHUB_REF_NAME Beta"
|
||||
--title "Sky Phone $GITHUB_REF_NAME"
|
||||
fi
|
||||
|
||||
@@ -92,23 +92,6 @@ as project instructions, not as optional documentation.
|
||||
proof.
|
||||
- Do not hand-edit generated frontend output.
|
||||
|
||||
## Phone Configurator parity (mandatory)
|
||||
|
||||
- Every new, changed, renamed, moved, or removed configurable value in
|
||||
`sky_phone/config/config.lua` or `sky_phone/config/media.lua` **must** be
|
||||
reflected in the in-game Phone Configurator in the same change. Config-only
|
||||
changes without matching Configurator support are incomplete and must not be
|
||||
committed or merged.
|
||||
- Keep the Configurator's runtime schema, defaults, value types, fixed versus
|
||||
extensible collection rules, labels, descriptions, English and German
|
||||
locales, SQL persistence, validation, and save/load roundtrip aligned with
|
||||
the Lua configuration.
|
||||
- The Configurator must remain complete when file-based configuration is
|
||||
disabled. Do not introduce a setting that can only be managed by editing Lua
|
||||
after `Config.PhoneConfigurator.Enabled` is enabled.
|
||||
- Add or update contract/fixture coverage so configuration parity regressions
|
||||
fail automated checks.
|
||||
|
||||
## Working method and verification
|
||||
|
||||
1. Trace the relevant client, server, NUI, configuration, persistence, and event
|
||||
|
||||
-750
@@ -1,750 +0,0 @@
|
||||
# Sky Phone Creator API
|
||||
|
||||
This is the first-party integration contract for resources that integrate directly with `sky_phone`. It covers the shared, client, and server exports, native custom apps, and the Sky iframe bridge protocol.
|
||||
|
||||
The current native API version is `1.0.0`. The current custom-app schema and iframe protocol version is `1`.
|
||||
|
||||
> Provider compatibility is a separate surface. New integrations should call `exports["sky_phone"]` directly, not the provided `lb-phone`, `17mov_Phone`, `high-phone`, `qs-smartphone`, or `yseries` aliases.
|
||||
|
||||
## Quick start
|
||||
|
||||
Declare Sky Phone as a dependency so FiveM starts it before your resource. A custom app normally has both client and server code and exposes its web files:
|
||||
|
||||
~~~lua
|
||||
fx_version "cerulean"
|
||||
game "gta5"
|
||||
|
||||
dependency "sky_phone"
|
||||
|
||||
client_script "client.lua"
|
||||
server_script "server.lua"
|
||||
|
||||
files {
|
||||
"web/index.html",
|
||||
"web/**/*",
|
||||
}
|
||||
~~~
|
||||
|
||||
The server must have both `Config.CustomApps.Enabled` and `Config.CustomApps.ExternalApps` enabled. Treat capability discovery as authoritative because a server can disable or tighten parts of this contract.
|
||||
|
||||
### Export context and ownership
|
||||
|
||||
Client and server exports are different runtimes even when they share a name. Call each export from the matching client or server script.
|
||||
|
||||
Native custom-app exports derive the owner from FiveM's `GetInvokingResource()`. Therefore:
|
||||
|
||||
- Call them directly as `exports["sky_phone"]:ExportName(...)`.
|
||||
- Do not proxy creator calls through another resource; the proxy becomes the owner.
|
||||
- Register the client app and server policy from the same resource name.
|
||||
- Only the owner can update, remove, open, close, message, or notify its app.
|
||||
- Owned client registrations and server policies are removed automatically when the owner resource stops.
|
||||
|
||||
All input from an iframe or game client remains untrusted. Perform permission, identity, money, inventory, and other consequential validation on the server.
|
||||
|
||||
## Readiness and capabilities
|
||||
|
||||
The client API is ready when its exports load. The server API becomes ready after Sky Phone's database migration and domain services are available.
|
||||
|
||||
| Side | Poll | Local event |
|
||||
| --- | --- | --- |
|
||||
| Client | `exports["sky_phone"]:IsApiReady()` | `sky_phone:client:apiReady` with `apiVersion` |
|
||||
| Server | `exports["sky_phone"]:IsApiReady()` | `sky_phone:server:apiReady` with `apiVersion` |
|
||||
|
||||
The ready events are local events, not network events. Check first and subscribe second so your resource also works when the event has already fired:
|
||||
|
||||
~~~lua
|
||||
local function on_sky_phone_ready()
|
||||
if not exports["sky_phone"]:IsApiReady() then
|
||||
return
|
||||
end
|
||||
|
||||
local capabilities = exports["sky_phone"]:GetApiCapabilities()
|
||||
print(("[my_resource] Sky Phone API %s is ready on %s."):format(
|
||||
capabilities.apiVersion,
|
||||
capabilities.side
|
||||
))
|
||||
end
|
||||
|
||||
CreateThread(on_sky_phone_ready)
|
||||
AddEventHandler("sky_phone:client:apiReady", on_sky_phone_ready) -- client.lua
|
||||
-- Use sky_phone:server:apiReady in server.lua.
|
||||
~~~
|
||||
|
||||
`GetApiCapabilities()` returns a fresh snapshot with `apiVersion`, `side`, `ready`, and `features`. The current client feature groups include calls, camera, custom apps, equipped phone number, navigation, custom-app notifications, phone game input, and phone state. The current server feature groups include calls, custom apps, device directory, custom-app notifications, and phone-number lookup.
|
||||
|
||||
`features.customApps` is an object with `enabled` and `external`. `features.notifications` is an object with `customApps = true` and `system = false`. There is intentionally no generic first-party system-notification export for creators.
|
||||
|
||||
For custom-app-specific export names, bridge methods, protocol version, and configured size limits, call `GetCustomAppCapabilities()` on the relevant side. Do not infer a feature from the API version alone.
|
||||
|
||||
## Return and error conventions
|
||||
|
||||
The API uses these conventions:
|
||||
|
||||
- Mutations normally return `true` on success or `false, errorCode` on rejection.
|
||||
- Lookups normally return a value or `nil, errorCode`. State queries such as `GetActiveCall()` and identity shortcuts can return plain `nil` when no value exists.
|
||||
- Predicates return a boolean and generally collapse invalid input into `false`.
|
||||
- `TogglePhone()` returns only a boolean.
|
||||
- Server `SendCustomAppNotification()` returns `true, { delivered = 1 }` on delivery or `false, errorCode`.
|
||||
- Returned tables are snapshots. Mutating them does not update Sky Phone.
|
||||
- Error codes are machine-readable strings. Handle unknown future codes as well as the documented common codes.
|
||||
|
||||
A missing resource or export is a FiveM invocation error rather than one of these return values. The manifest dependency avoids the normal load-order case.
|
||||
|
||||
## Shared exports
|
||||
|
||||
These exports exist in both client and server scripts.
|
||||
|
||||
| Export | Signature | Result |
|
||||
| --- | --- | --- |
|
||||
| `GetApiVersion` | `()` | `"1.0.0"` |
|
||||
| `NormalizePhoneNumber` | `(value)` | Configured normalized number, or `nil, "invalid_phone_number"` |
|
||||
| `FormatPhoneNumber` | `(value)` | Configured display format, or `nil, "invalid_phone_number"` |
|
||||
| `IsValidImei` | `(value)` | Boolean |
|
||||
|
||||
Normalization uses the server's configured SIM prefix and length; formatting uses its configured number groups.
|
||||
|
||||
## Client exports
|
||||
|
||||
### Phone state and focus
|
||||
|
||||
| Export | Signature | Result |
|
||||
| --- | --- | --- |
|
||||
| `TogglePhone` | `(open?, noFocus?)` | Boolean |
|
||||
| `GetPhoneState` | `()` | Phone-state snapshot |
|
||||
| `GetEquippedPhoneNumber` | `()` | Authoritative normalized number or `nil` |
|
||||
| `SetPhoneGameInputEnabled` | `(enabled)` | `true` or `false, errorCode` |
|
||||
|
||||
`TogglePhone(nil)` toggles. `TogglePhone(true, true)` requests an open phone without cursor focus. A successful open request is asynchronous; observe `sky_phone:client:phoneToggled` or read `GetPhoneState()` for the confirmed UI state.
|
||||
|
||||
The phone-state snapshot is:
|
||||
|
||||
~~~lua
|
||||
{
|
||||
inCall = boolean,
|
||||
onScreen = boolean, -- compatibility alias of open
|
||||
open = boolean,
|
||||
phoneNumber = string | nil,
|
||||
}
|
||||
~~~
|
||||
|
||||
`SetPhoneGameInputEnabled(true)` allows game input while the phone is open; `false` applies the external override in the other direction. The claim is associated with the invoking resource and is cleared when that resource stops or the phone closes. It can return `resource_required`, `invalid_focus_claim`, or `phone_closed`.
|
||||
|
||||
### Navigation
|
||||
|
||||
| Export | Signature | Result |
|
||||
| --- | --- | --- |
|
||||
| `OpenApp` | `(appId)` | `true` or `false, errorCode` |
|
||||
| `CloseApp` | `(appId?)` | `true` or `false, errorCode` |
|
||||
| `GetNavigationState` | `()` | Navigation snapshot |
|
||||
| `GetCurrentApp` | `()` | Active app ID, `"home"`, or `nil` if the phone is closed |
|
||||
| `GetCurrentApp` | `(appId)` | Boolean |
|
||||
| `IsAppDataLoaded` | `()` | Boolean |
|
||||
| `IsAppInstalled` | `(appId)` | Boolean |
|
||||
|
||||
`OpenApp` requires an open phone and an installed app. `CloseApp` optionally verifies the expected active app before closing it.
|
||||
|
||||
~~~lua
|
||||
local state = exports["sky_phone"]:GetNavigationState()
|
||||
-- {
|
||||
-- currentApp = string | nil,
|
||||
-- dataLoaded = boolean,
|
||||
-- installedApps = { [appId] = true },
|
||||
-- }
|
||||
~~~
|
||||
|
||||
API readiness and app-data readiness are separate. Wait for `IsAppDataLoaded()` before assuming the installed-app catalog is populated.
|
||||
|
||||
Common navigation errors are `invalid_app_id`, `phone_closed`, `app_not_installed`, and `app_not_active`.
|
||||
|
||||
### Calls
|
||||
|
||||
| Export | Signature | Result |
|
||||
| --- | --- | --- |
|
||||
| `Dial` | `(phoneNumber?, companyId?)` | `true` or `false, errorCode` |
|
||||
| `AnswerCall` | `()` | `true` or `false, errorCode` |
|
||||
| `DeclineCall` | `()` | `true` or `false, errorCode` |
|
||||
| `HangupCall` | `()` | `true` or `false, errorCode` |
|
||||
| `TerminateCall` | `()` | `true` or `false, errorCode` |
|
||||
| `GetActiveCall` | `()` | Call snapshot or `nil` |
|
||||
| `IsInCall` | `()` | Boolean |
|
||||
|
||||
At least one non-empty dial target is required. Use `Dial("5550100")` for a number or `Dial(nil, "police")` for a company service line. If a valid company ID is supplied, its service line takes precedence.
|
||||
|
||||
`AnswerCall` and `DeclineCall` require an incoming ringing call. `HangupCall` and `TerminateCall` accept a ringing or connected call. Normal hangup/decline semantics can reroute an unanswered company call; termination force-finishes it.
|
||||
|
||||
A client call snapshot contains:
|
||||
|
||||
~~~lua
|
||||
{
|
||||
id = string,
|
||||
state = "ringing" | "connected",
|
||||
direction = "incoming" | "outgoing",
|
||||
otherNumber = string,
|
||||
startedAt = number,
|
||||
answeredAt = number | nil,
|
||||
channel = number | nil,
|
||||
speakerEnabled = boolean,
|
||||
speakerSupported = boolean,
|
||||
muted = boolean,
|
||||
muteSupported = boolean,
|
||||
|
||||
-- Outgoing payphone calls can also contain:
|
||||
elapsedSeconds = number | nil,
|
||||
totalCost = number | nil,
|
||||
}
|
||||
~~~
|
||||
|
||||
Listen to the local `sky_phone:client:callChanged` event for state transitions. Its payload is the latest call snapshot, a terminal call-state payload, or `nil` when state is reset.
|
||||
|
||||
### Camera claims
|
||||
|
||||
| Export | Signature | Result |
|
||||
| --- | --- | --- |
|
||||
| `GetCameraState` | `()` | Camera-state snapshot |
|
||||
| `SetFlashlight` | `(enabled)` | `true` or `false, errorCode` |
|
||||
| `SetSelfieCamera` | `(enabled)` | `true` or `false, errorCode` |
|
||||
| `EnableWalkableCamera` | `(selfie?)` | `true` or `false, errorCode` |
|
||||
| `DisableWalkableCamera` | `()` | `true` or `false, errorCode` |
|
||||
| `SetCameraFrozen` | `(frozen)` | `true, state` or `false, errorCode` |
|
||||
| `ToggleCameraFrozen` | `()` | `true, state` or `false, errorCode` |
|
||||
| `ReleaseCamera` | `()` | `true` or `false, errorCode` |
|
||||
|
||||
The first state-changing call that needs camera control claims it for the invoking resource. A second resource receives `camera_claimed`. If the built-in camera is already active before an external claim, the caller receives `camera_in_use`.
|
||||
|
||||
~~~lua
|
||||
local ok, err = exports["sky_phone"]:EnableWalkableCamera(true)
|
||||
if not ok then
|
||||
print(("Could not claim the phone camera: %s"):format(err))
|
||||
return
|
||||
end
|
||||
|
||||
exports["sky_phone"]:SetFlashlight(true)
|
||||
|
||||
-- Always release on the normal completion/cancel path.
|
||||
exports["sky_phone"]:ReleaseCamera()
|
||||
~~~
|
||||
|
||||
`ReleaseCamera` disables walkable mode, flashlight, and selfie mode and releases the claim. `DisableWalkableCamera` performs the same full release for the caller's claimed session. Sky Phone also performs this cleanup automatically if the owning resource stops. `SetCameraFrozen` and `ToggleCameraFrozen` require an active camera and can return `camera_not_active`.
|
||||
|
||||
The state snapshot is:
|
||||
|
||||
~~~lua
|
||||
{
|
||||
active = boolean,
|
||||
flashEnabled = boolean,
|
||||
frozen = boolean,
|
||||
selfie = boolean,
|
||||
walkable = boolean,
|
||||
}
|
||||
~~~
|
||||
|
||||
Other common errors are `resource_required` and `invalid_state`. Observe `sky_phone:client:cameraActiveChanged` for active-state changes.
|
||||
|
||||
## Native custom apps
|
||||
|
||||
A complete server-backed app normally registers a client definition and a matching server policy. The app ID and permission list should match on both sides.
|
||||
|
||||
### Client registration
|
||||
|
||||
~~~lua
|
||||
local APP_ID = "my-resource-app"
|
||||
local PERMISSIONS = {
|
||||
"app.close",
|
||||
"app.open",
|
||||
"device.storage",
|
||||
"locale.read",
|
||||
"notifications",
|
||||
"theme.read",
|
||||
}
|
||||
|
||||
local registered = false
|
||||
|
||||
local function register_app()
|
||||
if registered or not exports["sky_phone"]:IsApiReady() then
|
||||
return
|
||||
end
|
||||
|
||||
local capabilities = exports["sky_phone"]:GetCustomAppCapabilities()
|
||||
if not capabilities.externalApps then
|
||||
print("[my_resource] External Sky Phone apps are disabled.")
|
||||
return
|
||||
end
|
||||
|
||||
local ok, err = exports["sky_phone"]:AddCustomApp({
|
||||
schemaVersion = 1,
|
||||
id = APP_ID,
|
||||
name = { en = "My App", de = "Meine App" },
|
||||
description = {
|
||||
en = "An example Sky Phone app.",
|
||||
de = "Eine Sky-Phone-Beispiel-App.",
|
||||
},
|
||||
developer = "My Studio",
|
||||
category = "utilities",
|
||||
ui = "web/index.html",
|
||||
icon = "web/icon.png",
|
||||
permissions = PERMISSIONS,
|
||||
orientation = "portrait",
|
||||
defaultInstalled = false,
|
||||
removable = true,
|
||||
bridgeMode = "sky",
|
||||
|
||||
onInstall = function(data) end,
|
||||
onDelete = function(data) end,
|
||||
onOpen = function(data) end,
|
||||
onReady = function(data) end,
|
||||
onClose = function(data) end,
|
||||
})
|
||||
|
||||
if not ok then
|
||||
print(("[my_resource] AddCustomApp failed: %s"):format(err))
|
||||
return
|
||||
end
|
||||
registered = true
|
||||
end
|
||||
|
||||
CreateThread(register_app)
|
||||
AddEventHandler("sky_phone:client:apiReady", register_app)
|
||||
~~~
|
||||
|
||||
Set `bridgeMode = "sky"` explicitly for new apps. The default for external definitions is `legacy` for compatibility.
|
||||
|
||||
#### Definition fields
|
||||
|
||||
| Field | Required | Contract |
|
||||
| --- | --- | --- |
|
||||
| `schemaVersion` | Recommended | If present, must be `1` |
|
||||
| `id` | Yes | 2-64 lowercase characters matching `^[a-z0-9][a-z0-9._-]+$`; built-in IDs are reserved |
|
||||
| `name` | Yes | Non-empty string or locale map, maximum 64 UTF-8 bytes per value |
|
||||
| `description` | No | String or locale map, maximum 320 UTF-8 bytes per value |
|
||||
| `developer` | No | Non-empty string, maximum 96 UTF-8 bytes |
|
||||
| `category` | No | `games`, `productivity`, `shopping`, `social`, or `utilities`; default `utilities` |
|
||||
| `ui` | Yes | Relative resource asset or HTTPS URL |
|
||||
| `icon` | No | Relative resource asset or HTTPS URL; omitted uses the default icon |
|
||||
| `permissions` | No | Unique permission array; default empty |
|
||||
| `orientation` | No | `portrait`, `landscape`, or `any`; default `portrait` |
|
||||
| `defaultInstalled` | No | Boolean; default `false` |
|
||||
| `removable` | No | Boolean; default `true` |
|
||||
| `gridOrder` | No | Integer from 0 through 9999 |
|
||||
| `iconBackground` | No | Non-empty string up to 64 UTF-8 bytes; CR/LF, semicolons, and braces are rejected |
|
||||
| `bridgeMode` | No | `sky` or `legacy`; external default `legacy` |
|
||||
| lifecycle hooks | No | Callable `onInstall`, `onDelete`, `onOpen`, `onReady`, and `onClose` |
|
||||
|
||||
Relative `ui` and `icon` values resolve to the calling resource's `https://cfx-nui-RESOURCE/...` origin. Path traversal, unsupported schemes, and cross-resource CFX-NUI ownership are rejected. Add every local asset to the creator resource's `files` list.
|
||||
|
||||
Registration is not the same as installation. `onInstall` and `onDelete` represent the user's App Store lifecycle. `onReady` occurs after the Sky bridge handshake (or frame load in legacy mode). Hook payloads are JSON-safe data or `nil`. Keep authoritative mutations on the server.
|
||||
|
||||
`UpdateCustomApp(definition)` takes a complete definition with the same owned ID. `RemoveCustomApp(appId)` removes the owned registration and closes it if active. Resource-stop cleanup is automatic.
|
||||
|
||||
### Permissions
|
||||
|
||||
| Permission | Current native effect |
|
||||
| --- | --- |
|
||||
| `app.close` | Enables iframe method `app.close` |
|
||||
| `app.open` | Enables iframe method `app.open` |
|
||||
| `device.storage` | Enables iframe methods `device.storage.get` and `device.storage.set` |
|
||||
| `locale.read` | Adds `locale.read` capability and conditionally exposes `language` and `locale` in context |
|
||||
| `notifications` | Enables iframe method `notification.create` and owner-validated Lua notifications |
|
||||
| `notifications.critical` | Enables `critical = true` only for the client Lua notification export |
|
||||
| `theme.read` | Adds `theme.read` capability and conditionally exposes `colorScheme` in context |
|
||||
|
||||
`camera.capture`, `contacts.pick`, `location.read`, `media.pick`, and `nui.fetch` are accepted permission identifiers, but the current v1 iframe bridge exposes no method or context field for them. Do not infer support from the permission list; inspect `GetCustomAppCapabilities().bridgeMethods` and the context `capabilities` array.
|
||||
|
||||
### Client lifecycle exports
|
||||
|
||||
| Export | Signature | Notes |
|
||||
| --- | --- | --- |
|
||||
| `AddCustomApp` | `(definition)` | Register a new app owned by the caller |
|
||||
| `UpdateCustomApp` | `(definition)` | Replace the complete owned definition |
|
||||
| `RemoveCustomApp` | `(appId)` | Remove the owned app |
|
||||
| `OpenCustomApp` | `(appId, payload?)` | Requires an open phone and an owned registered app |
|
||||
| `CloseCustomApp` | `(appId)` | Requires that owned app to be active |
|
||||
| `SendAppMessage` | `(appId, payload)` | Canonical host-to-iframe message export |
|
||||
| `SendCustomAppMessage` | `(appId, payload)` | Exact compatibility alias of `SendAppMessage` |
|
||||
| `SendCustomAppNotification` | `(appId, notification)` | Owner- and permission-validated local notification |
|
||||
| `GetCustomAppCapabilities` | `()` | Custom-app ABI, methods, export names, and configured limits |
|
||||
|
||||
Client message dispatch requires the owned app to be active. If its frame is active but has not completed readiness, up to 64 messages are queued and flushed after readiness. An inactive app returns `app_not_active`. Payloads must be JSON-safe and fit `maximumMessageBytes`.
|
||||
|
||||
~~~lua
|
||||
local ok, err = exports["sky_phone"]:SendAppMessage(APP_ID, {
|
||||
type = "job:update",
|
||||
data = { available = true },
|
||||
})
|
||||
~~~
|
||||
|
||||
The `sky-phone-app:message` iframe envelope is documented below.
|
||||
|
||||
### Client custom-app notifications
|
||||
|
||||
The invoking resource must own the registered client app, and that definition must include `notifications`.
|
||||
|
||||
~~~lua
|
||||
local ok, err = exports["sky_phone"]:SendCustomAppNotification(APP_ID, {
|
||||
title = "My App", -- optional; defaults to the registered app name
|
||||
text = "The job is ready.",
|
||||
subtitle = "Dispatch",
|
||||
sound = "chime", -- chime, signal, or soft
|
||||
persistent = false,
|
||||
critical = false,
|
||||
route = "/apps/" .. APP_ID,
|
||||
})
|
||||
~~~
|
||||
|
||||
`text` and `content` are aliases. Title and subtitle allow up to 160 UTF-8 bytes; text allows up to 2000 UTF-8 bytes. If supplied, `route` must be exactly `/apps/APP_ID`. `critical = true` additionally requires `notifications.critical`.
|
||||
|
||||
### Server policy registration
|
||||
|
||||
Register a policy even if the client definition already lists permissions. Server-backed storage, server messages, and server notifications use the server-owned policy.
|
||||
|
||||
~~~lua
|
||||
local APP_ID = "my-resource-app"
|
||||
local PERMISSIONS = {
|
||||
"app.close",
|
||||
"app.open",
|
||||
"device.storage",
|
||||
"locale.read",
|
||||
"notifications",
|
||||
"theme.read",
|
||||
}
|
||||
|
||||
local registered = false
|
||||
|
||||
local function register_policy()
|
||||
if registered or not exports["sky_phone"]:IsApiReady() then
|
||||
return
|
||||
end
|
||||
|
||||
local ok, err = exports["sky_phone"]:AddCustomAppPolicy({
|
||||
schemaVersion = 1,
|
||||
id = APP_ID,
|
||||
permissions = PERMISSIONS,
|
||||
})
|
||||
if not ok then
|
||||
print(("[my_resource] AddCustomAppPolicy failed: %s"):format(err))
|
||||
return
|
||||
end
|
||||
registered = true
|
||||
end
|
||||
|
||||
CreateThread(register_policy)
|
||||
AddEventHandler("sky_phone:server:apiReady", register_policy)
|
||||
~~~
|
||||
|
||||
| Export | Signature | Result |
|
||||
| --- | --- | --- |
|
||||
| `AddCustomAppPolicy` | `(definition)` | `true` or `false, errorCode` |
|
||||
| `UpdateCustomAppPolicy` | `(definition)` | `true` or `false, errorCode` |
|
||||
| `RemoveCustomAppPolicy` | `(appId)` | `true` or `false, errorCode` |
|
||||
| `GetCustomAppPolicy` | `(appId)` | Policy snapshot or `nil` |
|
||||
| `HasCustomAppPermission` | `(appId, permission)` | Boolean |
|
||||
| `GetCustomAppCapabilities` | `()` | Custom-app server capabilities |
|
||||
|
||||
A policy snapshot is:
|
||||
|
||||
~~~lua
|
||||
{
|
||||
bundled = boolean,
|
||||
id = string,
|
||||
ownerResource = string,
|
||||
permissions = { "sorted", "permission.list" },
|
||||
}
|
||||
~~~
|
||||
|
||||
### Server-to-player messages
|
||||
|
||||
`SendAppMessage(playerSource, appId, payload)` is canonical. `SendCustomAppMessage(playerSource, appId, payload)` is its exact compatibility alias.
|
||||
|
||||
~~~lua
|
||||
local ok, err = exports["sky_phone"]:SendAppMessage(player_source, APP_ID, {
|
||||
type = "server:update",
|
||||
data = { status = "ready" },
|
||||
})
|
||||
~~~
|
||||
|
||||
The export validates the direct policy owner, player source, online player, JSON payload, and configured message-size limit. `true` means the server accepted and dispatched the event. It does not acknowledge iframe delivery. The target client still needs the matching owned app to be registered and active; otherwise it rejects the message locally.
|
||||
|
||||
### Server custom-app notifications
|
||||
|
||||
The server form is `SendCustomAppNotification(playerSource, appId, notification)`. It verifies that the invoking resource owns the policy and that the policy contains `notifications`.
|
||||
|
||||
~~~lua
|
||||
local ok, result_or_error =
|
||||
exports["sky_phone"]:SendCustomAppNotification(player_source, APP_ID, {
|
||||
title = "My App",
|
||||
text = "The job is ready.", -- content is also accepted
|
||||
})
|
||||
|
||||
if ok then
|
||||
print(("Delivered %d notification."):format(result_or_error.delivered))
|
||||
else
|
||||
print(("Notification failed: %s"):format(result_or_error))
|
||||
end
|
||||
~~~
|
||||
|
||||
The server form supports a required title (up to 160 UTF-8 bytes) and required `text` or `content` (up to 2000 UTF-8 bytes). It delivers only to an online player whose equipped device can be revalidated. Success is `true, { delivered = 1 }`. There is no offline queue. Use the client export for subtitle, sound, persistence, critical state, or an explicit app route.
|
||||
|
||||
## Sky iframe protocol v1
|
||||
|
||||
Use `bridgeMode = "sky"`. Sky Phone appends `skyPhoneAppId=APP_ID` to the iframe URL.
|
||||
|
||||
### Secure handshake
|
||||
|
||||
Install the message listener before announcing readiness. In production the parent NUI origin is `https://cfx-nui-sky_phone`. If you use a development host, inject its expected parent origin explicitly rather than accepting every origin.
|
||||
|
||||
~~~js
|
||||
const appId = new URL(window.location.href).searchParams.get("skyPhoneAppId");
|
||||
const protocolVersion = 1;
|
||||
const phoneOrigin = "https://cfx-nui-sky_phone";
|
||||
|
||||
function postToPhone(message) {
|
||||
window.parent.postMessage(
|
||||
{ ...message, appId, protocolVersion },
|
||||
phoneOrigin,
|
||||
);
|
||||
}
|
||||
|
||||
window.addEventListener("message", (event) => {
|
||||
if (event.source !== window.parent || event.origin !== phoneOrigin) return;
|
||||
const message = event.data;
|
||||
if (
|
||||
!message ||
|
||||
message.appId !== appId ||
|
||||
message.protocolVersion !== protocolVersion
|
||||
) return;
|
||||
|
||||
if (message.type === "sky-phone-app:context") {
|
||||
// Read only fields allowed by message.context.capabilities.
|
||||
} else if (message.type === "sky-phone-app:open") {
|
||||
// Handle message.data.
|
||||
} else if (message.type === "sky-phone-app:message") {
|
||||
// Handle message.payload.
|
||||
} else if (message.type === "sky-phone-app:response") {
|
||||
// Resolve the matching message.requestId.
|
||||
}
|
||||
});
|
||||
|
||||
postToPhone({ type: "sky-phone-app:ready" });
|
||||
~~~
|
||||
|
||||
Sky Phone validates the iframe window, exact iframe origin, app ID, and protocol version before accepting a message.
|
||||
|
||||
### Host-to-iframe messages
|
||||
|
||||
| Type | Payload |
|
||||
| --- | --- |
|
||||
| `sky-phone-app:context` | `{ appId, protocolVersion, context }` |
|
||||
| `sky-phone-app:open` | `{ appId, protocolVersion, data }` |
|
||||
| `sky-phone-app:message` | `{ appId, protocolVersion, payload }` |
|
||||
| `sky-phone-app:response` | `{ appId, protocolVersion, requestId, success, data?, error? }` |
|
||||
|
||||
The v1 context is:
|
||||
|
||||
~~~ts
|
||||
type SkyPhoneAppContextV1 = {
|
||||
appId: string;
|
||||
capabilities: string[];
|
||||
phoneScale: number;
|
||||
protocolVersion: 1;
|
||||
safeArea: { top: number; right: number; bottom: number; left: number };
|
||||
|
||||
colorScheme?: "dark" | "light"; // only with theme.read
|
||||
language?: string; // only with locale.read
|
||||
locale?: { name?: string; description?: string }; // only with locale.read
|
||||
};
|
||||
~~~
|
||||
|
||||
`colorScheme` is not present without `theme.read`. `language` and `locale` are not present without `locale.read`. Treat all permission-gated fields as optional and use the returned `capabilities` array as the authority.
|
||||
|
||||
### Iframe requests
|
||||
|
||||
A request has this envelope:
|
||||
|
||||
~~~js
|
||||
postToPhone({
|
||||
type: "sky-phone-app:request",
|
||||
requestId: crypto.randomUUID(),
|
||||
method: "device.storage.get",
|
||||
payload: { key: "preferences" },
|
||||
});
|
||||
~~~
|
||||
|
||||
`requestId` must be unique, non-empty, and at most 128 characters. `method` must be non-empty and at most 64 characters. Duplicate request IDs are ignored without another response.
|
||||
|
||||
| Method | Required permission | Request payload | Success data |
|
||||
| --- | --- | --- | --- |
|
||||
| `app.close` | `app.close` | Omit | None |
|
||||
| `app.open` | `app.open` | `{ appId, data? }` | `{ appId }` |
|
||||
| `device.storage.get` | `device.storage` | `{ key }` | `{ exists, revision, value? }` |
|
||||
| `device.storage.set` | `device.storage` | `{ key, revision, value }` | `{ revision }` |
|
||||
| `notification.create` | `notifications` | `{ title, text, subtitle?, sound? }` | `{ notificationId }` |
|
||||
|
||||
`app.open` accepts an app ID up to 64 characters. Optional `data` must be a JSON object and is supported only when the target is an external app; its absolute v1 limit is 16384 bytes.
|
||||
|
||||
Storage is scoped to the equipped device IMEI and app ID. Keys match `^[A-Za-z0-9._-]{1,64}$`. A get for a missing key returns `{ exists = false, revision = 0 }`. Set requires a non-null JSON value and the revision returned by the latest get/set. New keys use revision `0`; a successful insert returns `1`. A stale write returns:
|
||||
|
||||
~~~js
|
||||
{
|
||||
success: false,
|
||||
error: "storage_conflict",
|
||||
data: { exists: true, revision: 3, value: currentValue },
|
||||
}
|
||||
~~~
|
||||
|
||||
The absolute v1 value ceiling is 65536 bytes, but the server can configure a lower per-value limit, total app quota, key count, key length, and request rate. Read the client custom-app capabilities instead of hardcoding server policy.
|
||||
|
||||
Iframe notifications require non-empty `title` (maximum 80 characters) and `text` (maximum 240). Optional `subtitle` is at most 80. `sound` is `chime`, `signal`, or `soft`. The route is always the source app. This iframe method does not expose critical or persistent notifications.
|
||||
|
||||
Bridge JSON values are bounded to depth 8, 512 nodes, 128 entries per array/object, and 64 characters per object key. Functions, cyclic objects, non-finite numbers, and prototype-sensitive keys are rejected.
|
||||
|
||||
### Legacy mode
|
||||
|
||||
`bridgeMode = "legacy"` exists for provider compatibility. It becomes ready on iframe load, sends host-message payloads without the Sky v1 envelope, and does not provide the native handshake/context/request-response contract above. New first-party apps should always select `sky`.
|
||||
|
||||
## Server phone and device API
|
||||
|
||||
Server exports are for trusted server resources. Do not relay unrestricted device-directory results to clients.
|
||||
|
||||
### Phone identity
|
||||
|
||||
| Export | Signature | Result |
|
||||
| --- | --- | --- |
|
||||
| `GetEquippedPhoneNumber` | `(playerSourceOrIdentifier)` | Normalized number, `nil`, or `nil, "api_not_ready"` |
|
||||
| `GetSourceFromPhoneNumber` | `(phoneNumber)` | Online player source, `nil`, or `nil, "api_not_ready"` |
|
||||
|
||||
`GetEquippedPhoneNumber` accepts a positive numeric player source or a non-empty framework identifier. It revalidates the equipped device. `GetSourceFromPhoneNumber` normalizes the input and resolves only an online source currently equipped with that number.
|
||||
|
||||
### Device directory
|
||||
|
||||
| Export | Input | Scope |
|
||||
| --- | --- | --- |
|
||||
| `GetOnlineDeviceBySource` | player source | Online and currently equipped |
|
||||
| `GetOnlineDeviceByPhoneNumber` | phone number | Online and currently equipped |
|
||||
| `GetOnlineDeviceByIdentifier` | framework identifier | Online and currently equipped |
|
||||
| `GetOnlineDeviceByImei` | IMEI | Online and currently equipped |
|
||||
| `GetStoredDeviceByImei` | IMEI | Persistent device record |
|
||||
| `GetStoredDeviceByPhoneNumber` | phone number | Persistent device record |
|
||||
| `GetStoredDeviceByIdentifier` | framework identifier | Persistent character device in non-unique-phone mode only |
|
||||
| `GetStoredSimByPhoneNumber` | phone number | Persistent SIM record |
|
||||
|
||||
Device lookups return a snapshot or `nil, errorCode`:
|
||||
|
||||
~~~lua
|
||||
{
|
||||
accountId = number | nil,
|
||||
deviceName = string,
|
||||
equipped = boolean,
|
||||
imei = string,
|
||||
mappedIdentifier = string | nil,
|
||||
online = boolean,
|
||||
phoneNumber = string | nil,
|
||||
registeredIdentifier = string | nil,
|
||||
simId = string | nil,
|
||||
simType = string | nil,
|
||||
source = number | nil,
|
||||
|
||||
identifier = string | nil, -- added to applicable identifier/online results
|
||||
}
|
||||
~~~
|
||||
|
||||
Stored device results always report `online = false`, `equipped = false`, and `source = nil`. Online lookups revalidate framework identity and inventory ownership before returning. `GetStoredDeviceByIdentifier` returns `identity_scope_unsupported` when `Config.Phone.Unique` is enabled.
|
||||
|
||||
A SIM snapshot is:
|
||||
|
||||
~~~lua
|
||||
{
|
||||
deviceImei = string | nil,
|
||||
phoneNumber = string,
|
||||
registeredIdentifier = string | nil,
|
||||
simId = string,
|
||||
simType = string | nil,
|
||||
}
|
||||
~~~
|
||||
|
||||
Common directory errors include `invalid_source`, `invalid_identifier`, `invalid_imei`, `invalid_phone_number`, `player_unavailable`, `device_not_found`, `device_not_equipped`, `equipped_device_ambiguous`, `identifier_ambiguous`, `device_holder_ambiguous`, `identity_inconsistent`, `identity_scope_unsupported`, and `sim_not_found`.
|
||||
|
||||
## Server call API
|
||||
|
||||
| Export | Signature | Result |
|
||||
| --- | --- | --- |
|
||||
| `GetActiveCallBySource` | `(playerSource)` | Server call snapshot or `nil, errorCode` |
|
||||
| `GetActiveCallById` | `(callId)` | Server call snapshot or `nil, errorCode` |
|
||||
| `IsPlayerInCall` | `(playerSource)` | Boolean |
|
||||
| `EndCallForSource` | `(playerSource)` | `true` or `false, errorCode` |
|
||||
| `TerminateCallForSource` | `(playerSource)` | `true` or `false, errorCode` |
|
||||
|
||||
The server snapshot contains the client call fields plus:
|
||||
|
||||
~~~lua
|
||||
{
|
||||
anonymous = false,
|
||||
caller = { source = number, number = string },
|
||||
callee = { source = number | nil, number = string },
|
||||
companyId = string | nil,
|
||||
payphone = boolean,
|
||||
video = false,
|
||||
}
|
||||
~~~
|
||||
|
||||
`GetActiveCallBySource` calculates `direction` and `otherNumber` for that participant. `GetActiveCallById` uses the caller perspective. `EndCallForSource` preserves normal company rerouting/decline behavior; `TerminateCallForSource` force-finishes the call. Common errors are `invalid_source`, `invalid_call_id`, and `call_not_found`.
|
||||
|
||||
## Useful client events
|
||||
|
||||
These are local observation events. They are not authorization boundaries.
|
||||
|
||||
| Event | Payload |
|
||||
| --- | --- |
|
||||
| `sky_phone:client:apiReady` | `apiVersion` |
|
||||
| `sky_phone:client:phoneToggled` | `open` boolean |
|
||||
| `sky_phone:client:phoneNumberChanged` | normalized number or `nil` |
|
||||
| `sky_phone:client:callChanged` | call state table or `nil` |
|
||||
| `sky_phone:client:cameraActiveChanged` | `active` boolean |
|
||||
|
||||
The server readiness event is `sky_phone:server:apiReady` with `apiVersion`.
|
||||
|
||||
## Common custom-app errors
|
||||
|
||||
This list is intentionally not closed.
|
||||
|
||||
| Area | Common error codes |
|
||||
| --- | --- |
|
||||
| Readiness/config | `api_not_ready`, `external_apps_disabled` |
|
||||
| Caller/owner | `missing_invoking_resource`, `resource_required`, `owner_resource_not_running`, `app_owner_mismatch` |
|
||||
| Registration | `invalid_definition`, `unsupported_schema_version`, `invalid_app_id`, `reserved_app_id`, `duplicate_app_id`, `app_not_found`, `bundled_app` |
|
||||
| Definition | `invalid_name`, `invalid_description`, `invalid_developer`, `invalid_category`, `invalid_asset_url`, `asset_owner_mismatch`, `invalid_permissions`, `unknown_permission`, `duplicate_permission`, `invalid_orientation`, `invalid_bridge_mode` |
|
||||
| State/message | `phone_closed`, `app_not_active`, `invalid_payload`, `payload_too_large`, `message_queue_full` |
|
||||
| Notification | `permission_denied`, `invalid_notification`, `invalid_notification_title`, `invalid_notification_text`, `invalid_notification_subtitle`, `invalid_notification_sound`, `invalid_notification_route`, `invalid_title`, `invalid_text`, `invalid_app_id`, `device_not_equipped` |
|
||||
| Iframe request | `permission_denied`, `unsupported_method`, `invalid_storage_request`, `invalid_notification`, `invalid_app_open`, `app_not_found`, `open_data_not_supported`, `open_failed`, `request_failed` |
|
||||
| Storage | `storage_not_allowed`, `rate_limited`, `invalid_storage_key`, `invalid_storage_value`, `invalid_storage_revision`, `storage_value_too_large`, `storage_conflict`, `storage_quota_exceeded`, `storage_key_limit` |
|
||||
|
||||
## Trusted adapter API
|
||||
|
||||
This section is not the normal Creator API. It exists only for isolated phone-provider compatibility adapters.
|
||||
|
||||
Only a resource explicitly listed in `Config.CustomApps.TrustedAdapters` can call a `FromAdapter` export. The adapter passes the original owner resource explicitly; Sky Phone validates the adapter, the original owner, resource state, and the existing adapter/owner binding.
|
||||
|
||||
~~~lua
|
||||
Config.CustomApps.TrustedAdapters = {
|
||||
["my_phone_adapter"] = true,
|
||||
}
|
||||
~~~
|
||||
|
||||
Client-only trusted adapter exports:
|
||||
|
||||
- `AddCustomAppFromAdapter(ownerResource, definition)`
|
||||
- `UpdateCustomAppFromAdapter(ownerResource, definition)`
|
||||
- `RemoveCustomAppFromAdapter(ownerResource, appId)`
|
||||
- `OpenCustomAppFromAdapter(ownerResource, appId, payload?)`
|
||||
- `CloseCustomAppFromAdapter(ownerResource, appId)`
|
||||
- `CloseActiveCustomAppFromAdapter(ownerResource)`
|
||||
- `SendCustomAppMessageFromAdapter(ownerResource, appId, payload)`
|
||||
- `SendCustomAppNotificationFromAdapter(ownerResource, appId, notification)`
|
||||
|
||||
Server-only trusted adapter exports:
|
||||
|
||||
- `AddCustomAppPolicyFromAdapter(ownerResource, definition)`
|
||||
- `UpdateCustomAppPolicyFromAdapter(ownerResource, definition)`
|
||||
- `RemoveCustomAppPolicyFromAdapter(ownerResource, appId)`
|
||||
|
||||
There is currently no server `SendAppMessageFromAdapter` or `SendCustomAppNotificationFromAdapter` export. `assetResource` and `compatibility` are adapter mapping fields, not part of the native first-party creator schema.
|
||||
|
||||
Normal app resources must use the direct exports and must never pass an owner resource themselves. Provider aliases and their provider-specific signatures are compatibility contracts, not aliases for every native export documented here.
|
||||
@@ -29,7 +29,7 @@
|
||||
<p align="center">
|
||||
<a href="https://www.sky-systems.net/shop/phone#live-demo"><strong>Live demo</strong></a>
|
||||
•
|
||||
<a href="https://github.com/sky-systems/sky_phone/releases/latest"><strong>Download for free</strong></a>
|
||||
<a href="https://github.com/sky-systems/sky_phone"><strong>Download for free</strong></a>
|
||||
•
|
||||
<a href="https://discord.gg/sky-systems"><strong>Discord support</strong></a>
|
||||
</p>
|
||||
@@ -44,7 +44,7 @@ Sky Phone is a **free and open-source FiveM phone script** built to give serious
|
||||
|
||||
This is not a cut-down free alternative. Sky Phone includes the core experience server owners and players expect from a leading paid FiveM phone, plus full source access, no purchase price, no feature paywalls, and no forced ecosystem lock-in.
|
||||
|
||||
The production frontend is included in the published release package, so a normal server installation does not require Node.js or pnpm. GitHub's automatically generated source archives do not contain that build.
|
||||
The production frontend is included, so a normal server installation does not require Node.js or pnpm.
|
||||
|
||||
## Why Sky Phone stands out
|
||||
|
||||
@@ -86,7 +86,7 @@ Sky Phone is built to be the **free FiveM phone you can choose without accepting
|
||||
| Layer | Supported options |
|
||||
| --- | --- |
|
||||
| **Frameworks** | ESX Legacy, QBCore, Qbox |
|
||||
| **Inventories** | ak47_inventory, codem-inventory, core_inventory, jaksam_inventory, jpr-inventory, lj-inventory, mf-inventory, one_inventory, origen_inventory, ox_inventory, ps-inventory, qb-inventory, qs-inventory, smx-inventory, tgiann-inventory, hex_4_inventory, and native ESX inventory |
|
||||
| **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** | RTX Housing, Quasar Housing, VMS Housing, RX Housing, NoLag Properties, SN Properties, ESX Property, qbx_properties |
|
||||
@@ -121,33 +121,20 @@ Sky Phone is built to be the **free FiveM phone you can choose without accepting
|
||||
| **Framework** | ESX Legacy (`es_extended`), QBCore (`qb-core`), or Qbox (`qbx_core`) |
|
||||
| **Inventory** | Choose one supported adapter from the table below |
|
||||
|
||||
| Inventory (configuration value) | Metadata support | Unique Phones | Notes |
|
||||
| Inventory | Metadata support | Unique Phones | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| `jaksam_inventory` (`jaksam`) | Yes | Yes | Direct per-slot metadata; usable items are registered through jaksam_inventory |
|
||||
| `qs-inventory` (`qs`) | Yes | Yes | Full per-slot metadata |
|
||||
| `ps-inventory` (`ps`) | Yes | Yes | QBCore only; uses item `info` metadata |
|
||||
| `codem-inventory` (`codem`) | Yes | Yes | Full per-slot metadata |
|
||||
| `tgiann-inventory` (`tgiann`) | Yes | Yes | Per-slot metadata; item definitions must enable `hasMetadata` |
|
||||
| `core_inventory` (`core`) | Yes | Yes | Full per-slot metadata |
|
||||
| `jpr-inventory` (`jpr`) | Yes | Yes | QBCore only; uses item `info` metadata |
|
||||
| `origen_inventory` (`origen`) | Yes | Yes | Full per-slot metadata |
|
||||
| `ak47_inventory` (`ak47`) | Yes | Yes | Uses per-slot item `info` metadata |
|
||||
| `one_inventory` (`one`) | Yes | Yes | Full per-slot metadata |
|
||||
| `ox_inventory` (`ox`) | Yes | Yes | Full per-item phone and physical SIM metadata |
|
||||
| `mf-inventory` (`mf`) | Yes | Yes | ESX only |
|
||||
| `smx-inventory` (`smx`) | Yes | Yes | ESX only; one metadata record per configured item name through the player metadata bridge |
|
||||
| `lj-inventory` (`lj`) | Yes | Yes | QBCore inventory with item `info` metadata |
|
||||
| `qb-inventory` (`qb`) | Yes | Yes | Uses item `info` metadata |
|
||||
| `hex_4_inventory` (`hex`) | **No metadata support** | **No, Unique Phones are not possible** | ESX only; Sky Phone automatically disables unique phones and physical SIM cards |
|
||||
| Native ESX inventory (`esx`) | **No metadata support** | **No, Unique Phones are not possible** | Count-based items; Sky Phone automatically disables unique phones and physical SIM cards |
|
||||
| `ox_inventory` | Yes | Yes | Full per-item phone and physical SIM metadata |
|
||||
| `qb-inventory` | Yes | Yes | Uses item `info` metadata |
|
||||
| `lj-inventory` | Yes | Yes | QBCore inventory with item `info` metadata |
|
||||
| `qs-inventory` | Yes | Yes | Full per-slot metadata |
|
||||
| `codem-inventory` | Yes | Yes | Full per-slot metadata |
|
||||
| `core_inventory` | Yes | Yes | Full per-slot metadata |
|
||||
| `mf-inventory` | Yes | Yes | Supported with ESX |
|
||||
| `smx-inventory` | Yes | Yes | Supported with ESX through the player metadata bridge |
|
||||
| `hex_4_inventory` | **No metadata support** | **No, Unique Phones are not possible** | ESX only; set `Config.Phone.Unique = false` and `Config.Sim.Enabled = false` |
|
||||
| Native ESX inventory | **No metadata support** | **No, Unique Phones are not possible** | Count-based items; set `Config.Phone.Unique = false` and `Config.Sim.Enabled = false` |
|
||||
|
||||
`hex_4_inventory` and native ESX inventory cannot persist per-item metadata. Sky Phone therefore forces `Config.Phone.Unique` and `Config.Sim.Enabled` to `false` at runtime whenever either adapter is active.
|
||||
|
||||
`Config.Bridge.Inventory = "auto"` detects framework-compatible adapters in the table order. This deliberately matches the Sky inventory priority so a dedicated inventory is selected before a compatibility resource it may run beside. You may configure either the short value shown in parentheses or the exact resource name.
|
||||
|
||||
The adapters shared with `sky_base` are implemented locally inside Sky Phone. Installing or starting `sky_base` is not required; Sky Phone remains a standalone resource.
|
||||
|
||||
For configuration parity with `sky_base`, `qb-inv` is accepted as an alias for `qb`, while `qbox` selects the Qbox-native `ox_inventory` adapter.
|
||||
`hex_4_inventory` and native ESX inventory cannot persist per-item metadata. Unique Phones and physical SIM cards are therefore unavailable with these adapters.
|
||||
|
||||
### Voice
|
||||
|
||||
@@ -173,8 +160,8 @@ Start the selected voice resource before Sky Phone.
|
||||
|
||||
## Quick installation
|
||||
|
||||
1. Download and extract the latest published [Sky Phone release](https://github.com/sky-systems/sky_phone/releases/latest). Do not use GitHub's automatically generated "Source code" archives for a server installation because they do not contain the built frontend.
|
||||
2. Copy the included resource into your FiveM resources directory and keep its folder name `sky_phone`.
|
||||
1. Copy the resource into your FiveM resources directory.
|
||||
2. Keep the resource folder name `sky_phone`.
|
||||
3. Start `oxmysql`, your framework, inventory, and voice resource before Sky Phone.
|
||||
4. Review `sky_phone/config/config.lua` and `sky_phone/config/media.lua`.
|
||||
5. Add the required inventory items.
|
||||
@@ -195,13 +182,6 @@ Replace the example framework, inventory, and voice resources with the providers
|
||||
|
||||
Sky Phone creates and upgrades its database tables automatically. A manual SQL import is normally not required.
|
||||
|
||||
### How players open the phone
|
||||
|
||||
- Give the player the item configured in `Config.Phone.Item` (default: `phone`).
|
||||
- Players can use that inventory item or press the configured keybind (default: `F1`).
|
||||
- The keybind still verifies server-side that the player owns a configured phone item; it does not bypass inventory ownership.
|
||||
- A SIM card is **not required to open or use the phone itself**. With `Config.Sim.Enabled = true`, only cellular features such as calls and messages require an inserted SIM.
|
||||
|
||||
## Configuration
|
||||
|
||||
Customer settings are organized in:
|
||||
@@ -216,7 +196,6 @@ The files contain clearly separated sections for:
|
||||
| Section | Purpose |
|
||||
| --- | --- |
|
||||
| `Config.Bridge` | Framework, inventory, language, callback timeout, and debug mode |
|
||||
| `Config.CommandPermissions` | Fixed groups for the admin panel, test data, verification commands, and social moderation |
|
||||
| `Config.Phone` | Phone item, movement, unique-device mode, and development command |
|
||||
| `Config.Sim` | Physical or virtual SIM behavior and number formatting |
|
||||
| `Config.Calls` / `Config.Radio` | Voice providers, call behavior, radio limits, and permissions |
|
||||
@@ -232,44 +211,6 @@ The files contain clearly separated sections for:
|
||||
|
||||
Restart `sky_phone` after changing Lua configuration.
|
||||
|
||||
### In-game phone configurator
|
||||
|
||||
Set the switch at the beginning of `config/config.lua` to use SQL-backed configuration:
|
||||
|
||||
```lua
|
||||
Config.PhoneConfigurator = {
|
||||
Enabled = true,
|
||||
}
|
||||
```
|
||||
|
||||
When enabled, the generated `source/shared/config_default.lua` is the shipped first-run baseline.
|
||||
The frontend build recreates this file from `config.lua` and the server-only `media.lua`; do not edit
|
||||
the generated snapshot directly. Sky Phone creates the `sky_phone_configurator` table automatically,
|
||||
loads its saved values before framework and phone
|
||||
modules initialize, and exposes the editor through `/phonepanel`. Nothing autosaves: stage changes
|
||||
in the Phone Configurator tool and press the green check. Saving verifies both SQL payloads and then
|
||||
applies the new server, client, media, app, item, command, provider, animation, and UI values through
|
||||
Sky Phone's internal runtime refresh. It does not execute a resource restart command.
|
||||
|
||||
Every configurable `Config.*` value from `config.lua`, including server-only sections, and every
|
||||
value from `Config.Media` is discovered automatically. The bootstrap switch and
|
||||
`Config.CommandPermissions` intentionally remain file-owned: the switch decides whether SQL
|
||||
configuration is loaded, while permissions must remain authoritative outside the panel. The fixed
|
||||
permission table is never displayed or overwritten by the Phone Configurator, and its stable keys do
|
||||
not change when their commands are renamed in the panel. Lists, nested objects, vectors,
|
||||
and numeric-keyed Lua tables use structured editors instead of raw JSON. Shipped schema rows stay
|
||||
editable but cannot be renamed, converted, or removed. Every list and table still accepts any number
|
||||
of additional rows; administrator-added rows remain removable. Company job keys are intentionally
|
||||
fully removable because `Config.Companies.Definitions` is a freely managed job collection.
|
||||
|
||||
ESX and QBCore use the groups listed in `Config.CommandPermissions`. Qbox checks the configured ACE
|
||||
objects first and then its framework groups. The standard Qbox `permissions.cfg` grants the `admin`
|
||||
ACE to `group.admin`, so an identifier assigned to `group.admin` can open `/phonepanel` with the
|
||||
shipped `phonepanel` permission list. Restart `sky_phone` after changing fixed permissions.
|
||||
|
||||
Media API keys and server peppers are never returned in plaintext to the NUI. Existing secrets are
|
||||
shown only as configured and are replaced only when an administrator enters a new value.
|
||||
|
||||
### Language
|
||||
|
||||
Available locales:
|
||||
@@ -374,9 +315,9 @@ Default entries for unique phones with physical SIM cards:
|
||||
|
||||
Do not configure an LB Phone client event or client export. Sky Phone registers the usable items through its server-side inventory adapter.
|
||||
|
||||
The server registers `Config.Phone.Item` as usable for every supported inventory adapter: `ak47`, `codem`, `core`, `jaksam`, `jpr`, `lj`, `mf`, `one`, `origen`, `ox`, `ps`, `qb`, `qs`, `smx`, `tgiann`, `hex`, and `esx`. Resource startup fails visibly if the selected adapter or its resource is unavailable.
|
||||
The server registers `Config.Phone.Item` as usable for every supported inventory adapter: `ox`, `qb`, `lj`, `qs`, `codem`, `core`, `mf`, `smx`, `hex`, and `esx`. Resource startup fails visibly if the selected adapter cannot complete that registration.
|
||||
|
||||
The `hex` and `esx` adapters use ESX's count-based item API, which cannot persist per-item phone or physical SIM metadata. Sky Phone automatically forces `Config.Phone.Unique = false` and `Config.Sim.Enabled = false` while either adapter is active. `auto` selects `hex` when `hex_4_inventory` is started and otherwise falls back to `esx` on an ESX server when no metadata-capable inventory is detected.
|
||||
The `hex` and `esx` adapters use ESX's count-based item API. They require both `Config.Phone.Unique = false` and `Config.Sim.Enabled = false` because this API cannot persist per-item phone or physical SIM metadata. `auto` selects `hex` when `hex_4_inventory` is started and otherwise falls back to `esx` on an ESX server when no metadata-capable inventory is detected.
|
||||
|
||||
### QBCore-style item tables
|
||||
|
||||
@@ -385,67 +326,6 @@ The `hex` and `esx` adapters use ESX's count-based item API, which cannot persis
|
||||
- Physical SIM items must always be unique.
|
||||
- SIM items are not required when `Config.Sim.Enabled = false`.
|
||||
|
||||
Example for `qb-inventory`, `lj-inventory`, `ps-inventory`, and `jpr-inventory`:
|
||||
|
||||
```lua
|
||||
phone = {
|
||||
name = "phone",
|
||||
label = "iFruit Phone",
|
||||
weight = 200,
|
||||
type = "item",
|
||||
image = "phone.png",
|
||||
unique = true,
|
||||
useable = true,
|
||||
shouldClose = true,
|
||||
description = "A personal mobile phone",
|
||||
},
|
||||
|
||||
sky_phone_sim_registered = {
|
||||
name = "sky_phone_sim_registered",
|
||||
label = "Registered SIM",
|
||||
weight = 5,
|
||||
type = "item",
|
||||
image = "sky_phone_sim_registered.png",
|
||||
unique = true,
|
||||
useable = true,
|
||||
shouldClose = true,
|
||||
},
|
||||
|
||||
sky_phone_sim_anonymous = {
|
||||
name = "sky_phone_sim_anonymous",
|
||||
label = "Anonymous SIM",
|
||||
weight = 5,
|
||||
type = "item",
|
||||
image = "sky_phone_sim_anonymous.png",
|
||||
unique = true,
|
||||
useable = true,
|
||||
shouldClose = true,
|
||||
},
|
||||
```
|
||||
|
||||
For `tgiann-inventory`, set `hasMetadata = true`, `useable = true`, and `shouldClose = true` on all three item definitions. Follow the inventory's own item schema for the remaining adapters; the required behavior is always the same: a unique phone or physical SIM must occupy its own slot and its metadata table must survive moving, dropping, storing, and trading the item.
|
||||
|
||||
### Unique Phones and metadata
|
||||
|
||||
Sky Phone owns the metadata values and writes them server-side. Do not pre-generate IMEIs or phone numbers in item definitions:
|
||||
|
||||
| Item | Metadata written by Sky Phone |
|
||||
| --- | --- |
|
||||
| Phone | `imei`; when a SIM is inserted, also `sim_id`, `phone_number`, and `formatted_number` |
|
||||
| Physical SIM | `sim_metadata_version`, `sim_id`, `phone_number`, `formatted_number`, `sim_type`, and registration details where applicable |
|
||||
|
||||
When a metadata-capable phone item is used for the first time, Sky Phone reserves an IMEI and writes it back to that exact slot. Existing metadata is preserved. The adapter then reads the slot again and rejects the operation if the inventory did not persist the requested values.
|
||||
|
||||
For reliable Unique Phones:
|
||||
|
||||
- Set `Config.Phone.Unique = true`.
|
||||
- Make the phone item non-stackable/unique. Every phone slot must contain exactly one item.
|
||||
- If `Config.Sim.Enabled = true`, make both physical SIM items non-stackable/unique and metadata-capable too.
|
||||
- Do not use inventory conversion, admin, crafting, or shop scripts that strip item metadata. Copying an item with its metadata also copies its IMEI; duplicated IMEIs are reported in the server console.
|
||||
- When changing inventory systems, migrate the complete item metadata table. Without the old `imei`, the next use creates a new device identity and does not automatically attach the old handset data.
|
||||
|
||||
With `Config.Phone.Unique = false`, the handset identity is stored once per framework character instead of on each phone item. The phone item may stack. Physical SIMs still require per-item metadata, so `Config.Sim.Enabled` must be `false` on `hex` and native `esx`.
|
||||
|
||||
## Phone and SIM modes
|
||||
|
||||
The two mode switches are independent:
|
||||
@@ -464,7 +344,7 @@ With unique phones, using an inventory item selects that exact handset whenever
|
||||
|
||||
| SIM mode | Behavior |
|
||||
| --- | --- |
|
||||
| `Enabled = true` | The phone opens with or without a SIM. A registered or anonymous physical SIM item is required only for cellular service such as calls and messages. |
|
||||
| `Enabled = true` | A registered or anonymous physical SIM item is required for cellular service. |
|
||||
| `Enabled = false` | Sky Phone creates a persistent automatic number for devices without a SIM. Physical SIM items are not required. |
|
||||
|
||||
When changing these modes on an existing production server, restart the resource and test with a copy of the database first. The first phone used after switching to non-unique mode may adopt an existing valid IMEI so its local data is preserved.
|
||||
@@ -654,11 +534,7 @@ Select `rtx`, `quasar`, `vms`, `rx`, `nolag`, `sn`, `esx_property`, or `qbx_prop
|
||||
|
||||
### Companies
|
||||
|
||||
Company jobs, public profiles, service numbers, services, permissions, locations, and default
|
||||
availability are configured under `Config.Companies.Definitions`. Definitions are not limited to the
|
||||
shipped jobs: add any number of company IDs in the in-game configurator and fill the freely
|
||||
configurable `Job` value in the automatically generated full company template. Existing job keys can
|
||||
also be removed; the remaining Companies settings stay available as normal individual fields.
|
||||
Company jobs, public profiles, service numbers, services, permissions, locations, and default availability are configured under `Config.Companies.Definitions`.
|
||||
|
||||
### Weazel News
|
||||
|
||||
@@ -677,8 +553,6 @@ Unlisted jobs can read news but cannot manage articles.
|
||||
|
||||
Sky Phone is not limited to the apps that ship with it. Other resources can register installable custom apps, publish them through the App Store, exchange messages with their NUI, send notifications, and use server-controlled permissions and storage.
|
||||
|
||||
For the complete first-party export, ownership, readiness, and iframe protocol contract, see the [Creator API](CREATOR_API.md).
|
||||
|
||||
Its native custom app surface includes client and server exports for app registration, lifecycle control, messaging, notifications, capability discovery, and policy management. Sky Phone also normalizes supported custom-app contracts from:
|
||||
|
||||
- LB Phone
|
||||
@@ -691,8 +565,6 @@ The resource provides the compatibility aliases `lb-phone`, `17mov_Phone`, `high
|
||||
|
||||
That means servers can replace LB Phone without giving up supported custom apps, while developers can build directly against Sky Phone for deeper lifecycle, permission, and storage integration.
|
||||
|
||||
Start Sky Phone before the custom app resources and do not start the original phone resource for an alias at the same time. For example, an unchanged app using `exports["lb-phone"]:AddCustomApp(...)` must run with `sky_phone`, not with the original `lb-phone`, as the active provider. Two active providers expose the same FiveM export event and can send registrations to the wrong phone.
|
||||
|
||||
## Frontend development
|
||||
|
||||
Customers installing a release do not need to build the frontend.
|
||||
@@ -726,20 +598,12 @@ pnpm build
|
||||
|
||||
### The phone item does nothing
|
||||
|
||||
- A warning that the inventory returned no configured phone item means an item definition, `Config.Phone.Item`, inventory selection, or player ownership problem. It is not caused by a missing SIM card.
|
||||
- Confirm the framework and inventory are supported and started first.
|
||||
- Confirm the item name matches `Config.Phone.Item`.
|
||||
- Confirm the item is usable.
|
||||
- In unique mode, confirm the phone is non-stackable.
|
||||
- Check the server console for inventory adapter warnings.
|
||||
|
||||
### The resource starts but the phone UI is missing
|
||||
|
||||
- On startup, the server console prints `SKY PHONE UI BUILD IS MISSING OR INCOMPLETE`, lists the missing or invalid packaged files, and shows repository-native build commands.
|
||||
- Install the latest published release package rather than GitHub's automatically generated source archive.
|
||||
- Confirm `sky_phone/source/html/index.html`, `assets`, `img`, and `sounds` exist.
|
||||
- Developers working from source must run the frontend production build before starting the resource.
|
||||
|
||||
### Calls connect without audio
|
||||
|
||||
- Confirm the configured voice resource is running.
|
||||
|
||||
@@ -26,100 +26,6 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
## Inter 4.1
|
||||
|
||||
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION AND CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER
|
||||
DEALINGS IN THE FONT SOFTWARE.
|
||||
|
||||
## Framework7 documentation placeholder media
|
||||
|
||||
The development-only Sky UI Kitchen Sink includes placeholder images mirrored
|
||||
|
||||
+5
-40
@@ -2,49 +2,15 @@ const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
|
||||
const frontendRoot = __dirname
|
||||
const resourceDirectory = path.join(frontendRoot, '..', 'sky_phone')
|
||||
const sourceDirectory = path.join(frontendRoot, 'dist')
|
||||
const targetDirectory = path.join(resourceDirectory, 'source', 'html')
|
||||
const configDefaultPath = path.join(
|
||||
resourceDirectory,
|
||||
const targetDirectory = path.join(
|
||||
frontendRoot,
|
||||
'..',
|
||||
'sky_phone',
|
||||
'source',
|
||||
'shared',
|
||||
'config_default.lua',
|
||||
'html',
|
||||
)
|
||||
|
||||
function readLuaSource(...segments) {
|
||||
return fs
|
||||
.readFileSync(path.join(resourceDirectory, ...segments), 'utf8')
|
||||
.replace(/\r\n?/g, '\n')
|
||||
.trimEnd()
|
||||
}
|
||||
|
||||
function generateConfigDefault() {
|
||||
const configSource = readLuaSource('config', 'config.lua').replace(
|
||||
/\n?-- CONFIG_DEFAULT_EXCLUDE_START[\s\S]*?-- CONFIG_DEFAULT_EXCLUDE_END\n?/g,
|
||||
'\n',
|
||||
)
|
||||
const mediaSource = readLuaSource('config', 'media.lua')
|
||||
const generatedSource = [
|
||||
'-- GENERATED by frontend/build.cjs from config/config.lua and config/media.lua - do not edit.',
|
||||
'-- Escrowed snapshot of the shipped defaults used by the Phone Configurator.',
|
||||
'local realConfig = Config',
|
||||
'Config = {}',
|
||||
'',
|
||||
configSource,
|
||||
'',
|
||||
mediaSource,
|
||||
'',
|
||||
'ConfigDefaults = Config',
|
||||
'Config = realConfig',
|
||||
'',
|
||||
].join('\n')
|
||||
|
||||
fs.mkdirSync(path.dirname(configDefaultPath), { recursive: true })
|
||||
fs.writeFileSync(configDefaultPath, generatedSource, 'utf8')
|
||||
console.log(`Generated Phone Configurator defaults at ${configDefaultPath}`)
|
||||
}
|
||||
|
||||
if (!fs.existsSync(sourceDirectory)) {
|
||||
throw new Error(
|
||||
'Missing frontend/dist. Run vite build before publishing the NUI.',
|
||||
@@ -62,5 +28,4 @@ const normalizedIndex = fs
|
||||
.replace(/\n[ \t]*\n([ \t]*<\/body>)/g, '\n$1')
|
||||
fs.writeFileSync(targetIndex, normalizedIndex)
|
||||
|
||||
generateConfigDefault()
|
||||
console.log(`Published NUI to ${targetDirectory}`)
|
||||
|
||||
+13
-190
@@ -11,7 +11,6 @@ import {
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { SkyProvider } from '@/ui'
|
||||
import AdminPanel from '@/components/AdminPanel.vue'
|
||||
import PhoneHomeIndicator from '@/components/PhoneHomeIndicator.vue'
|
||||
import PhoneControlCenter from '@/components/PhoneControlCenter.vue'
|
||||
import PhoneDynamicIsland from '@/components/PhoneDynamicIsland.vue'
|
||||
@@ -52,12 +51,11 @@ import { useMarketplaceStore } from '@/stores/marketplace'
|
||||
import { useAppCatalogStore } from '@/stores/app-catalog'
|
||||
import { useAppStoreStore } from '@/stores/app-store'
|
||||
import { useWidgetsStore } from '@/stores/widgets'
|
||||
import { isPhoneAppId, PHONE_APPS } from '@/config/apps'
|
||||
import { isPhoneAppId } from '@/config/apps'
|
||||
import { useNotesStore } from '@/stores/notes'
|
||||
import { useMemosStore } from '@/stores/memos'
|
||||
import { useWeatherStore } from '@/stores/weather'
|
||||
import { useEasyShareStore } from '@/stores/easyshare'
|
||||
import { useRadioStore } from '@/stores/radio'
|
||||
import {
|
||||
useNotificationsStore,
|
||||
type PhoneNotification,
|
||||
@@ -71,26 +69,19 @@ import type {
|
||||
CompanyChangedPayload,
|
||||
CompanyUnreadCounts,
|
||||
} from '@/types/companies'
|
||||
import type { PhoneCall, PhoneNumberFormat } from '@/types/phone'
|
||||
import type { DynamicIslandActivity } from '@/types/dynamicIsland'
|
||||
import type { PhoneCall } from '@/types/phone'
|
||||
import type { EasyShareEvent } from '@/types/easyshare'
|
||||
import type { CryptoMarketChangedData } from '@/types/crypto'
|
||||
import type { CityWarnEventData } from '@/types/citywarn'
|
||||
import { nuiCall } from '@/utils/nui'
|
||||
import {
|
||||
installPhoneAudioController,
|
||||
setPhoneOutputVolume,
|
||||
} from '@/utils/phoneAudio'
|
||||
import { formatTimer } from '@/utils/clock'
|
||||
import { parsePhonePreferences } from '@/utils/preferences'
|
||||
import { getHairlinePixelStyle } from '@/utils/rendering'
|
||||
import { isTextInputElement } from '@/utils/textInputFocus'
|
||||
import { configurePhoneNumberFormat } from '@/utils/phone'
|
||||
import { isTrustedRootMessageSource } from '@/utils/windowMessages'
|
||||
import SpringboardView from '@/views/SpringboardView.vue'
|
||||
|
||||
type AppMessage = {
|
||||
openHome?: boolean
|
||||
type?: string
|
||||
data?:
|
||||
| CalendarReminderData
|
||||
@@ -115,14 +106,8 @@ type AppMessage = {
|
||||
| PhoneOpenPayload
|
||||
| CustomAppCatalogEventData
|
||||
| CustomAppEventData
|
||||
| NavigationEventData
|
||||
| AdminPanelOpenPayload
|
||||
}
|
||||
|
||||
type AdminPanelOpenPayload = Required<
|
||||
Pick<PhoneOpenPayload, 'fallbackLocales' | 'lang' | 'locales'>
|
||||
>
|
||||
|
||||
type CustomAppCatalogEventData = {
|
||||
apps?: unknown
|
||||
}
|
||||
@@ -133,14 +118,9 @@ type CustomAppEventData = {
|
||||
payload?: unknown
|
||||
}
|
||||
|
||||
type NavigationEventData = {
|
||||
appId?: unknown
|
||||
}
|
||||
|
||||
type SimPickerPayload = {
|
||||
choices: SimPhoneChoice[]
|
||||
number: string
|
||||
phoneNumberFormat?: PhoneNumberFormat
|
||||
}
|
||||
|
||||
type NotificationEventData = Omit<PhoneNotificationInput, 'device'> & {
|
||||
@@ -328,7 +308,6 @@ const notes = useNotesStore()
|
||||
const memos = useMemosStore()
|
||||
const weather = useWeatherStore()
|
||||
const easyShare = useEasyShareStore()
|
||||
const radio = useRadioStore()
|
||||
const notifications = useNotificationsStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -351,21 +330,13 @@ const DARK_STATUS_BAR_APP_IDS = new Set([
|
||||
'minesweeper',
|
||||
'number-merge',
|
||||
])
|
||||
const isDynamicIslandGalleryRoute = computed(
|
||||
() => isDevelopment && route.name === 'development-dynamic-islands',
|
||||
)
|
||||
const isDevelopmentRoute = computed(
|
||||
() =>
|
||||
isDevelopment &&
|
||||
(route.name === 'development-sky-ui' || isDynamicIslandGalleryRoute.value),
|
||||
() => isDevelopment && route.name === 'development-sky-ui',
|
||||
)
|
||||
const appTransitionName = computed(() =>
|
||||
route.query.transition === 'app-switch' ? 'app-switch' : 'app-window',
|
||||
)
|
||||
const isLocked = ref(false)
|
||||
const adminPanelOpen = ref(
|
||||
isDevelopment && developmentParameters.has('adminPanel'),
|
||||
)
|
||||
const springboardEditing = ref(false)
|
||||
const isUnlocking = ref(false)
|
||||
const passcodeBusy = ref(false)
|
||||
@@ -380,12 +351,9 @@ const setupPreviewDismissed = ref(false)
|
||||
const setupDevelopmentSkipped = ref(false)
|
||||
const setupAppearanceSelected = ref(false)
|
||||
const pendingUnlockRoute = ref<string | null>(null)
|
||||
const openHomeRequested = ref(false)
|
||||
const unlockedServicesLoaded = ref(false)
|
||||
const controlCenterOpened = ref(false)
|
||||
const activitySuspended = ref(false)
|
||||
const dynamicIslandExpanded = ref(false)
|
||||
const dynamicIslandActivity = ref<DynamicIslandActivity | null>(null)
|
||||
const simPicker = ref<SimPickerPayload | null>(null)
|
||||
const setupRequired = computed(
|
||||
() =>
|
||||
@@ -459,12 +427,6 @@ const phoneResolutionStyle = computed<CSSProperties>(() => ({
|
||||
}))
|
||||
const phoneStageStyle = computed<CSSProperties>(() => ({
|
||||
...phoneResolutionStyle.value,
|
||||
'--phone-live-activity-peek-height':
|
||||
dynamicIslandActivity.value === 'music'
|
||||
? '190px'
|
||||
: dynamicIslandActivity.value === 'recording'
|
||||
? '132px'
|
||||
: '112px',
|
||||
visibility: activitySuspended.value ? 'hidden' : 'visible',
|
||||
}))
|
||||
const phoneDisplayStyle = computed<CSSProperties>(() => ({
|
||||
@@ -482,7 +444,6 @@ let unlockTimer: number | undefined
|
||||
let passcodeLockTimer: number | undefined
|
||||
let hardwareVolumeHudTimer: number | undefined
|
||||
let unlockedServicesIdle: number | undefined
|
||||
let removePhoneAudioController: (() => void) | undefined
|
||||
let phoneClosePending = false
|
||||
let simPickerClosePending = false
|
||||
|
||||
@@ -502,7 +463,6 @@ function getViewportScale(): number {
|
||||
}
|
||||
|
||||
function hydratePhone(payload: PhoneOpenPayload): void {
|
||||
configurePhoneNumberFormat(payload.phoneNumberFormat)
|
||||
if (payload.device?.imei) {
|
||||
companies.bindDeviceScope(
|
||||
payload.device.imei,
|
||||
@@ -530,34 +490,8 @@ function hydratePhone(payload: PhoneOpenPayload): void {
|
||||
clock.hydrate(payload.device?.data.alarms?.payload)
|
||||
games.hydrate(payload.device?.data.games?.payload)
|
||||
media.hydrate(payload.device?.data.media?.payload)
|
||||
appStore.hydrate(payload.device?.data.apps?.payload, payload.disabledApps)
|
||||
appStore.hydrate(payload.device?.data.apps?.payload)
|
||||
widgets.hydrate(payload.device?.data.widgets?.payload)
|
||||
|
||||
const currentAppId = route.params.appId
|
||||
if (
|
||||
typeof currentAppId === 'string' &&
|
||||
isPhoneAppId(currentAppId) &&
|
||||
!appStore.isInstalled(currentAppId)
|
||||
) {
|
||||
void router.push('/')
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -648,8 +582,6 @@ function loadUnlockedPhoneData(): void {
|
||||
}
|
||||
|
||||
function completePhoneSetup(): void {
|
||||
const requestedRoute = pendingUnlockRoute.value
|
||||
pendingUnlockRoute.value = null
|
||||
setupPreviewDismissed.value = true
|
||||
setupAppearanceSelected.value = false
|
||||
isLocked.value = false
|
||||
@@ -657,7 +589,7 @@ function completePhoneSetup(): void {
|
||||
passcodeVisible.value = false
|
||||
passcodeRequired.value = false
|
||||
controlCenterOpened.value = false
|
||||
void router.replace(requestedRoute ?? '/')
|
||||
void router.replace('/')
|
||||
loadUnlockedPhoneData()
|
||||
}
|
||||
|
||||
@@ -741,15 +673,7 @@ function openDevelopmentPayphonePreview(): void {
|
||||
function onMessage(event: MessageEvent<AppMessage>): void {
|
||||
if (!isTrustedRootMessageSource(event.source, window)) return
|
||||
|
||||
if (event.data?.type === 'admin:open') {
|
||||
const data = event.data.data as AdminPanelOpenPayload | undefined
|
||||
if (data?.lang && data.locales && data.fallbackLocales) {
|
||||
phone.setLocale(data.lang, data.locales, data.fallbackLocales)
|
||||
}
|
||||
adminPanelOpen.value = true
|
||||
} else if (event.data?.type === 'admin:close') {
|
||||
adminPanelOpen.value = false
|
||||
} else if (event.data?.type === 'custom-apps:catalog') {
|
||||
if (event.data?.type === 'custom-apps:catalog') {
|
||||
appCatalog.replaceCatalog(event.data.data)
|
||||
const catalogPayload = event.data.data as
|
||||
| { apps?: unknown; debug?: unknown }
|
||||
@@ -787,39 +711,9 @@ function onMessage(event: MessageEvent<AppMessage>): void {
|
||||
if (typeof data?.appId === 'string' && route.params.appId === data.appId) {
|
||||
void router.push('/')
|
||||
}
|
||||
} else if (event.data?.type === 'navigation:open-app') {
|
||||
const data = event.data.data as NavigationEventData | undefined
|
||||
if (
|
||||
typeof data?.appId === 'string' &&
|
||||
isPhoneAppId(data.appId) &&
|
||||
appStore.isInstalled(data.appId)
|
||||
) {
|
||||
const requestedRoute = `/apps/${data.appId}`
|
||||
if (setupRequired.value || isLocked.value) {
|
||||
pendingUnlockRoute.value = requestedRoute
|
||||
} else {
|
||||
void router.push(requestedRoute)
|
||||
}
|
||||
} 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') {
|
||||
openHomeRequested.value = event.data.openHome === true
|
||||
hydratePhone(event.data.data as PhoneOpenPayload)
|
||||
void syncNavigationState().then(() => nuiCall('ui:opened'))
|
||||
void nuiCall('ui:opened')
|
||||
} else if (event.data?.type === 'device:updated') {
|
||||
hydratePhone(event.data.data as PhoneOpenPayload)
|
||||
} else if (event.data?.type === 'app:close') {
|
||||
@@ -1258,9 +1152,7 @@ function onMessage(event: MessageEvent<AppMessage>): void {
|
||||
loadUnlockedPhoneData()
|
||||
}
|
||||
} else if (event.data?.type === 'sim:picker' && event.data.data) {
|
||||
const payload = event.data.data as unknown as SimPickerPayload
|
||||
configurePhoneNumberFormat(payload.phoneNumberFormat)
|
||||
simPicker.value = payload
|
||||
simPicker.value = event.data.data as unknown as SimPickerPayload
|
||||
} else if (event.data?.type === 'sim:picker-close') {
|
||||
simPicker.value = null
|
||||
}
|
||||
@@ -1535,7 +1427,6 @@ function onFocusOut(event: FocusEvent): void {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
removePhoneAudioController = installPhoneAudioController()
|
||||
document.addEventListener('focusin', onFocusIn)
|
||||
document.addEventListener('focusout', onFocusOut)
|
||||
window.addEventListener('message', onMessage)
|
||||
@@ -1631,18 +1522,6 @@ watch(
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => ({
|
||||
appIds: getInstalledNavigationAppIds(),
|
||||
currentApp: activeAppId.value,
|
||||
open: phone.isOpen,
|
||||
}),
|
||||
() => {
|
||||
if (phone.isOpen && appStore.hydrated) void syncNavigationState()
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => notifications.requiresAttention,
|
||||
(requiresAttention) => {
|
||||
@@ -1650,23 +1529,6 @@ watch(
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => Boolean(dynamicIslandActivity.value || calls.activeCall),
|
||||
(active) => {
|
||||
void nuiCall('ui:live-activity', { active })
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
hardwareAlertVolume,
|
||||
(volume) => {
|
||||
setPhoneOutputVolume(volume / 100)
|
||||
if (radio.data.connected) void radio.setVolume(volume)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => phone.isOpen,
|
||||
(isOpen) => {
|
||||
@@ -1699,8 +1561,7 @@ watch(
|
||||
}
|
||||
isLocked.value = setupRequired.value
|
||||
? false
|
||||
: developmentLockScreenPreview ||
|
||||
(!isDevelopment && phone.security.enabled)
|
||||
: !isDevelopment || developmentLockScreenPreview
|
||||
passcodeRequired.value = isLocked.value && phone.security.enabled
|
||||
unlockedServicesLoaded.value = false
|
||||
controlCenterOpened.value = false
|
||||
@@ -1718,18 +1579,8 @@ watch(
|
||||
startPasscodeLock(passcodeRetrySeconds.value)
|
||||
}
|
||||
phone.setLaunchOrigin(null)
|
||||
if (setupRequired.value) {
|
||||
void router.replace('/')
|
||||
} else if (openHomeRequested.value) {
|
||||
openHomeRequested.value = false
|
||||
if (isLocked.value) pendingUnlockRoute.value = '/'
|
||||
else {
|
||||
void router.replace('/')
|
||||
loadUnlockedPhoneData()
|
||||
}
|
||||
} else if (!isLocked.value) {
|
||||
loadUnlockedPhoneData()
|
||||
}
|
||||
if (isLocked.value || setupRequired.value) void router.replace('/')
|
||||
else loadUnlockedPhoneData()
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1741,7 +1592,6 @@ watch(
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
removePhoneAudioController?.()
|
||||
updateTextInputFocus(false)
|
||||
cancelUnlockedPhoneDataLoad()
|
||||
weather.stop()
|
||||
@@ -1764,15 +1614,6 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SkyProvider
|
||||
v-if="adminPanelOpen"
|
||||
dark
|
||||
:safe-areas="false"
|
||||
accent="#74d66f"
|
||||
accent-soft="rgba(116, 214, 111, 0.14)"
|
||||
>
|
||||
<AdminPanel @close="adminPanelOpen = false" />
|
||||
</SkyProvider>
|
||||
<PhoneMediaCapture />
|
||||
<PhoneMemoRecorder />
|
||||
<RadioHud />
|
||||
@@ -1789,15 +1630,12 @@ onBeforeUnmount(() => {
|
||||
phone.isOpen ||
|
||||
notifications.current ||
|
||||
calls.activeCall ||
|
||||
dynamicIslandActivity ||
|
||||
notifications.devicePreviews.length
|
||||
"
|
||||
class="phone-stage"
|
||||
:class="{
|
||||
'phone-stage--browser-preview': isBrowserPreview,
|
||||
'phone-stage--landscape': phone.cameraLandscape,
|
||||
'phone-stage--live-activity':
|
||||
!phone.isOpen && Boolean(dynamicIslandActivity || calls.activeCall),
|
||||
'phone-stage--peek': notifications.isPeeking,
|
||||
}"
|
||||
:style="phoneStageStyle"
|
||||
@@ -1817,25 +1655,14 @@ onBeforeUnmount(() => {
|
||||
@open="openNotificationPreview"
|
||||
/>
|
||||
<div
|
||||
v-if="
|
||||
phone.isOpen ||
|
||||
notifications.current ||
|
||||
calls.activeCall ||
|
||||
dynamicIslandActivity
|
||||
"
|
||||
v-if="phone.isOpen || notifications.current || calls.activeCall"
|
||||
class="phone-resolution-wrapper phone-resolution-wrapper--primary"
|
||||
>
|
||||
<div
|
||||
id="phone-home-drag-portal"
|
||||
class="phone-home-drag-portal"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
<div class="phone-resolution-canvas phone-resolution-canvas--primary">
|
||||
<section
|
||||
class="phone-device"
|
||||
:class="{
|
||||
'phone-app--light': !displayedDarkMode,
|
||||
'phone-device--island-expanded': dynamicIslandExpanded,
|
||||
[`phone-app--${phone.preferences.settings.graphicsMode}`]: true,
|
||||
}"
|
||||
:aria-label="phone.t('Common.phone')"
|
||||
@@ -1947,6 +1774,7 @@ onBeforeUnmount(() => {
|
||||
@control-center="toggleControlCenter"
|
||||
@lock="lockPhone"
|
||||
/>
|
||||
<PhoneDynamicIsland v-if="!setupRequired" />
|
||||
<SpringboardView
|
||||
v-if="!isDevelopmentRoute && !setupRequired"
|
||||
@edit-mode-change="springboardEditing = $event"
|
||||
@@ -2032,11 +1860,6 @@ onBeforeUnmount(() => {
|
||||
aria-hidden="true"
|
||||
draggable="false"
|
||||
/>
|
||||
<PhoneDynamicIsland
|
||||
v-if="!setupRequired && !isDynamicIslandGalleryRoute"
|
||||
@expanded-change="dynamicIslandExpanded = $event"
|
||||
@live-activity-change="dynamicIslandActivity = $event"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const source = readFileSync(new URL('./App.vue', import.meta.url), 'utf8').replace(/\r\n/g, '\n')
|
||||
const source = readFileSync(new URL('./App.vue', import.meta.url), 'utf8')
|
||||
const mainCss = readFileSync(
|
||||
new URL('./assets/main.css', import.meta.url),
|
||||
'utf8',
|
||||
@@ -22,32 +22,15 @@ describe('browser development preview contract', () => {
|
||||
|
||||
it('starts unlocked while preserving an explicit lock screen preview', () => {
|
||||
expect(source).toContain("developmentParameters.has('lockScreenPreview')")
|
||||
expect(source).toContain(
|
||||
'developmentLockScreenPreview ||\n (!isDevelopment && phone.security.enabled)',
|
||||
)
|
||||
expect(source).toContain(': !isDevelopment || developmentLockScreenPreview')
|
||||
expect(source).toContain("developmentParameters.has('setupPreview')")
|
||||
})
|
||||
|
||||
it('restores the active route after the lock screen', () => {
|
||||
expect(source).toMatch(
|
||||
/if \(setupRequired\.value\) \{[\s\S]*?router\.replace\('\/'\)/,
|
||||
)
|
||||
expect(source).toMatch(
|
||||
/else if \(!isLocked\.value\) \{\s*loadUnlockedPhoneData\(\)/,
|
||||
)
|
||||
expect(source).not.toContain(
|
||||
it('loads authenticated app data without replacing direct app routes', () => {
|
||||
expect(source).toContain(
|
||||
"if (isLocked.value || setupRequired.value) void router.replace('/')",
|
||||
)
|
||||
})
|
||||
|
||||
it('opens Space-triggered live activities on Home or the enabled lock screen', () => {
|
||||
expect(source).toContain(
|
||||
'openHomeRequested.value = event.data.openHome === true',
|
||||
)
|
||||
expect(source).toContain(
|
||||
"if (isLocked.value) pendingUnlockRoute.value = '/'",
|
||||
)
|
||||
expect(source).toContain("void router.replace('/')")
|
||||
expect(source).toContain('else loadUnlockedPhoneData()')
|
||||
})
|
||||
|
||||
it('requires the passcode again after a full device lock', () => {
|
||||
@@ -92,10 +75,6 @@ describe('browser development preview contract', () => {
|
||||
|
||||
it('uses layout zoom so the fixed-resolution phone stays sharply rasterized', () => {
|
||||
expect(source).toContain('phone-resolution-canvas--primary')
|
||||
expect(source).toMatch(
|
||||
/phone-resolution-wrapper--primary[\s\S]*?id="phone-home-drag-portal"[\s\S]*?phone-resolution-canvas--primary/,
|
||||
)
|
||||
expect(source.match(/id="phone-home-drag-portal"/g)).toHaveLength(1)
|
||||
expect(source).toContain("'--phone-rendered-height'")
|
||||
expect(source).toContain("'--phone-rendered-width'")
|
||||
expect(mainCss).toMatch(
|
||||
@@ -121,9 +100,7 @@ describe('browser development preview contract', () => {
|
||||
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(source).toContain('return (availableScale * 0.94) / PHONE_BASE_SCALE')
|
||||
expect(mainCss).toMatch(
|
||||
/\.phone-stage--browser-preview\s*\{[^}]*place-items:\s*center;[^}]*padding:\s*0;/s,
|
||||
)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
+99
-130
@@ -211,11 +211,9 @@
|
||||
}
|
||||
}
|
||||
:root {
|
||||
font-family: var(--sky-font-family);
|
||||
font-feature-settings:
|
||||
'liga' 1,
|
||||
'calt' 1;
|
||||
font-optical-sizing: auto;
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
@@ -236,9 +234,7 @@ body,
|
||||
user-select: none;
|
||||
}
|
||||
button,
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
input {
|
||||
font: inherit;
|
||||
}
|
||||
button {
|
||||
@@ -263,20 +259,6 @@ button {
|
||||
transform: translateY(calc(100% - 190px));
|
||||
transform-origin: right bottom;
|
||||
}
|
||||
|
||||
.phone-stage--live-activity .phone-resolution-wrapper--primary .phone-device {
|
||||
pointer-events: none;
|
||||
transform: translateY(
|
||||
calc(100% - var(--phone-live-activity-peek-height, 145px))
|
||||
);
|
||||
transform-origin: right bottom;
|
||||
}
|
||||
.phone-stage--live-activity
|
||||
.phone-device
|
||||
> :not(.phone-screen):not(.phone-device__frame):not(.phone-dynamic-island),
|
||||
.phone-stage--live-activity .phone-screen > * {
|
||||
visibility: hidden;
|
||||
}
|
||||
.phone-lift-enter-active {
|
||||
transition: opacity 0.52s linear;
|
||||
}
|
||||
@@ -299,18 +281,6 @@ button {
|
||||
height: var(--phone-rendered-height, 844px);
|
||||
}
|
||||
|
||||
.phone-home-drag-portal {
|
||||
position: absolute;
|
||||
z-index: 70;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.phone-home-drag-portal * {
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
.phone-resolution-canvas {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
@@ -414,7 +384,7 @@ button {
|
||||
overflow: hidden;
|
||||
border: 1px solid rgb(255 255 255 / 16%);
|
||||
border-radius: 15px;
|
||||
color: #a9abb2;
|
||||
color: #0a84ff;
|
||||
background: rgb(47 47 51 / 98%);
|
||||
box-shadow: 0 8px 24px rgb(0 0 0 / 28%);
|
||||
pointer-events: none;
|
||||
@@ -702,9 +672,6 @@ button {
|
||||
transition: transform 280ms var(--sky-ease-out, ease-out);
|
||||
will-change: transform;
|
||||
}
|
||||
.phone-device--island-expanded .phone-notification {
|
||||
top: 130px !important;
|
||||
}
|
||||
.phone-notification__icon {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
@@ -828,6 +795,78 @@ button {
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.phone-dynamic-island {
|
||||
position: absolute;
|
||||
z-index: 98;
|
||||
top: 12px;
|
||||
left: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 300px;
|
||||
min-height: 72px;
|
||||
padding: 10px 12px 10px 16px;
|
||||
border: 1px solid rgb(255 255 255 / 10%);
|
||||
border-radius: 28px;
|
||||
color: #fff;
|
||||
background: #050505;
|
||||
box-shadow: 0 8px 24px rgb(0 0 0 / 45%);
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
.phone-dynamic-island__caller {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.phone-dynamic-island__caller span,
|
||||
.phone-dynamic-island__caller strong {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.phone-dynamic-island__caller span {
|
||||
color: rgb(255 255 255 / 58%);
|
||||
font-size: 11px;
|
||||
line-height: 14px;
|
||||
}
|
||||
.phone-dynamic-island__caller strong {
|
||||
margin-top: 2px;
|
||||
font-size: 15px;
|
||||
line-height: 18px;
|
||||
}
|
||||
.phone-dynamic-island__actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.phone-dynamic-island__actions .sky-button--icon-only {
|
||||
width: 44px;
|
||||
min-width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
.phone-dynamic-island__actions svg {
|
||||
width: 19px;
|
||||
height: 19px;
|
||||
}
|
||||
.phone-dynamic-island__answer {
|
||||
--sky-app-accent: #34c759;
|
||||
}
|
||||
.phone-dynamic-island-enter-active,
|
||||
.phone-dynamic-island-leave-active {
|
||||
transition:
|
||||
opacity 180ms ease,
|
||||
transform 220ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
.phone-dynamic-island-enter-from,
|
||||
.phone-dynamic-island-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) scale(0.86);
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.phone-dynamic-island-enter-active,
|
||||
.phone-dynamic-island-leave-active {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
.phone-home-indicator {
|
||||
position: absolute;
|
||||
z-index: 90;
|
||||
@@ -1302,7 +1341,8 @@ button {
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
color: #f5f5f7;
|
||||
font-family: var(--sky-font-family);
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', sans-serif;
|
||||
}
|
||||
.darkchat-page button,
|
||||
.darkchat-page input,
|
||||
@@ -2637,30 +2677,8 @@ button {
|
||||
.springboard--widget-dragging .springboard-widget-page-scroll {
|
||||
overflow: visible;
|
||||
}
|
||||
.home-drag-layer {
|
||||
position: absolute;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
.home-drag-layer * {
|
||||
pointer-events: none !important;
|
||||
}
|
||||
.home-drag-position {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
will-change: transform;
|
||||
}
|
||||
.home-drag-ghost {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
margin: 0;
|
||||
opacity: 1;
|
||||
transition: none !important;
|
||||
animation: none !important;
|
||||
transform-origin: 0 0;
|
||||
will-change: transform;
|
||||
.springboard--home-dragging .springboard-page--apps {
|
||||
overflow: visible;
|
||||
}
|
||||
.app-grid {
|
||||
display: grid;
|
||||
@@ -2695,9 +2713,6 @@ button {
|
||||
transition: transform var(--springboard-page-duration)
|
||||
var(--springboard-page-easing);
|
||||
}
|
||||
.app-icon-item--drag-source {
|
||||
opacity: 0;
|
||||
}
|
||||
.app-icon-remove {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
@@ -2823,6 +2838,7 @@ button {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 11px;
|
||||
}
|
||||
.app-icon--calculator {
|
||||
background: linear-gradient(145deg, #76767b, #1b1b1d);
|
||||
@@ -4162,8 +4178,8 @@ button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
min-height: 206px;
|
||||
padding: 1px 0 10px;
|
||||
min-height: 218px;
|
||||
padding: 1px 0 16px;
|
||||
text-align: center;
|
||||
}
|
||||
.weather-location {
|
||||
@@ -4215,7 +4231,7 @@ button {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--sky-space-2);
|
||||
margin-bottom: var(--sky-space-3);
|
||||
margin-bottom: var(--sky-space-4);
|
||||
}
|
||||
.weather-detail-card,
|
||||
.weather-panel {
|
||||
@@ -4226,21 +4242,18 @@ button {
|
||||
0 8px 22px rgba(5, 16, 32, 0.14);
|
||||
color: #fff;
|
||||
}
|
||||
.weather-details > .weather-detail-card {
|
||||
.weather-detail-card {
|
||||
display: grid;
|
||||
grid-template-columns: 21px 1fr;
|
||||
grid-template-rows: auto auto;
|
||||
align-content: center;
|
||||
gap: 7px;
|
||||
min-height: 76px;
|
||||
gap: 4px 7px;
|
||||
min-height: 94px;
|
||||
margin: 0;
|
||||
padding: 11px 12px;
|
||||
padding: 14px;
|
||||
border-radius: calc(var(--sky-radius-card) - 6px);
|
||||
}
|
||||
.weather-detail-card svg {
|
||||
grid-row: span 2;
|
||||
align-self: center;
|
||||
margin-top: 0;
|
||||
margin-top: 2px;
|
||||
color: #b7d9ec;
|
||||
}
|
||||
.weather-detail-card:nth-child(1) svg {
|
||||
@@ -4260,7 +4273,7 @@ button {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.weather-detail-card strong {
|
||||
align-self: auto;
|
||||
align-self: end;
|
||||
font-size: 18px;
|
||||
font-weight: 680;
|
||||
line-height: 1.1;
|
||||
@@ -4268,7 +4281,7 @@ button {
|
||||
.weather-detail-card:nth-child(4) strong {
|
||||
color: var(--weather-accent-cyan);
|
||||
}
|
||||
.weather-scroll > .weather-panel {
|
||||
.weather-panel {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
border-radius: var(--sky-radius-card);
|
||||
@@ -4302,11 +4315,10 @@ button {
|
||||
min-height: 108px;
|
||||
padding: 7px 2px 4px;
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 0;
|
||||
border-radius: var(--sky-radius-control);
|
||||
}
|
||||
.weather-hour:first-child {
|
||||
border-left: 0;
|
||||
border-radius: var(--sky-radius-control);
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
}
|
||||
.weather-hour span {
|
||||
@@ -6599,47 +6611,9 @@ button {
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
}
|
||||
.messages-media-picker__gifs--masonry {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 10px;
|
||||
}
|
||||
.messages-gif-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
align-items: start;
|
||||
gap: 8px;
|
||||
}
|
||||
.messages-gif-column {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 8px;
|
||||
}
|
||||
.messages-media-picker__gifs--masonry .messages-gif-result {
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgb(60 60 67 / 10%);
|
||||
border-radius: 12px;
|
||||
background: var(--sky-surface-muted);
|
||||
box-shadow: none;
|
||||
}
|
||||
.messages-media-picker__gifs--masonry .messages-gif-result img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
}
|
||||
.messages-media-picker__gifs .messages-gif-more {
|
||||
width: 100%;
|
||||
min-height: 36px;
|
||||
flex: 0 0 auto;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
grid-column: 1 / -1;
|
||||
color: var(--ios-blue);
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
@@ -6961,12 +6935,6 @@ button {
|
||||
.phone-app.dark .messages-media-picker__gifs button {
|
||||
background: #2c2c2e;
|
||||
}
|
||||
.phone-app.dark
|
||||
.messages-media-picker__gifs--masonry
|
||||
.messages-gif-result {
|
||||
border-color: rgb(255 255 255 / 10%);
|
||||
background: var(--sky-surface-muted);
|
||||
}
|
||||
.phone-app.dark .messages-media-picker__gifs .messages-gif-more,
|
||||
.phone-app.dark .messages-media-picker__gifs .messages-gif-error button {
|
||||
background: rgb(10 132 255 / 20%);
|
||||
@@ -7581,17 +7549,18 @@ button {
|
||||
min-height: var(--springboard-grid-row);
|
||||
}
|
||||
.app-icon-button {
|
||||
font-family: var(--sky-font-family);
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', sans-serif;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
.app-icon-label {
|
||||
min-height: var(--sky-home-label-height);
|
||||
font-size: var(--sky-home-label-font-size);
|
||||
min-height: 15px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.15px;
|
||||
line-height: var(--sky-home-label-height);
|
||||
line-height: 15px;
|
||||
text-align: center;
|
||||
}
|
||||
.springboard-edit-add {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const client = readFileSync(
|
||||
new URL('../../sky_phone/source/client/nui_server_bridge.lua', import.meta.url),
|
||||
new URL('../../sky_phone/source/client/main.lua', import.meta.url),
|
||||
'utf8',
|
||||
).replace(/\r\n/g, '\n')
|
||||
const companiesServer = readFileSync(
|
||||
@@ -14,10 +14,6 @@ const callsServer = readFileSync(
|
||||
new URL('../../sky_phone/source/server/calls.lua', import.meta.url),
|
||||
'utf8',
|
||||
).replace(/\r\n/g, '\n')
|
||||
const phoneServer = readFileSync(
|
||||
new URL('../../sky_phone/source/server/phone.lua', import.meta.url),
|
||||
'utf8',
|
||||
).replace(/\r\n/g, '\n')
|
||||
const companiesStore = readFileSync(
|
||||
new URL('./stores/companies.ts', import.meta.url),
|
||||
'utf8',
|
||||
@@ -36,7 +32,7 @@ function sourceBlock(source: string, startMarker: string, endMarker: string) {
|
||||
|
||||
describe('Companies outbound service-line call contract', () => {
|
||||
it('exposes the dedicated callback through the NUI client bridge', () => {
|
||||
expect(client).toMatch(/companies\s*=\s*\[\[[^\]]*dial-service-line/)
|
||||
expect(client).toContain(`${quote}companies:dial-service-line${quote}`)
|
||||
})
|
||||
|
||||
it('accepts only a target number and derives the company from the live server member', () => {
|
||||
@@ -89,35 +85,3 @@ describe('Companies outbound service-line call contract', () => {
|
||||
expect(startCompanyCall).not.toContain('data.callerNumber')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Companies background call availability contract', () => {
|
||||
it('keeps call availability enabled after the phone UI closes', () => {
|
||||
const closeDevice = sourceBlock(
|
||||
phoneServer,
|
||||
`Bridge.Callbacks.Register(${quote}sky_phone:device:close${quote}`,
|
||||
`Bridge.Callbacks.Register(${quote}sky_phone:device:notification-open${quote}`,
|
||||
)
|
||||
|
||||
expect(closeDevice).toContain('sessions[source] = nil')
|
||||
expect(closeDevice).not.toContain(
|
||||
'SkyPhoneCompanies.ClearCallAvailability(source)',
|
||||
)
|
||||
})
|
||||
|
||||
it('routes background calls only to an owned phone with the same registered SIM', () => {
|
||||
const getCallTargets = sourceBlock(
|
||||
companiesServer,
|
||||
'function SkyPhoneCompanies.GetCallTargets(',
|
||||
'\n\nlocal function profile_row(',
|
||||
)
|
||||
|
||||
expect(getCallTargets).toContain('SkyPhone.LoadDevice(readiness.imei)')
|
||||
expect(getCallTargets).toContain(
|
||||
'SkyPhone.FindDeviceSlots(source, readiness.imei)',
|
||||
)
|
||||
expect(getCallTargets).toContain('device.sim_id == readiness.sim_id')
|
||||
expect(getCallTargets).toContain('device.sim_type == "registered"')
|
||||
expect(getCallTargets).toContain('device.registered_at ~= nil')
|
||||
expect(getCallTargets).not.toContain('current_device(source, true)')
|
||||
})
|
||||
})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,524 +0,0 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const source = readFileSync(
|
||||
new URL('./AdminPanel.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const agentInstructions = readFileSync(
|
||||
new URL('../../../AGENTS.md', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const app = readFileSync(new URL('../App.vue', import.meta.url), 'utf8')
|
||||
const apps = readFileSync(new URL('../config/apps.ts', import.meta.url), 'utf8')
|
||||
const store = readFileSync(
|
||||
new URL('../stores/admin.ts', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const server = readFileSync(
|
||||
new URL('../../../sky_phone/source/server/admin.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const phoneServer = readFileSync(
|
||||
new URL('../../../sky_phone/source/server/phone.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const persistenceServer = readFileSync(
|
||||
new URL(
|
||||
'../../../sky_phone/source/server/phone_persistence.lua',
|
||||
import.meta.url,
|
||||
),
|
||||
'utf8',
|
||||
)
|
||||
const simServer = readFileSync(
|
||||
new URL('../../../sky_phone/source/server/sim.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const phoneClient = readFileSync(
|
||||
new URL('../../../sky_phone/source/client/main.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const focusClient = readFileSync(
|
||||
new URL('../../../sky_phone/source/client/focus.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const bridge = readFileSync(
|
||||
new URL(
|
||||
'../../../sky_phone/source/client/nui_server_bridge.lua',
|
||||
import.meta.url,
|
||||
),
|
||||
'utf8',
|
||||
)
|
||||
const config = readFileSync(
|
||||
new URL('../../../sky_phone/config/config.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const configDefaultsSource = config.replace(
|
||||
/\r?\n?-- CONFIG_DEFAULT_EXCLUDE_START[\s\S]*?-- CONFIG_DEFAULT_EXCLUDE_END\r?\n?/g,
|
||||
'\n',
|
||||
)
|
||||
const mediaConfig = readFileSync(
|
||||
new URL('../../../sky_phone/config/media.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const schema = readFileSync(
|
||||
new URL('../../../sky_phone/sql/install.sql', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const manifest = readFileSync(
|
||||
new URL('../../../sky_phone/fxmanifest.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const configuratorServer = readFileSync(
|
||||
new URL(
|
||||
'../../../sky_phone/source/server/phone_configurator.lua',
|
||||
import.meta.url,
|
||||
),
|
||||
'utf8',
|
||||
)
|
||||
const configDefault = readFileSync(
|
||||
new URL(
|
||||
'../../../sky_phone/source/shared/config_default.lua',
|
||||
import.meta.url,
|
||||
),
|
||||
'utf8',
|
||||
)
|
||||
const frontendBuild = readFileSync(
|
||||
new URL('../../build.cjs', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const configuratorClient = readFileSync(
|
||||
new URL(
|
||||
'../../../sky_phone/source/client/phone_configurator.lua',
|
||||
import.meta.url,
|
||||
),
|
||||
'utf8',
|
||||
)
|
||||
const mediaImportServer = readFileSync(
|
||||
new URL('../../../sky_phone/source/server/media_import.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const configuratorValueEditor = readFileSync(
|
||||
new URL('./AdminConfigValueEditor.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const configInputWidth = readFileSync(
|
||||
new URL('../directives/configInputWidth.ts', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
describe('standalone admin panel contracts', () => {
|
||||
it('renders as a dedicated full-screen editor outside the phone shell', () => {
|
||||
expect(apps).not.toContain("id: 'admin'")
|
||||
expect(app).toContain("event.data?.type === 'admin:open'")
|
||||
expect(app).toContain('v-if="adminPanelOpen"')
|
||||
expect(app).toContain('<AdminPanel')
|
||||
expect(source).toContain("import { SkyButton } from '@/ui'")
|
||||
expect(source).toContain('class="admin-panel-overlay"')
|
||||
expect(source).toContain('class="admin-panel-rail"')
|
||||
expect(source).toContain('class="admin-panel-directory"')
|
||||
expect(source).toContain('class="admin-panel-editor"')
|
||||
expect(source).toContain('pointer-events: auto')
|
||||
expect(source).toContain('@media (prefers-reduced-motion: reduce)')
|
||||
})
|
||||
|
||||
it('uses a compact transparent shell with dedicated admin workspaces', () => {
|
||||
expect(source).toContain('background: transparent')
|
||||
expect(source).toContain('width: min(76vw, 1220px)')
|
||||
expect(source).toContain('height: min(74vh, 700px)')
|
||||
expect(source).toContain('--admin-row-hover: linear-gradient')
|
||||
expect(source).toContain('--admin-row-active: linear-gradient')
|
||||
expect(source).toContain('background: var(--admin-nav-active)')
|
||||
expect(source).not.toContain('admin-panel-brand__mark')
|
||||
expect(source).not.toContain('admin-panel-profile-heading__status')
|
||||
expect(source).not.toContain('backdrop-filter: blur(2px)')
|
||||
expect(source).not.toContain("t('overview.recent')")
|
||||
|
||||
for (const tab of [
|
||||
'overview',
|
||||
'players',
|
||||
'devices',
|
||||
'apps',
|
||||
'accounts',
|
||||
'messages',
|
||||
'calls',
|
||||
'moderation',
|
||||
'audit',
|
||||
'configurator',
|
||||
]) {
|
||||
expect(source).toContain(`selectTab('${tab}')`)
|
||||
expect(source).toContain(`t('tabs.${tab}')`)
|
||||
}
|
||||
})
|
||||
|
||||
it('uses a fixed blue accent and replaces appearance controls with live statistics', () => {
|
||||
expect(source).not.toContain('<RefreshCw')
|
||||
expect(source).not.toContain("kind: 'refresh'")
|
||||
expect(source).not.toContain("'sky-phone-admin-accent'")
|
||||
expect(source).not.toContain("'sky-phone-admin-font-family'")
|
||||
expect(source).not.toContain("'sky-phone-admin-font-size'")
|
||||
expect(source).not.toContain("t('appearance.")
|
||||
expect(source).toContain('--admin-accent: #00b8e4')
|
||||
expect(source).toContain('class="admin-panel-statistics-layout"')
|
||||
expect(source).toContain('class="admin-panel-activity-grid"')
|
||||
expect(source).toContain('class="admin-panel-coverage-list"')
|
||||
expect(source).toContain('statisticsTimer = window.setInterval')
|
||||
expect(source).toContain('15_000')
|
||||
expect(server).toContain('AS `messages_today`')
|
||||
expect(server).toContain('AS `calls_today`')
|
||||
expect(server).toContain('AS `active_devices`')
|
||||
expect(server).toContain('auditEntries = tonumber(stats.audit_entries)')
|
||||
expect(source).toContain('color-mix(in srgb, var(--admin-green)')
|
||||
expect(source).toContain('--admin-toggle-on: #63d471')
|
||||
expect(source).toContain('background: var(--admin-toggle-on)')
|
||||
expect(configuratorValueEditor).toContain(
|
||||
'background: var(--admin-toggle-on)',
|
||||
)
|
||||
})
|
||||
|
||||
it('stages app changes locally and saves them only from the toolbar action', () => {
|
||||
expect(source).toContain('const drafts = ref<')
|
||||
expect(source).toContain('@click="saveChanges"')
|
||||
expect(source).toContain("t('editor.noAutoSave')")
|
||||
expect(store).toContain("'admin:save-apps'")
|
||||
expect(store).not.toContain("'admin:set-app'")
|
||||
expect(server).toContain(
|
||||
'Bridge.Callbacks.Register("sky_phone:admin:save-apps"',
|
||||
)
|
||||
})
|
||||
|
||||
it('applies global bundled-app availability to phones and admin management', () => {
|
||||
expect(config).toContain('Config.Apps = {')
|
||||
expect(config).toContain('memory = true,')
|
||||
expect(config).toContain('weather = true,')
|
||||
expect(configDefault).toContain('Config.Apps = {')
|
||||
expect(configuratorServer).toContain('Apps = true,')
|
||||
expect(phoneServer).toContain('function SkyPhone.IsAppEnabled(app_id)')
|
||||
expect(phoneServer).toContain('function SkyPhone.GetDisabledApps()')
|
||||
expect(phoneServer).toContain(
|
||||
'disabledApps = SkyPhone.GetDisabledApps()',
|
||||
)
|
||||
expect(server).toContain('if not SkyPhone.IsAppEnabled(app_id) then')
|
||||
expect(server).toContain('disabledApps = SkyPhone.GetDisabledApps()')
|
||||
expect(store).toContain('disabledApps: [] as string[]')
|
||||
expect(store).toContain('disabledAppsFromConfigurator(response.data)')
|
||||
expect(source).toContain('!admin.disabledApps.includes(app.id)')
|
||||
})
|
||||
|
||||
it('connects every operation through the standard NUI callback bridge', () => {
|
||||
expect(bridge).toContain('admin = [[')
|
||||
for (const endpoint of [
|
||||
'admin:bootstrap',
|
||||
'admin:player',
|
||||
'admin:save-apps',
|
||||
'admin:reveal-password',
|
||||
'admin:activity',
|
||||
'admin:reset-passcode',
|
||||
'admin:change-number',
|
||||
'admin:factory-reset',
|
||||
'admin:configurator',
|
||||
'admin:save-configurator',
|
||||
]) {
|
||||
expect(store).toContain(endpoint)
|
||||
}
|
||||
})
|
||||
|
||||
it('protects activity views and device moderation with ownership and audit checks', () => {
|
||||
for (const endpoint of [
|
||||
'activity',
|
||||
'reset-passcode',
|
||||
'change-number',
|
||||
'factory-reset',
|
||||
]) {
|
||||
expect(server).toContain(
|
||||
`Bridge.Callbacks.Register("sky_phone:admin:${endpoint}"`,
|
||||
)
|
||||
}
|
||||
expect(server).toContain('data.kind ~= "messages"')
|
||||
expect(server).toContain('data.kind ~= "calls"')
|
||||
expect(server).toContain('"view_messages"')
|
||||
expect(server).toContain('"view_calls"')
|
||||
expect(server).toContain('"reset_passcode"')
|
||||
expect(server).toContain('"change_number"')
|
||||
expect(server).toContain('"factory_reset"')
|
||||
expect(simServer).toContain('function SkyPhoneSim.ChangeNumber(')
|
||||
expect(simServer).toContain('UPDATE IGNORE `sky_phone_sims`')
|
||||
expect(persistenceServer).toContain(
|
||||
'function SkyPhonePersistence.FactoryReset(imei)',
|
||||
)
|
||||
})
|
||||
|
||||
it('authorizes every server request without requiring a phone session', () => {
|
||||
expect(server).not.toContain('SkyPhone.RequireSession(source)')
|
||||
expect(server).toContain(
|
||||
'Bridge.Framework.HasPermission(source, "phonepanel")',
|
||||
)
|
||||
expect(server).toContain('Config.AdminPanel.ReadRequestsPerMinute')
|
||||
expect(server).toContain('Config.AdminPanel.ActionRequestsPerMinute')
|
||||
expect(server).toContain('Config.AdminPanel.CredentialRevealsPerMinute')
|
||||
expect(config).toContain('Config.AdminPanel = {')
|
||||
expect(config).toContain('Config.CommandPermissions = {')
|
||||
expect(config).not.toContain('AdminGroups =')
|
||||
})
|
||||
|
||||
it('opens directly from the configurable command with dedicated focus', () => {
|
||||
expect(config).toContain('Command = "phonepanel"')
|
||||
expect(server).toContain('local command_name = Config.AdminPanel.Command')
|
||||
expect(server).toContain(
|
||||
'RegisterCommand(command_name, function(command_source)',
|
||||
)
|
||||
expect(server).toContain(
|
||||
'TriggerClientEvent("sky_phone:admin:launch", player_source)',
|
||||
)
|
||||
expect(phoneServer).not.toContain(
|
||||
'RegisterCommand(Config.AdminPanel.Command',
|
||||
)
|
||||
expect(phoneClient).toContain('RegisterNetEvent("sky_phone:admin:launch"')
|
||||
expect(phoneClient).toContain('SkyPhoneFocus.SetAdminPanel(true)')
|
||||
expect(focusClient).toContain('function SkyPhoneFocus.SetAdminPanel(open)')
|
||||
})
|
||||
|
||||
it('validates ownership, app policy, and revision before a batch mutation', () => {
|
||||
expect(server).toContain('find_owned_device(target_source, data.imei)')
|
||||
expect(server).toContain('app_metadata(change.appId)')
|
||||
expect(server).toContain('error = "device_not_owned"')
|
||||
expect(server).toContain('error = "app_protected"')
|
||||
expect(server).toContain('AND `revision` = ?')
|
||||
expect(server).toContain('SkyPhone.RefreshDevice(data.imei)')
|
||||
})
|
||||
|
||||
it('gates plaintext account reveals behind confirmation and audit logging', () => {
|
||||
expect(source).toContain('revealDialogImei')
|
||||
expect(source).toContain("t('credentials.revealTitle')")
|
||||
expect(server).toContain('"reveal_account_password"')
|
||||
expect(server).toContain('write_audit(')
|
||||
expect(server).not.toContain('passcode_hash` AS')
|
||||
expect(schema).toContain(
|
||||
'CREATE TABLE IF NOT EXISTS `sky_phone_admin_audit`',
|
||||
)
|
||||
})
|
||||
|
||||
it('loads the SQL phone configurator before framework-owned configuration is read', () => {
|
||||
expect(agentInstructions).toContain('Phone Configurator parity (mandatory)')
|
||||
expect(agentInstructions).toContain(
|
||||
'changes without matching Configurator support are incomplete',
|
||||
)
|
||||
expect(config).toMatch(
|
||||
/Config\.PhoneConfigurator\s*=\s*\{[\s\S]*?Enabled\s*=\s*true[\s\S]*?Config\.Bridge\s*=/,
|
||||
)
|
||||
expect(schema).toContain(
|
||||
'CREATE TABLE IF NOT EXISTS `sky_phone_configurator`',
|
||||
)
|
||||
expect(
|
||||
manifest.indexOf("'source/shared/config_default.lua'"),
|
||||
).toBeGreaterThan(manifest.indexOf("'config/media.lua'"))
|
||||
expect(manifest.indexOf("'source/shared/config_default.lua'")).toBeLessThan(
|
||||
manifest.indexOf("'source/server/phone_configurator.lua'"),
|
||||
)
|
||||
expect(
|
||||
manifest.indexOf("'source/server/phone_configurator.lua'"),
|
||||
).toBeLessThan(manifest.indexOf("'source/bridge/server/framework.lua'"))
|
||||
expect(
|
||||
manifest.indexOf("'source/client/phone_configurator.lua'"),
|
||||
).toBeLessThan(manifest.indexOf("'source/bridge/client/framework.lua'"))
|
||||
expect(configuratorServer).toContain('AND `revision` = ?')
|
||||
expect(configuratorServer).toContain('configurator_enabled')
|
||||
expect(configuratorServer).toMatch(
|
||||
/for key, value in pairs\(ConfigDefaults\)[\s\S]*?key ~= "Media"[\s\S]*?key ~= "PhoneConfigurator"/,
|
||||
)
|
||||
expect(configuratorServer).toContain(
|
||||
'default_media = serialize_value(ConfigDefaults.Media)',
|
||||
)
|
||||
expect(configDefault).toContain(
|
||||
'GENERATED by frontend/build.cjs from config/config.lua and config/media.lua',
|
||||
)
|
||||
expect(configDefault).toContain('local realConfig = Config')
|
||||
expect(configDefault).toContain('ConfigDefaults = Config')
|
||||
expect(configDefault.replace(/\r\n?/g, '\n').trimEnd()).toBe(
|
||||
[
|
||||
'-- GENERATED by frontend/build.cjs from config/config.lua and config/media.lua - do not edit.',
|
||||
'-- Escrowed snapshot of the shipped defaults used by the Phone Configurator.',
|
||||
'local realConfig = Config',
|
||||
'Config = {}',
|
||||
'',
|
||||
configDefaultsSource.replace(/\r\n?/g, '\n').trimEnd(),
|
||||
'',
|
||||
mediaConfig.replace(/\r\n?/g, '\n').trimEnd(),
|
||||
'',
|
||||
'ConfigDefaults = Config',
|
||||
'Config = realConfig',
|
||||
].join('\n'),
|
||||
)
|
||||
expect(frontendBuild).toContain("readLuaSource('config', 'config.lua')")
|
||||
expect(frontendBuild).toContain("readLuaSource('config', 'media.lua')")
|
||||
expect(frontendBuild).toContain("'config_default.lua'")
|
||||
expect(configDefault).not.toContain('Config.PhoneConfigurator')
|
||||
expect(configDefault).not.toContain('Config.CommandPermissions')
|
||||
expect(configuratorServer).toContain(
|
||||
'build_sections("config", stored_config',
|
||||
)
|
||||
expect(configuratorServer).toContain('build_sections("media", stored_media')
|
||||
expect(configuratorServer).toContain('sensitive_path')
|
||||
expect(configuratorServer).toContain('restore_redacted_values')
|
||||
expect(configuratorServer).toContain(
|
||||
'{ __skyType = "map", entries = entries }',
|
||||
)
|
||||
expect(configuratorServer).toContain('validate_structured_value')
|
||||
expect(configuratorServer).toContain('validate_locked_structure')
|
||||
expect(configuratorServer).toContain('build_structure(default_value')
|
||||
expect(configuratorServer).toContain('path == "Companies.Definitions"')
|
||||
expect(configuratorServer).toContain('flatten_company_fields')
|
||||
expect(configuratorServer).toContain('structure.mutableKeys')
|
||||
expect(configuratorServer).toContain('local function empty_structure(')
|
||||
expect(configuratorServer).toContain('template = items[1]')
|
||||
expect(configuratorServer).toContain('structure.template')
|
||||
expect(configuratorServer).toContain('if not structure.fields[key] then')
|
||||
expect(configuratorServer).toContain('field.type == "stringOrFalse"')
|
||||
expect(configuratorServer).not.toContain('Config.Media = client_payload')
|
||||
expect(configuratorServer).toContain(
|
||||
'apply_runtime_table(Config[key], value)',
|
||||
)
|
||||
expect(configuratorServer).toContain(
|
||||
'apply_runtime_table(Config.Media, runtime_media)',
|
||||
)
|
||||
expect(configuratorServer).toContain('local function read_stored_row()')
|
||||
expect(configuratorServer).toContain('local function apply_stored_row(row)')
|
||||
expect(configuratorServer).toContain(
|
||||
'persisted_row.media_payload ~= media_encoded',
|
||||
)
|
||||
expect(configuratorServer).toContain(
|
||||
'Phone configurator SQL verification failed',
|
||||
)
|
||||
expect(server).not.toContain('ExecuteCommand(("restart %s")')
|
||||
expect(configuratorServer).not.toContain('clear_runtime_table')
|
||||
expect(configuratorServer).toContain(
|
||||
'TriggerEvent("sky_phone:configurator:serverUpdated", revision)',
|
||||
)
|
||||
expect(configuratorServer).toContain(
|
||||
'function SkyPhoneConfigurator.Broadcast(target)',
|
||||
)
|
||||
expect(configuratorServer).toContain('SkyPhoneConfigurator.Broadcast(-1)')
|
||||
expect(configuratorServer).toContain('through the internal runtime refresh')
|
||||
expect(configuratorClient).toContain(
|
||||
'Bridge.Callbacks.Trigger("sky_phone:configurator:runtime"',
|
||||
)
|
||||
expect(configuratorClient).toContain(
|
||||
'apply_runtime_table(Config[key], value)',
|
||||
)
|
||||
expect(configuratorClient).not.toContain('clear_runtime_table')
|
||||
expect(configuratorClient).toContain(
|
||||
'TriggerEvent("sky_phone:configurator:updated"',
|
||||
)
|
||||
expect(phoneClient).toMatch(
|
||||
/AddEventHandler\("sky_phone:configurator:updated"[\s\S]*?SkyPhoneLocales\.Resolve\(Config\.Bridge\.Locale\)[\s\S]*?SkyPhoneApps\.SendCatalog\(\)[\s\S]*?type = "device:updated"/,
|
||||
)
|
||||
expect(server).toContain(
|
||||
'AddEventHandler("sky_phone:configurator:serverUpdated"',
|
||||
)
|
||||
expect(phoneServer).toContain('register_configured_phone_item()')
|
||||
expect(phoneServer).toContain(
|
||||
'AddEventHandler("sky_phone:configurator:serverUpdated"',
|
||||
)
|
||||
expect(simServer).toContain('refresh_sim_types()')
|
||||
expect(simServer).toContain(
|
||||
'AddEventHandler("sky_phone:configurator:serverUpdated"',
|
||||
)
|
||||
expect(mediaImportServer).toContain('local website = {}')
|
||||
expect(mediaImportServer).toContain('website._adapter = adapter')
|
||||
expect(mediaImportServer).not.toContain('definition._adapter = adapter')
|
||||
expect(mediaImportServer).toMatch(
|
||||
/AddEventHandler\("sky_phone:configurator:serverUpdated"[\s\S]*?if initialized then[\s\S]*?build_registry\(\)/,
|
||||
)
|
||||
expect(source).toContain('class="admin-panel-rail__configurator"')
|
||||
expect(source).toContain(
|
||||
'.admin-panel-rail .admin-panel-rail__configurator',
|
||||
)
|
||||
expect(source).toContain('<AdminConfigValueEditor')
|
||||
expect(source).toContain('function configuratorFieldRepeatsSection(')
|
||||
expect(source).toContain('v-if="!configuratorFieldRepeatsSection(field)"')
|
||||
expect(source).toContain('class="admin-panel-config-scopes"')
|
||||
expect(source).toContain("selectConfiguratorScope('config')")
|
||||
expect(source).toContain("selectConfiguratorScope('media')")
|
||||
expect(source).not.toContain('class="admin-panel-config-meta"')
|
||||
expect(configuratorValueEditor).toContain('function addListRow()')
|
||||
expect(configuratorValueEditor).toContain('function addTableField()')
|
||||
expect(configuratorValueEditor).toContain(
|
||||
'const canExtendTable = computed(',
|
||||
)
|
||||
expect(configuratorValueEditor).toContain(
|
||||
'tableStructure.value.mutableKeys === true',
|
||||
)
|
||||
expect(configuratorValueEditor).toContain(
|
||||
'const usesFixedTableLayout = computed(',
|
||||
)
|
||||
expect(configuratorValueEditor).toContain(
|
||||
"'is-fixed-table': usesFixedTableLayout",
|
||||
)
|
||||
expect(configuratorValueEditor).toContain('v-if="!usesFixedTableLayout"')
|
||||
expect(configuratorValueEditor).toContain(
|
||||
'.config-structured-editor.is-fixed-table',
|
||||
)
|
||||
expect(configuratorValueEditor).toContain('v-else-if="canExtendTable"')
|
||||
expect(configuratorValueEditor).toContain('function removeListRow(')
|
||||
expect(configuratorValueEditor).toContain('function removeTableField(')
|
||||
expect(configuratorValueEditor).toContain('function addMapEntry()')
|
||||
expect(configuratorValueEditor).toContain('function updateMapKey(')
|
||||
expect(configuratorValueEditor).toContain('fixedMapEntryStructure(current)')
|
||||
expect(configuratorValueEditor).toContain('listStructure?.items[index]')
|
||||
expect(configuratorValueEditor).toContain('function listItemStructure(')
|
||||
expect(configuratorValueEditor).toContain('const listTemplate = computed(')
|
||||
expect(configuratorValueEditor).toContain('function structureTypeLabel(')
|
||||
expect(configuratorValueEditor).toContain(
|
||||
'class="config-structured-editor__fixed-type"',
|
||||
)
|
||||
expect(configuratorValueEditor).toContain(
|
||||
'const rootTableTabs = computed<RootTableTab[]>(',
|
||||
)
|
||||
expect(configuratorValueEditor).toContain(
|
||||
'class="config-structured-editor__tabs"',
|
||||
)
|
||||
expect(configuratorValueEditor).toContain(
|
||||
'class="config-structured-editor__tab-panel"',
|
||||
)
|
||||
expect(source).toContain(':tab-label="configuratorSubtabLabel"')
|
||||
expect(source).toContain('configurator.table.subtabs.${key}')
|
||||
expect(configuratorValueEditor).toContain(
|
||||
'props.tabLabel?.(key, tableValue.value[key]) ?? key',
|
||||
)
|
||||
expect(source).toContain('v-config-input-width')
|
||||
expect(configuratorValueEditor).toContain('v-config-input-width')
|
||||
expect(configInputWidth).toContain("input.addEventListener('input'")
|
||||
expect(configInputWidth).toContain('input.scrollWidth')
|
||||
expect(source).toContain('filter: drop-shadow')
|
||||
expect(source).toContain("input[type='number']::-webkit-inner-spin-button")
|
||||
expect(configuratorValueEditor).toContain(
|
||||
"input[type='number']::-webkit-inner-spin-button",
|
||||
)
|
||||
expect(source).toContain('justify-self: start')
|
||||
expect(configuratorValueEditor).toContain('function tableFieldStructure(')
|
||||
expect(configuratorValueEditor).toContain('function isFixedTableField(')
|
||||
expect(configuratorValueEditor).toContain('function blankCollectionValue(')
|
||||
expect(configuratorValueEditor).toContain('function blankFromStructure(')
|
||||
expect(source).toContain("'is-structured': field.type === 'json'")
|
||||
expect(configuratorValueEditor).toContain('function isStructuredValue(')
|
||||
expect(configuratorValueEditor).toContain(
|
||||
'class="config-structured-editor__add-field is-list"',
|
||||
)
|
||||
expect(configuratorValueEditor).toContain(
|
||||
'class="config-structured-editor__actions"',
|
||||
)
|
||||
expect(configuratorValueEditor).toContain('function toggleStructuredEntry(')
|
||||
expect(configuratorValueEditor).toContain(
|
||||
'class="config-structured-editor__section-toggle"',
|
||||
)
|
||||
expect(configuratorValueEditor).toContain(':aria-expanded=')
|
||||
expect(configuratorValueEditor).toContain(
|
||||
'@media (prefers-reduced-motion: reduce)',
|
||||
)
|
||||
expect(configuratorValueEditor).toContain(
|
||||
'.config-structured-editor__property.has-structured-value',
|
||||
)
|
||||
expect(configuratorValueEditor).not.toContain('<textarea')
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -30,13 +30,11 @@ const props = withDefaults(
|
||||
app: PhoneAppDefinition
|
||||
compact?: boolean
|
||||
editMode?: boolean
|
||||
externalDragVisual?: boolean
|
||||
showLabel?: boolean
|
||||
}>(),
|
||||
{
|
||||
compact: false,
|
||||
editMode: false,
|
||||
externalDragVisual: false,
|
||||
showLabel: true,
|
||||
},
|
||||
)
|
||||
@@ -65,14 +63,14 @@ let dragStartPage = 0
|
||||
let dragPageWidth = 0
|
||||
let dragPageMetrics: SpringboardDragMetrics | null = null
|
||||
const dragStyle = computed(() =>
|
||||
isDragging.value && !props.externalDragVisual
|
||||
isDragging.value
|
||||
? {
|
||||
transform: `translate3d(${springboardPageDragCompensation(dragStartPage, phone.currentPage, dragPageWidth)}px, 0, 0)`,
|
||||
}
|
||||
: undefined,
|
||||
)
|
||||
const dragPointerStyle = computed(() =>
|
||||
isDragging.value && !props.externalDragVisual
|
||||
isDragging.value
|
||||
? {
|
||||
transform: `translate3d(${dragOffset.value.x}px, ${dragOffset.value.y}px, 0)`,
|
||||
}
|
||||
@@ -302,7 +300,6 @@ onBeforeUnmount(() => {
|
||||
class="app-icon-item"
|
||||
:class="{
|
||||
'app-icon-item--compact': compact,
|
||||
'app-icon-item--drag-source': isDragging && externalDragVisual,
|
||||
'app-icon-item--dragging': isDragging,
|
||||
'app-icon-item--editing': editMode,
|
||||
}"
|
||||
@@ -328,10 +325,7 @@ onBeforeUnmount(() => {
|
||||
class="app-icon"
|
||||
:class="[
|
||||
app.iconClass,
|
||||
{
|
||||
'app-icon--image':
|
||||
!iconFailed && Boolean(app.iconImage) && app.id !== 'calendar',
|
||||
},
|
||||
{ 'app-icon--image': !iconFailed && app.id !== 'calendar' },
|
||||
]"
|
||||
:style="iconStyle"
|
||||
>
|
||||
@@ -340,7 +334,7 @@ onBeforeUnmount(() => {
|
||||
<b>{{ calendarDay }}</b>
|
||||
</span>
|
||||
<img
|
||||
v-else-if="!iconFailed && app.iconImage"
|
||||
v-else-if="!iconFailed"
|
||||
:src="app.iconImage"
|
||||
alt=""
|
||||
draggable="false"
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const frame = readFileSync(
|
||||
new URL('./CustomAppFrame.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const appTypes = readFileSync(
|
||||
new URL('../types/apps.ts', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
describe('Custom app frame context permissions', () => {
|
||||
it('only exposes locale and theme context when their capabilities are granted', () => {
|
||||
expect(frame).toContain("capabilities.includes('theme.read')")
|
||||
expect(frame).toContain("capabilities.includes('locale.read')")
|
||||
expect(frame).toMatch(
|
||||
/capabilities\.includes\('theme\.read'\)[\s\S]*colorScheme/,
|
||||
)
|
||||
expect(frame).toMatch(
|
||||
/capabilities\.includes\('locale\.read'\)[\s\S]*language:[\s\S]*locale:/,
|
||||
)
|
||||
expect(appTypes).toContain("colorScheme?: 'dark' | 'light'")
|
||||
expect(appTypes).toContain('language?: string')
|
||||
expect(appTypes).toContain('locale?: Record<string, unknown>')
|
||||
})
|
||||
})
|
||||
@@ -10,8 +10,6 @@ import { useRouter } from 'vue-router'
|
||||
|
||||
import { getPhoneApp, isExternalPhoneApp } from '@/config/apps'
|
||||
import { useAppCatalogStore } from '@/stores/app-catalog'
|
||||
import { useCallsStore } from '@/stores/calls'
|
||||
import { useMessagesStore } from '@/stores/messages'
|
||||
import { useNotificationsStore } from '@/stores/notifications'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import type {
|
||||
@@ -35,18 +33,13 @@ import {
|
||||
getCustomAppSafeArea,
|
||||
} from '@/utils/customAppLifecycle'
|
||||
import {
|
||||
LB_PHONE_STORAGE_MESSAGE_TYPE,
|
||||
LB_PHONE_ACTION_MESSAGE_TYPE,
|
||||
createLbPhoneFrameDocument,
|
||||
createLbPhoneHostSettings,
|
||||
getLbPhoneCallbackResource,
|
||||
readLbPhoneStorage,
|
||||
usesLbPhoneHostRuntime,
|
||||
writeLbPhoneStorage,
|
||||
} from '@/utils/lbPhoneAppBridge'
|
||||
import { cloneJsonData } from '@/utils/clone'
|
||||
import { nuiCall } from '@/utils/nui'
|
||||
import type { PhoneCall } from '@/types/phone'
|
||||
|
||||
const props = defineProps<{
|
||||
app: ExternalPhoneAppDefinition
|
||||
@@ -55,8 +48,6 @@ const props = defineProps<{
|
||||
const PROTOCOL_VERSION = 1
|
||||
|
||||
const catalog = useAppCatalogStore()
|
||||
const calls = useCallsStore()
|
||||
const messages = useMessagesStore()
|
||||
const notifications = useNotificationsStore()
|
||||
const phone = usePhoneStore()
|
||||
const router = useRouter()
|
||||
@@ -137,28 +128,19 @@ const frameReady = computed(
|
||||
frameLoaded.value &&
|
||||
(props.app.bridgeMode === 'legacy' || skyBridgeReady.value),
|
||||
)
|
||||
const context = computed<SkyPhoneAppContextV1>(() => {
|
||||
const capabilities = getSkyPhoneAppCapabilities(props.app.capabilities)
|
||||
return {
|
||||
appId: props.app.id,
|
||||
capabilities,
|
||||
...(capabilities.includes('theme.read')
|
||||
? { colorScheme: phone.isDarkMode ? ('dark' as const) : ('light' as const) }
|
||||
: {}),
|
||||
...(capabilities.includes('locale.read')
|
||||
? {
|
||||
language: phone.lang,
|
||||
locale: {
|
||||
description: props.app.description,
|
||||
name: props.app.name,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
phoneScale: phone.preferences.settings.phoneScale / 100,
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
safeArea: getCustomAppSafeArea(props.app.orientation),
|
||||
}
|
||||
})
|
||||
const context = computed<SkyPhoneAppContextV1>(() => ({
|
||||
appId: props.app.id,
|
||||
capabilities: getSkyPhoneAppCapabilities(props.app.capabilities),
|
||||
colorScheme: phone.isDarkMode ? 'dark' : 'light',
|
||||
language: phone.lang,
|
||||
locale: {
|
||||
description: props.app.description,
|
||||
name: props.app.name,
|
||||
},
|
||||
phoneScale: phone.preferences.settings.phoneScale / 100,
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
safeArea: getCustomAppSafeArea(props.app.orientation),
|
||||
}))
|
||||
const lbSettings = computed(() =>
|
||||
createLbPhoneHostSettings({
|
||||
deviceName: phone.device?.name ?? '',
|
||||
@@ -209,16 +191,6 @@ async function prepareLbFrameDocument(): Promise<void> {
|
||||
const controller = new AbortController()
|
||||
frameDocumentController = controller
|
||||
try {
|
||||
let appStorage = {}
|
||||
try {
|
||||
appStorage = readLbPhoneStorage(window.localStorage, props.app.id)
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[Custom apps] Could not read LB Phone storage for ${props.app.id}.`,
|
||||
error,
|
||||
)
|
||||
}
|
||||
|
||||
const response = await fetch(frameUrl.value, {
|
||||
credentials: 'omit',
|
||||
signal: controller.signal,
|
||||
@@ -229,7 +201,6 @@ async function prepareLbFrameDocument(): Promise<void> {
|
||||
const html = await response.text()
|
||||
lbFrameDocument.value = createLbPhoneFrameDocument(html, {
|
||||
appName: props.app.id,
|
||||
localStorage: appStorage,
|
||||
resourceName: getLbPhoneCallbackResource(props.app),
|
||||
settings: lbSettings.value,
|
||||
ui: props.app.ui,
|
||||
@@ -311,55 +282,6 @@ async function handleBridgeRequest(
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLbPhoneAction(message: Record<string, unknown>) {
|
||||
if (message.action === 'createCall') {
|
||||
const options = message.options
|
||||
if (!options || typeof options !== 'object' || Array.isArray(options)) {
|
||||
console.error(
|
||||
`[Custom apps] Rejected invalid LB call action from ${props.app.id}.`,
|
||||
)
|
||||
return
|
||||
}
|
||||
const target = options as Record<string, unknown>
|
||||
if (
|
||||
typeof target.number !== 'string' &&
|
||||
typeof target.company !== 'string'
|
||||
) {
|
||||
console.error(
|
||||
`[Custom apps] Rejected invalid LB call target from ${props.app.id}.`,
|
||||
)
|
||||
return
|
||||
}
|
||||
const response = await nuiCall<PhoneCall>('calls:dial', {
|
||||
company: target.company,
|
||||
phoneNumber: target.number,
|
||||
})
|
||||
if (response.success && response.data) calls.applyCallState(response.data)
|
||||
return
|
||||
}
|
||||
|
||||
if (message.action === 'createSMS') {
|
||||
const options = message.options
|
||||
const phoneNumber =
|
||||
typeof options === 'string'
|
||||
? options
|
||||
: options && typeof options === 'object' && !Array.isArray(options)
|
||||
? ((options as Record<string, unknown>).number ??
|
||||
(options as Record<string, unknown>).phoneNumber)
|
||||
: undefined
|
||||
if (
|
||||
typeof phoneNumber !== 'string' ||
|
||||
!(await messages.openThread(phoneNumber))
|
||||
) {
|
||||
console.error(
|
||||
`[Custom apps] Rejected invalid LB SMS target from ${props.app.id}.`,
|
||||
)
|
||||
return
|
||||
}
|
||||
void router.push('/apps/messages')
|
||||
}
|
||||
}
|
||||
|
||||
function isTrustedFrameMessage(event: MessageEvent): boolean {
|
||||
if (event.source !== frame.value?.contentWindow) return false
|
||||
if (props.app.bundled || lbHostRuntime.value) {
|
||||
@@ -386,29 +308,6 @@ function onFrameMessage(event: MessageEvent): void {
|
||||
return
|
||||
}
|
||||
|
||||
if (message.type === LB_PHONE_STORAGE_MESSAGE_TYPE) {
|
||||
try {
|
||||
if (
|
||||
!writeLbPhoneStorage(window.localStorage, props.app.id, message.storage)
|
||||
) {
|
||||
console.error(
|
||||
`[Custom apps] Rejected invalid LB Phone storage for ${props.app.id}.`,
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[Custom apps] Could not persist LB Phone storage for ${props.app.id}.`,
|
||||
error,
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (message.type === LB_PHONE_ACTION_MESSAGE_TYPE) {
|
||||
void handleLbPhoneAction(message)
|
||||
return
|
||||
}
|
||||
|
||||
if (message.type === 'sky-phone-app:ready') {
|
||||
if (!skyBridgeReady.value) {
|
||||
if (loadTimeout !== undefined) clearTimeout(loadTimeout)
|
||||
|
||||
@@ -22,13 +22,11 @@ const props = withDefaults(
|
||||
apps: PhoneAppDefinition[]
|
||||
defaultName: string
|
||||
editMode?: boolean
|
||||
externalDragVisual?: boolean
|
||||
folder: HomeFolder
|
||||
showLabel?: boolean
|
||||
}>(),
|
||||
{
|
||||
editMode: false,
|
||||
externalDragVisual: false,
|
||||
showLabel: true,
|
||||
},
|
||||
)
|
||||
@@ -57,14 +55,14 @@ let stopPointerSession: (() => void) | null = null
|
||||
|
||||
const folderName = computed(() => props.folder.name || props.defaultName)
|
||||
const dragStyle = computed(() =>
|
||||
isDragging.value && !props.externalDragVisual
|
||||
isDragging.value
|
||||
? {
|
||||
transform: `translate3d(${springboardPageDragCompensation(dragStartPage, phone.currentPage, dragPageWidth)}px, 0, 0)`,
|
||||
}
|
||||
: undefined,
|
||||
)
|
||||
const dragPointerStyle = computed(() =>
|
||||
isDragging.value && !props.externalDragVisual
|
||||
isDragging.value
|
||||
? {
|
||||
transform: `translate3d(${dragOffset.value.x}px, ${dragOffset.value.y}px, 0)`,
|
||||
}
|
||||
@@ -234,7 +232,6 @@ onBeforeUnmount(() => {
|
||||
<div
|
||||
class="home-folder-item app-icon-item"
|
||||
:class="{
|
||||
'app-icon-item--drag-source': isDragging && externalDragVisual,
|
||||
'app-icon-item--dragging': isDragging,
|
||||
'app-icon-item--editing': editMode,
|
||||
'home-folder-item--dragging': isDragging,
|
||||
|
||||
@@ -331,7 +331,8 @@ function finishPageSwipe(event: PointerEvent): void {
|
||||
z-index: 70;
|
||||
inset: 0;
|
||||
color: #fff;
|
||||
font-family: var(--sky-font-family);
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', sans-serif;
|
||||
}
|
||||
|
||||
.home-folder-backdrop {
|
||||
|
||||
@@ -3,7 +3,6 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
import payphoneFrame from '@/assets/img/payphone/american-payphone-frame.png'
|
||||
import { nuiCall } from '@/utils/nui'
|
||||
import { registerPhoneMediaElement } from '@/utils/phoneAudio'
|
||||
import { isTrustedRootMessageSource } from '@/utils/windowMessages'
|
||||
|
||||
type PayphoneState =
|
||||
@@ -71,9 +70,7 @@ const buttonSounds: HTMLAudioElement[] = []
|
||||
function prepareButtonSounds(): void {
|
||||
if (buttonSounds.length) return
|
||||
for (let index = 0; index < 4; index += 1) {
|
||||
const sound = registerPhoneMediaElement(
|
||||
new Audio(`${import.meta.env.BASE_URL}sounds/button.mp3`),
|
||||
)
|
||||
const sound = new Audio(`${import.meta.env.BASE_URL}sounds/button.mp3`)
|
||||
sound.preload = 'auto'
|
||||
sound.volume = 0.55
|
||||
buttonSounds.push(sound)
|
||||
@@ -402,7 +399,7 @@ onBeforeUnmount(() => {
|
||||
rgb(30 38 42 / 35%),
|
||||
rgb(0 0 0 / 84%) 72%
|
||||
);
|
||||
font-family: var(--sky-font-family);
|
||||
font-family: 'Segoe UI', Arial, sans-serif;
|
||||
pointer-events: auto;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
@@ -5,15 +5,8 @@ import { describe, expect, it } from 'vitest'
|
||||
const source = readFileSync(
|
||||
new URL('./PhoneDynamicIsland.vue', import.meta.url),
|
||||
'utf8',
|
||||
).replace(/\r\n/g, '\n')
|
||||
const appSource = readFileSync(
|
||||
new URL('../App.vue', import.meta.url),
|
||||
'utf8',
|
||||
).replace(/\r\n/g, '\n')
|
||||
const mainCss = readFileSync(
|
||||
new URL('../assets/main.css', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const appSource = readFileSync(new URL('../App.vue', import.meta.url), 'utf8')
|
||||
const recorderSource = readFileSync(
|
||||
new URL('./PhoneMemoRecorder.vue', import.meta.url),
|
||||
'utf8',
|
||||
@@ -26,23 +19,16 @@ const clockSource = readFileSync(
|
||||
new URL('../views/apps/ClockApp.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const serverMemosSource = readFileSync(
|
||||
new URL('../../../sky_phone/source/server/memos.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
describe('Phone Dynamic Island contract', () => {
|
||||
it('renders once at phone shell level instead of forcing calls into Phone', () => {
|
||||
expect(appSource).toContain(
|
||||
"import PhoneDynamicIsland from '@/components/PhoneDynamicIsland.vue'",
|
||||
)
|
||||
expect(appSource).toContain('<PhoneDynamicIsland v-if="!setupRequired" />')
|
||||
expect(appSource).toContain(
|
||||
"'phone-device--island-expanded': dynamicIslandExpanded",
|
||||
'phone.isOpen || notifications.current || calls.activeCall',
|
||||
)
|
||||
expect(appSource).toMatch(
|
||||
/class="phone-device__frame"[\s\S]*?<PhoneDynamicIsland[\s\S]*?@expanded-change="dynamicIslandExpanded = \$event"[\s\S]*?@live-activity-change="dynamicIslandActivity = \$event"/,
|
||||
)
|
||||
expect(appSource).toContain('dynamicIslandActivity ||')
|
||||
expect(appSource).not.toContain(
|
||||
"window.setTimeout(() => void router.push('/apps/phone'), 0)",
|
||||
)
|
||||
@@ -59,44 +45,7 @@ describe('Phone Dynamic Island contract', () => {
|
||||
expect(source).toContain('call.answeredAt ?? call.startedAt')
|
||||
})
|
||||
|
||||
it('hides an activity in its owning foreground app but restores it after closing', () => {
|
||||
expect(source).toContain(
|
||||
'if (!currentActivity || !phone.isOpen) return currentActivity',
|
||||
)
|
||||
expect(source).toContain("activeAppId.value === 'phone'")
|
||||
expect(source).toContain(
|
||||
"currentActivity === 'recording' && activeAppId.value === 'memos'",
|
||||
)
|
||||
expect(source).toContain("activeAppId.value === 'clock'")
|
||||
expect(source).toContain(
|
||||
"currentActivity === 'music' && activeAppId.value === 'music'",
|
||||
)
|
||||
})
|
||||
|
||||
it('shows only a compact frame and island for background live activities', () => {
|
||||
expect(source).toContain("'live-activity-change': [activity:")
|
||||
expect(source).toContain("emit('live-activity-change', nextActivity)")
|
||||
expect(appSource).toContain(
|
||||
"'phone-stage--live-activity':\n !phone.isOpen && Boolean(dynamicIslandActivity || calls.activeCall)",
|
||||
)
|
||||
expect(mainCss).toMatch(
|
||||
/\.phone-stage--live-activity[\s\S]*?pointer-events:\s*none;[\s\S]*?--phone-live-activity-peek-height/,
|
||||
)
|
||||
expect(mainCss).toMatch(
|
||||
/\.phone-stage--live-activity[\s\S]*?> :not\(\.phone-screen\):not\(\.phone-device__frame\):not\(\.phone-dynamic-island\)[\s\S]*?\.phone-screen > \*[\s\S]*?visibility:\s*hidden;/,
|
||||
)
|
||||
expect(appSource).toContain("? '190px'")
|
||||
expect(appSource).toContain("? '132px'")
|
||||
expect(appSource).toContain(": '112px'")
|
||||
expect(source).toContain(
|
||||
"!phone.isOpen ||\n activity.value === 'incoming-call' ||",
|
||||
)
|
||||
})
|
||||
|
||||
it('connects music, recorder, timer, and stopwatch controls to their stores', () => {
|
||||
expect(source).toContain(
|
||||
"if (music.isPlaying && music.currentTrack) return 'music'",
|
||||
)
|
||||
expect(source).toContain('@click.stop="music.previous()"')
|
||||
expect(source).toContain('@click.stop="music.toggle()"')
|
||||
expect(source).toContain('@click.stop="music.next()"')
|
||||
@@ -104,31 +53,9 @@ describe('Phone Dynamic Island contract', () => {
|
||||
expect(source).toContain('clock.pauseTimer(Date.now())')
|
||||
expect(source).toContain('clock.pauseStopwatch(Date.now())')
|
||||
expect(source).toContain('clock.addLap(Date.now())')
|
||||
expect(source).not.toContain('phone-dynamic-island__lap')
|
||||
expect(source).toContain('phone-dynamic-island__stopwatch-meta')
|
||||
expect(source).toContain('{{ stopwatchLapLabel }}')
|
||||
expect(source).toContain('{{ stopwatchLapValue }}')
|
||||
expect(source).toContain('{{ stopwatchTotalDisplay }}')
|
||||
})
|
||||
|
||||
it('matches the reference music player and timer control layouts', () => {
|
||||
expect(source).toContain('phone-dynamic-island__music-equalizer')
|
||||
expect(source).toContain('phone-dynamic-island__progress-track')
|
||||
expect(source).toContain('{{ musicElapsedLabel }}')
|
||||
expect(source).toContain('{{ musicRemainingLabel }}')
|
||||
expect(source).not.toContain('Airplay')
|
||||
expect(source).toContain('<X aria-hidden="true" />')
|
||||
expect(source).toMatch(
|
||||
/\.phone-dynamic-island--timer\.phone-dynamic-island__copy|\.phone-dynamic-island--timer \.phone-dynamic-island__copy/,
|
||||
)
|
||||
expect(source).toContain(
|
||||
'.phone-dynamic-island--music .phone-dynamic-island__actions--media',
|
||||
)
|
||||
expect(source).toContain('justify-content: center')
|
||||
expect(source).toContain('gap: 34px')
|
||||
})
|
||||
|
||||
it('keeps recorder state available across app changes and phone closes', () => {
|
||||
it('keeps recorder state available across app changes', () => {
|
||||
expect(recorderSource).toContain(
|
||||
"message.type === 'memo:recordStateRequest'",
|
||||
)
|
||||
@@ -138,15 +65,6 @@ describe('Phone Dynamic Island contract', () => {
|
||||
expect(memosSource).not.toContain(
|
||||
"if (recordingActive.value) postRecorderCommand('memo:recordCancel')",
|
||||
)
|
||||
expect(recorderSource).toContain('() => phone.device?.imei ?? null')
|
||||
expect(recorderSource).not.toContain(
|
||||
'if (!isOpen || deviceSessionChanged) cancelRecording()',
|
||||
)
|
||||
expect(recorderSource).toContain('deviceImei: finalDeviceImei')
|
||||
expect(serverMemosSource).toContain('device_owner(src, data.deviceImei)')
|
||||
expect(serverMemosSource).toContain(
|
||||
'SkyPhone.FindDeviceSlots(source, imei)[1]',
|
||||
)
|
||||
})
|
||||
|
||||
it('opens both clock live activities on the correct clock tab', () => {
|
||||
@@ -157,68 +75,13 @@ describe('Phone Dynamic Island contract', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('opens activities by tapping the island without a separate expand icon', () => {
|
||||
expect(source).toContain('@click.stop="toggleExpanded"')
|
||||
expect(source).toContain('@click.stop="openActivity"')
|
||||
expect(source).not.toContain('Maximize2')
|
||||
expect(source).not.toContain('phone-dynamic-island__open-icon')
|
||||
})
|
||||
|
||||
it('collapses expanded activities on taps, swipes, and scrolling outside', () => {
|
||||
expect(source).toContain('ref="islandElement"')
|
||||
expect(source).toContain(
|
||||
"document.addEventListener('pointerdown', onOutsidePointerDown, true)",
|
||||
)
|
||||
expect(source).toContain(
|
||||
"document.addEventListener('scroll', collapseExpanded, true)",
|
||||
)
|
||||
expect(source).toContain('islandElement.value?.contains(event.target)')
|
||||
expect(source).toContain('expanded.value = false')
|
||||
expect(source).toContain(
|
||||
"document.removeEventListener('pointerdown', onOutsidePointerDown, true)",
|
||||
)
|
||||
expect(source).toContain(
|
||||
"document.removeEventListener('scroll', collapseExpanded, true)",
|
||||
)
|
||||
})
|
||||
|
||||
it('animates state changes and moves popup notifications below expanded UI', () => {
|
||||
expect(source).toContain('<Transition name="phone-dynamic-island">')
|
||||
expect(source).toContain(
|
||||
'<Transition name="phone-dynamic-island-content" mode="out-in">',
|
||||
)
|
||||
expect(source).toContain(".phone-dynamic-island[data-expanded='true']")
|
||||
expect(source).toContain("emit('expanded-change', false)")
|
||||
expect(mainCss).toContain(
|
||||
'.phone-device--island-expanded .phone-notification',
|
||||
)
|
||||
expect(source).toContain('~ .phone-notification-provider')
|
||||
expect(source).toContain('@media (prefers-reduced-motion: reduce)')
|
||||
})
|
||||
|
||||
it('renders below the top edge and above the physical camera frame', () => {
|
||||
expect(source).toMatch(
|
||||
/\.phone-dynamic-island\s*\{[^}]*z-index:\s*102;[^}]*top:\s*30px;/s,
|
||||
)
|
||||
expect(mainCss).not.toMatch(/\.phone-dynamic-island\s*\{/)
|
||||
})
|
||||
|
||||
it('keeps compact and expanded islands close to the physical camera proportions', () => {
|
||||
expect(source).toMatch(
|
||||
/\.phone-dynamic-island\s*\{[^}]*width:\s*126px;[^}]*height:\s*38px;/s,
|
||||
)
|
||||
expect(source).toMatch(
|
||||
/\.phone-dynamic-island\[data-expanded='true'\]\s*\{[^}]*width:\s*318px;[^}]*height:\s*74px;/s,
|
||||
)
|
||||
expect(source).toMatch(
|
||||
/\.phone-dynamic-island--incoming-call\[data-expanded='true'\]\s*\{[^}]*height:\s*68px;/s,
|
||||
)
|
||||
expect(source).toMatch(
|
||||
/\.phone-dynamic-island--music\[data-expanded='true'\]\s*\{[^}]*width:\s*316px;[^}]*height:\s*150px;/s,
|
||||
)
|
||||
expect(source).toMatch(
|
||||
/\.phone-dynamic-island--stopwatch\[data-expanded='true'\]\s*\{[^}]*height:\s*70px;/s,
|
||||
)
|
||||
expect(source).toContain('box-sizing: border-box')
|
||||
expect(source).toContain('padding: 8px 16px 8px 10px')
|
||||
})
|
||||
})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,12 +14,6 @@ import { isTrustedRootMessageSource } from '@/utils/windowMessages'
|
||||
|
||||
type RecordingChunk = { blob: Blob; durationMs: number }
|
||||
type PendingVideo = { blob: Blob; fileName: string }
|
||||
type UploadFailureDebug = {
|
||||
correlationId: string
|
||||
message: string
|
||||
stage: string
|
||||
status?: number
|
||||
}
|
||||
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
||||
const pendingVideos = new Map<string, PendingVideo>()
|
||||
@@ -136,10 +130,7 @@ function cleanupRecording(): void {
|
||||
try {
|
||||
activeRecorder.stop()
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[Camera] Could not stop the failed media recorder.',
|
||||
error,
|
||||
)
|
||||
console.error('[Camera] Could not stop the failed media recorder.', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -254,7 +245,8 @@ async function startRecording(data: Record<string, unknown>): Promise<void> {
|
||||
const activeRecorder = recorder
|
||||
removeRecorderErrorListener = bindMediaRecorderError(
|
||||
activeRecorder,
|
||||
() => generation === recordingGeneration && recorder === activeRecorder,
|
||||
() =>
|
||||
generation === recordingGeneration && recorder === activeRecorder,
|
||||
(event) => {
|
||||
console.error('[Camera] Media recorder failed while recording.', event)
|
||||
cleanupRecording()
|
||||
@@ -425,32 +417,8 @@ async function capturePhotoBlob(ready: UploadReady): Promise<Blob> {
|
||||
}
|
||||
}
|
||||
|
||||
async function failUpload(
|
||||
requestId: string,
|
||||
error: string,
|
||||
debug: UploadFailureDebug,
|
||||
): Promise<void> {
|
||||
console.error('[Sky Phone Media] Upload failed.', {
|
||||
correlationId: debug.correlationId,
|
||||
detail: debug.message,
|
||||
error,
|
||||
stage: debug.stage,
|
||||
status: debug.status,
|
||||
})
|
||||
const response = await nuiCall('media:failUpload', {
|
||||
correlationId: debug.correlationId,
|
||||
debugMessage: debug.message,
|
||||
debugStage: debug.stage,
|
||||
debugStatus: debug.status,
|
||||
error,
|
||||
requestId,
|
||||
})
|
||||
if (!response.success) {
|
||||
console.error('[Sky Phone Media] Could not forward upload diagnostics.', {
|
||||
correlationId: debug.correlationId,
|
||||
error: response.error,
|
||||
})
|
||||
}
|
||||
async function failUpload(requestId: string, error: string): Promise<void> {
|
||||
await nuiCall('media:failUpload', { error, requestId })
|
||||
}
|
||||
|
||||
async function uploadReady(ready: UploadReady): Promise<void> {
|
||||
@@ -467,87 +435,49 @@ async function uploadReady(ready: UploadReady): Promise<void> {
|
||||
blob = await capturePhotoBlob(ready)
|
||||
fileName = `camera-${ready.correlationId}.${ready.photo?.Encoding ?? 'jpg'}`
|
||||
}
|
||||
} catch (error) {
|
||||
await failUpload(ready.requestId, 'capture_failed', {
|
||||
correlationId: ready.correlationId,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
stage: 'capture',
|
||||
})
|
||||
} catch {
|
||||
await failUpload(ready.requestId, 'capture_failed')
|
||||
return
|
||||
}
|
||||
|
||||
console.info('[Sky Phone Media] Capture prepared for upload.', {
|
||||
bytes: blob.size,
|
||||
correlationId: ready.correlationId,
|
||||
mimeType: blob.type,
|
||||
type: ready.mediaType,
|
||||
})
|
||||
|
||||
const form = new FormData()
|
||||
form.append('file', blob, fileName)
|
||||
form.append(
|
||||
'metadata',
|
||||
JSON.stringify({ captureToken: ready.captureToken, source: 'sky_phone' }),
|
||||
)
|
||||
const controller = new AbortController()
|
||||
const timeout = window.setTimeout(
|
||||
() => controller.abort(),
|
||||
ready.uploadTimeoutMs ?? 25000,
|
||||
)
|
||||
let debugStage = 'provider_request'
|
||||
let debugStatus: number | undefined
|
||||
try {
|
||||
const response = await fetch(ready.presignedUrl, {
|
||||
body: form,
|
||||
method: 'POST',
|
||||
signal: controller.signal,
|
||||
})
|
||||
debugStatus = response.status
|
||||
debugStage = 'provider_response'
|
||||
console.info('[Sky Phone Media] FiveManage upload responded.', {
|
||||
correlationId: ready.correlationId,
|
||||
status: response.status,
|
||||
})
|
||||
const text = await response.text()
|
||||
const body = JSON.parse(text) as {
|
||||
data?: { id?: string; url?: string }
|
||||
error?: string
|
||||
id?: string
|
||||
message?: string
|
||||
url?: string
|
||||
}
|
||||
const uploaded = body.data ?? body
|
||||
if (!response.ok || !uploaded.id || !uploaded.url) {
|
||||
throw new Error(
|
||||
(typeof body.error === 'string' && body.error) ||
|
||||
(typeof body.message === 'string' && body.message) ||
|
||||
'upload_failed',
|
||||
)
|
||||
throw new Error('upload_failed')
|
||||
}
|
||||
debugStage = 'completion_callback'
|
||||
const completion = await nuiCall('media:completeUpload', {
|
||||
correlationId: ready.correlationId,
|
||||
await nuiCall('media:completeUpload', {
|
||||
remoteId: uploaded.id,
|
||||
requestId: ready.requestId,
|
||||
url: uploaded.url,
|
||||
})
|
||||
if (!completion.success) {
|
||||
throw new Error(completion.error ?? 'completion_callback_failed')
|
||||
}
|
||||
console.info(
|
||||
'[Sky Phone Media] Upload completion forwarded to the server.',
|
||||
{
|
||||
correlationId: ready.correlationId,
|
||||
},
|
||||
)
|
||||
} catch (error) {
|
||||
await failUpload(
|
||||
ready.requestId,
|
||||
error instanceof DOMException && error.name === 'AbortError'
|
||||
? 'upload_timeout'
|
||||
: 'upload_failed',
|
||||
{
|
||||
correlationId: ready.correlationId,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
stage: debugStage,
|
||||
status: debugStatus,
|
||||
},
|
||||
)
|
||||
} finally {
|
||||
window.clearTimeout(timeout)
|
||||
@@ -593,12 +523,7 @@ function onMessage(event: MessageEvent): void {
|
||||
)
|
||||
}
|
||||
} else if (message.type === 'media:uploadReady') {
|
||||
const ready = message.data as UploadReady
|
||||
console.info('[Sky Phone Media] Upload-ready received.', {
|
||||
correlationId: ready.correlationId,
|
||||
type: ready.mediaType,
|
||||
})
|
||||
void uploadReady(ready)
|
||||
void uploadReady(message.data as UploadReady)
|
||||
} else if (message.type === 'media:uploadResult') {
|
||||
const correlationId = String(message.data?.correlationId ?? '')
|
||||
if (correlationId) pendingVideos.delete(correlationId)
|
||||
|
||||
@@ -54,7 +54,6 @@ let metadata: MemoRecordingMetadata = { note: '', pinned: false, title: '' }
|
||||
let currentState: MemoRecorderStateName = 'idle'
|
||||
let currentElapsedMs = 0
|
||||
let currentCorrelationId = ''
|
||||
let recordingDeviceImei = ''
|
||||
let removeRecorderErrorListener: (() => void) | null = null
|
||||
|
||||
function postRecorderState(state: MemoRecorderStateName, error?: string): void {
|
||||
@@ -154,7 +153,6 @@ function resetRecordingData(): void {
|
||||
pausedStartedAt = 0
|
||||
totalPausedMs = 0
|
||||
currentElapsedMs = 0
|
||||
recordingDeviceImei = ''
|
||||
liveLevels = Array(LIVE_LEVEL_SAMPLES).fill(0.08)
|
||||
}
|
||||
|
||||
@@ -191,8 +189,7 @@ function failRecording(error: string, generation = recordingGeneration): void {
|
||||
}
|
||||
|
||||
async function startRecording(data: Record<string, unknown>): Promise<void> {
|
||||
const deviceImei = phone.device?.imei
|
||||
if (!phone.isOpen || !deviceImei) {
|
||||
if (!phone.isOpen) {
|
||||
console.error('[Memos] Cannot start a recording while the phone is closed.')
|
||||
return
|
||||
}
|
||||
@@ -220,7 +217,6 @@ async function startRecording(data: Record<string, unknown>): Promise<void> {
|
||||
metadata = { note: '', pinned: false, title: '' }
|
||||
updateMetadata(data)
|
||||
resetRecordingData()
|
||||
recordingDeviceImei = deviceImei
|
||||
postRecorderState('starting')
|
||||
try {
|
||||
const acquiredStream = await navigator.mediaDevices.getUserMedia({
|
||||
@@ -344,7 +340,6 @@ async function stopRecording(data: Record<string, unknown>): Promise<void> {
|
||||
if (generation !== recordingGeneration) return
|
||||
const waveform = compressedWaveform()
|
||||
const finalMetadata = { ...metadata }
|
||||
const finalDeviceImei = recordingDeviceImei
|
||||
const exceededSizeLimit = recordingTooLarge
|
||||
cleanupRecorder(false)
|
||||
resetRecordingData()
|
||||
@@ -360,12 +355,10 @@ async function stopRecording(data: Record<string, unknown>): Promise<void> {
|
||||
liveLevels = waveform.slice(-LIVE_LEVEL_SAMPLES)
|
||||
const uploadData = {
|
||||
correlationId,
|
||||
deviceImei: finalDeviceImei,
|
||||
durationMs,
|
||||
mimeType,
|
||||
note: finalMetadata.note,
|
||||
pinned: finalMetadata.pinned,
|
||||
sizeBytes: blob.size,
|
||||
title: finalMetadata.title,
|
||||
waveform,
|
||||
}
|
||||
@@ -459,6 +452,14 @@ async function uploadReady(ready: MemoUploadReady): Promise<void> {
|
||||
pending.requestId = ready.requestId
|
||||
const form = new FormData()
|
||||
form.append('file', pending.blob, pending.fileName)
|
||||
form.append(
|
||||
'metadata',
|
||||
JSON.stringify({
|
||||
captureToken: ready.captureToken,
|
||||
purpose: 'memo',
|
||||
source: 'sky_phone',
|
||||
}),
|
||||
)
|
||||
const controller = new AbortController()
|
||||
pending.abortController = controller
|
||||
const timeout = window.setTimeout(
|
||||
@@ -568,9 +569,18 @@ function onMessage(event: MessageEvent): void {
|
||||
onMounted(() => window.addEventListener('message', onMessage))
|
||||
|
||||
watch(
|
||||
() => phone.device?.imei ?? null,
|
||||
(imei, previousImei) => {
|
||||
if (previousImei && imei !== previousImei) cancelRecording()
|
||||
() =>
|
||||
[
|
||||
phone.isOpen,
|
||||
phone.device?.imei ?? null,
|
||||
phone.deviceSessionToken,
|
||||
] as const,
|
||||
([isOpen, imei, sessionToken], previous) => {
|
||||
const deviceSessionChanged =
|
||||
previous !== undefined &&
|
||||
previous[0] &&
|
||||
(previous[1] !== imei || previous[2] !== sessionToken)
|
||||
if (!isOpen || deviceSessionChanged) cancelRecording()
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -61,22 +61,16 @@ const passcodeError = ref('')
|
||||
const passcodeLength = ref<4 | 6>(phone.security.length === 4 ? 4 : 6)
|
||||
const notificationsEnabled = ref(true)
|
||||
const notificationSounds = ref(true)
|
||||
const selectedApps = ref<BuiltinPhoneAppId[]>(
|
||||
(['banking', 'garage', 'skyride'] as const).filter((id) =>
|
||||
appStore.isAvailable(id),
|
||||
),
|
||||
)
|
||||
const selectedApps = ref<BuiltinPhoneAppId[]>(['banking', 'garage', 'skyride'])
|
||||
const setupCompleteBusy = ref(false)
|
||||
const setupCompleteError = ref('')
|
||||
|
||||
const setupApps = computed(() =>
|
||||
(
|
||||
['banking', 'garage', 'skyride', 'citymarkt', 'picstagram', 'snake'] as const
|
||||
).flatMap((id) => {
|
||||
const app = getPhoneApp(id)
|
||||
return app && appStore.isAvailable(id) ? [app] : []
|
||||
}),
|
||||
)
|
||||
const setupApps = (
|
||||
['banking', 'garage', 'skyride', 'citymarkt', 'picstagram', 'snake'] as const
|
||||
).flatMap((id) => {
|
||||
const app = getPhoneApp(id)
|
||||
return app ? [app] : []
|
||||
})
|
||||
const wallpaperChoices = WALLPAPER_IDS
|
||||
const progress = computed(() => `${((step.value + 1) / 10) * 100}%`)
|
||||
const displayName = computed(() => {
|
||||
|
||||
@@ -210,7 +210,7 @@ onBeforeUnmount(() => {
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
pointer-events: none;
|
||||
font-family: var(--sky-font-family);
|
||||
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.radio-hud[data-horizontal='left'] {
|
||||
|
||||
@@ -34,15 +34,6 @@ describe('SpringboardWidget UI contract', () => {
|
||||
expect(source).toContain('music.progress.value')
|
||||
})
|
||||
|
||||
it('fades empty music artwork downward while keeping its message above the fade', () => {
|
||||
expect(source).toContain('.home-widget--music-empty::after')
|
||||
expect(source).toContain(
|
||||
'.home-widget--music-empty .widget-music-placeholder',
|
||||
)
|
||||
expect(source).toContain('-webkit-mask-image: linear-gradient(')
|
||||
expect(source).toMatch(/\.widget-music-empty\s*{[\s\S]*?z-index:\s*2;/)
|
||||
})
|
||||
|
||||
it.each([
|
||||
'sunny',
|
||||
'clear',
|
||||
|
||||
@@ -677,7 +677,9 @@ onBeforeUnmount(() => {
|
||||
backdrop-filter: blur(26px) saturate(125%);
|
||||
-webkit-backdrop-filter: blur(26px) saturate(125%);
|
||||
cursor: pointer;
|
||||
font-family: var(--sky-font-family);
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'SF Pro Text',
|
||||
'Segoe UI', sans-serif;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
touch-action: none;
|
||||
@@ -1074,39 +1076,6 @@ onBeforeUnmount(() => {
|
||||
background: rgb(39 39 42 / 95%);
|
||||
}
|
||||
|
||||
.home-widget--music-empty::after {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
inset: 0;
|
||||
content: '';
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
transparent 34%,
|
||||
rgb(20 20 22 / 18%) 62%,
|
||||
rgb(16 16 18 / 82%) 100%
|
||||
);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.home-widget--music-empty .widget-album,
|
||||
.home-widget--music-empty .widget-music-placeholder {
|
||||
-webkit-mask-image: linear-gradient(
|
||||
to bottom,
|
||||
#000 0%,
|
||||
#000 42%,
|
||||
rgb(0 0 0 / 42%) 72%,
|
||||
transparent 100%
|
||||
);
|
||||
mask-image: linear-gradient(
|
||||
to bottom,
|
||||
#000 0%,
|
||||
#000 42%,
|
||||
rgb(0 0 0 / 42%) 72%,
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
|
||||
.home-widget-shell--large .home-widget--music {
|
||||
align-content: start;
|
||||
grid-template-columns: 1fr;
|
||||
@@ -1199,12 +1168,12 @@ onBeforeUnmount(() => {
|
||||
.widget-music-empty {
|
||||
position: absolute;
|
||||
right: 15px;
|
||||
bottom: 14px;
|
||||
bottom: 11px;
|
||||
left: 15px;
|
||||
z-index: 2;
|
||||
z-index: 0;
|
||||
margin: 0;
|
||||
color: rgb(255 255 255 / 72%);
|
||||
font-size: 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
line-height: 17px;
|
||||
text-align: center;
|
||||
|
||||
@@ -748,7 +748,6 @@ export function getPhoneAppLabel(
|
||||
}
|
||||
|
||||
export function isPhoneAppRemovable(app: PhoneAppDefinition): boolean {
|
||||
if (app.adminOnly) return false
|
||||
return app.kind === 'external'
|
||||
? app.removable && !app.defaultInstalled
|
||||
: !DEFAULT_INSTALLED_PHONE_APP_IDS.has(app.id) &&
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import type { Directive } from 'vue'
|
||||
|
||||
const inputListeners = new WeakMap<HTMLInputElement, () => void>()
|
||||
const MINIMUM_INPUT_WIDTH = 42
|
||||
|
||||
function syncInputWidth(input: HTMLInputElement): void {
|
||||
input.style.width = '1px'
|
||||
input.style.width = `${Math.max(MINIMUM_INPUT_WIDTH, Math.ceil(input.scrollWidth) + 2)}px`
|
||||
}
|
||||
|
||||
export const vConfigInputWidth: Directive<HTMLInputElement> = {
|
||||
mounted(input) {
|
||||
const listener = () => syncInputWidth(input)
|
||||
inputListeners.set(input, listener)
|
||||
input.addEventListener('input', listener)
|
||||
syncInputWidth(input)
|
||||
},
|
||||
updated(input) {
|
||||
syncInputWidth(input)
|
||||
},
|
||||
beforeUnmount(input) {
|
||||
const listener = inputListeners.get(input)
|
||||
if (listener) input.removeEventListener('input', listener)
|
||||
inputListeners.delete(input)
|
||||
},
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
import { getPhoneOutputVolume } from '@/utils/phoneAudio'
|
||||
|
||||
export type MemorySound = 'flip' | 'match' | 'mismatch' | 'win'
|
||||
|
||||
type Tone = {
|
||||
@@ -48,10 +46,7 @@ export function playMemorySound(sound: MemorySound, enabled: boolean): void {
|
||||
oscillator.type = tone.type
|
||||
oscillator.frequency.setValueAtTime(tone.frequency, start)
|
||||
gain.gain.setValueAtTime(0.0001, start)
|
||||
gain.gain.exponentialRampToValueAtTime(
|
||||
Math.max(0.0001, tone.volume * getPhoneOutputVolume()),
|
||||
start + 0.012,
|
||||
)
|
||||
gain.gain.exponentialRampToValueAtTime(tone.volume, start + 0.012)
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, end)
|
||||
oscillator.connect(gain)
|
||||
gain.connect(audioContext.destination)
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { registerPhoneMediaElement } from '@/utils/phoneAudio'
|
||||
|
||||
import flagUrl from '@/assets/audio/minesweeper/flag.wav?url'
|
||||
import clearUrl from '@/assets/audio/minesweeper/clear.wav?url'
|
||||
import mineUrl from '@/assets/audio/minesweeper/mine.wav?url'
|
||||
@@ -30,7 +28,7 @@ function getPlayers(sound: MinesweeperSound): HTMLAudioElement[] {
|
||||
if (existing) return existing
|
||||
|
||||
const players = Array.from({ length: 3 }, () => {
|
||||
const player = registerPhoneMediaElement(new Audio(soundUrls[sound]))
|
||||
const player = new Audio(soundUrls[sound])
|
||||
player.preload = 'auto'
|
||||
player.volume = 0.82
|
||||
return player
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { getPhoneOutputVolume } from '@/utils/phoneAudio'
|
||||
|
||||
export type NeonDropSound =
|
||||
| 'clear'
|
||||
| 'drop'
|
||||
@@ -50,10 +48,7 @@ function playSequence(sound: NeonDropSound): void {
|
||||
oscillator.type = type
|
||||
oscillator.frequency.setValueAtTime(frequency, start + delay)
|
||||
gain.gain.setValueAtTime(0.0001, start + delay)
|
||||
gain.gain.exponentialRampToValueAtTime(
|
||||
Math.max(0.0001, 0.12 * getPhoneOutputVolume()),
|
||||
start + delay + 0.008,
|
||||
)
|
||||
gain.gain.exponentialRampToValueAtTime(0.12, start + delay + 0.008)
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, start + delay + duration)
|
||||
oscillator.connect(gain)
|
||||
gain.connect(context.destination)
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { registerPhoneMediaElement } from '@/utils/phoneAudio'
|
||||
|
||||
import gameOverUrl from '@/assets/audio/number-merge/game-over.wav?url'
|
||||
import mergeUrl from '@/assets/audio/number-merge/merge.wav?url'
|
||||
import moveUrl from '@/assets/audio/number-merge/move.wav?url'
|
||||
@@ -20,7 +18,7 @@ function getPlayers(sound: NumberMergeSound): HTMLAudioElement[] {
|
||||
if (existing) return existing
|
||||
|
||||
const players = Array.from({ length: 3 }, () => {
|
||||
const player = registerPhoneMediaElement(new Audio(soundUrls[sound]))
|
||||
const player = new Audio(soundUrls[sound])
|
||||
player.preload = 'auto'
|
||||
player.volume = 0.82
|
||||
return player
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { registerPhoneMediaElement } from '@/utils/phoneAudio'
|
||||
|
||||
import crashUrl from '@/assets/audio/sky-flappy/crash.wav?url'
|
||||
import flapUrl from '@/assets/audio/sky-flappy/flap.wav?url'
|
||||
import pointUrl from '@/assets/audio/sky-flappy/point.wav?url'
|
||||
@@ -13,7 +11,7 @@ export function playSkyFlappySound(sound: SkyFlappySound, enabled: boolean): voi
|
||||
let players = pools.get(sound)
|
||||
if (!players) {
|
||||
players = Array.from({ length: 3 }, () => {
|
||||
const player = registerPhoneMediaElement(new Audio(urls[sound]))
|
||||
const player = new Audio(urls[sound])
|
||||
player.preload = 'auto'
|
||||
player.volume = 0.84
|
||||
return player
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { registerPhoneMediaElement } from '@/utils/phoneAudio'
|
||||
|
||||
import fallUrl from '@/assets/audio/tower-stack/fall.wav?url'
|
||||
import hitUrl from '@/assets/audio/tower-stack/hit.wav?url'
|
||||
import perfectUrl from '@/assets/audio/tower-stack/perfect.wav?url'
|
||||
@@ -20,7 +18,7 @@ function getPlayers(sound: TowerStackSound): HTMLAudioElement[] {
|
||||
if (existing) return existing
|
||||
|
||||
const players = Array.from({ length: 3 }, () => {
|
||||
const player = registerPhoneMediaElement(new Audio(soundUrls[sound]))
|
||||
const player = new Audio(soundUrls[sound])
|
||||
player.preload = 'auto'
|
||||
player.volume = 0.84
|
||||
return player
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const readResourceFile = (path: string) =>
|
||||
readFileSync(new URL(`../../sky_phone/${path}`, import.meta.url), 'utf8')
|
||||
|
||||
describe('LB runtime compatibility contracts', () => {
|
||||
it('routes legacy call and SMS actions through Sky Phone', () => {
|
||||
const callsClient = readResourceFile('source/client/calls.lua')
|
||||
const phoneBridge = readResourceFile('source/bridge/phones/client/lb.lua')
|
||||
const callsServer = readResourceFile('source/server/calls.lua')
|
||||
const phoneUi = readFileSync(new URL('./App.vue', import.meta.url), 'utf8')
|
||||
const customAppFrame = readFileSync(
|
||||
new URL('./components/CustomAppFrame.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
expect(phoneBridge).toContain(
|
||||
'SkyPhoneCompatibility.RegisterExportAlias("lb-phone", "CreateCall", create_call)',
|
||||
)
|
||||
expect(callsClient).toContain(
|
||||
'Bridge.Callbacks.Trigger("sky_phone:calls:dial"',
|
||||
)
|
||||
expect(callsServer).toContain(
|
||||
'SkyPhoneCompanies.GetServiceLineForCompany(data.company)',
|
||||
)
|
||||
expect(phoneBridge).toContain(
|
||||
'SkyPhoneCompatibility.RegisterExportAlias("lb-phone", "CreateSMS", create_sms)',
|
||||
)
|
||||
expect(phoneBridge).toContain('type = "compat:open-messages"')
|
||||
expect(phoneUi).toContain("event.data?.type === 'compat:open-messages'")
|
||||
expect(phoneUi).toContain('messages.openThread(data.phoneNumber)')
|
||||
expect(customAppFrame).toContain("message.action === 'createCall'")
|
||||
expect(customAppFrame).toContain("message.action === 'createSMS'")
|
||||
})
|
||||
|
||||
it('redirects PicChat phone identity to the Sky SIM table without deleting orphaned data', () => {
|
||||
const manifest = readResourceFile('fxmanifest.lua')
|
||||
const migration = readResourceFile(
|
||||
'source/server/lb_app_compat_migration.lua',
|
||||
)
|
||||
|
||||
expect(manifest).toContain("'source/server/lb_app_compat_migration.lua'")
|
||||
expect(
|
||||
manifest.indexOf("'source/server/lb_app_compat_migration.lua'"),
|
||||
).toBeGreaterThan(manifest.indexOf("'source/server/testdata.lua'"))
|
||||
expect(migration).toContain('"lbpicchat_logged_in"')
|
||||
expect(migration).toContain('"phone_phones"')
|
||||
expect(migration).toContain('"sky_phone_sims"')
|
||||
expect(migration).toContain('DROP FOREIGN KEY')
|
||||
expect(migration).toContain('ON DELETE CASCADE ON UPDATE CASCADE')
|
||||
expect(migration).toContain('Legacy data was preserved')
|
||||
expect(migration).toContain('FROM `INFORMATION_SCHEMA`.`KEY_COLUMN_USAGE`')
|
||||
expect(migration).not.toMatch(/KEY_COLUMN_USAGE`\s+keys/i)
|
||||
expect(migration).toContain(
|
||||
'xpcall(migrate_picchat_phone_reference, debug.traceback)',
|
||||
)
|
||||
expect(migration).toContain('Sky Phone startup will continue')
|
||||
expect(migration).not.toMatch(/DELETE\s+FROM\s+/i)
|
||||
})
|
||||
})
|
||||
@@ -1,103 +0,0 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const mediaConfig = readFileSync(
|
||||
new URL('../../sky_phone/config/media.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const manifest = readFileSync(
|
||||
new URL('../../sky_phone/fxmanifest.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const mediaProviderConfig = readFileSync(
|
||||
new URL(
|
||||
'../../sky_phone/source/server/media_provider_config.lua',
|
||||
import.meta.url,
|
||||
),
|
||||
'utf8',
|
||||
)
|
||||
const mediaImportAdapter = readFileSync(
|
||||
new URL(
|
||||
'../../sky_phone/source/server/media_import/fivemanage.lua',
|
||||
import.meta.url,
|
||||
),
|
||||
'utf8',
|
||||
)
|
||||
const mediaServer = readFileSync(
|
||||
new URL('../../sky_phone/source/server/media.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const memoServer = readFileSync(
|
||||
new URL('../../sky_phone/source/server/memos.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const mediaCapture = readFileSync(
|
||||
new URL('./components/PhoneMediaCapture.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const memoRecorder = readFileSync(
|
||||
new URL('./components/PhoneMemoRecorder.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
describe('FiveManage server configuration contract', () => {
|
||||
it('keeps the provider token in the server-only media config', () => {
|
||||
expect(mediaConfig).toMatch(/FiveManage\s*=\s*{\s*ApiKey\s*=/)
|
||||
expect(mediaProviderConfig).toContain(
|
||||
'return trim_key(Config.Media.FiveManage.ApiKey)',
|
||||
)
|
||||
expect(mediaProviderConfig).not.toContain('GetConvar')
|
||||
})
|
||||
|
||||
it('uses one resolver for Camera uploads and FiveManage imports', () => {
|
||||
expect(
|
||||
manifest.indexOf("'source/server/media_provider_config.lua'"),
|
||||
).toBeLessThan(manifest.indexOf("'source/server/media_import.lua'"))
|
||||
expect(mediaServer).toContain(
|
||||
'SkyPhoneMediaProviderConfig.FiveManageApiKey()',
|
||||
)
|
||||
expect(mediaImportAdapter).toContain(
|
||||
'SkyPhoneMediaProviderConfig.FiveManageApiKey(website.ApiKey)',
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the direct FiveManage upload response flow for Camera and voice memos', () => {
|
||||
expect(mediaConfig).not.toContain('VerificationRetryDelaysMs')
|
||||
expect(mediaCapture).toContain("form.append('file', blob, fileName)")
|
||||
expect(mediaCapture).not.toContain("form.append('path'")
|
||||
expect(mediaCapture).not.toContain("form.append(\n 'metadata'")
|
||||
expect(memoRecorder).toContain(
|
||||
"form.append('file', pending.blob, pending.fileName)",
|
||||
)
|
||||
expect(memoRecorder).not.toContain("form.append('path'")
|
||||
expect(memoRecorder).not.toContain("form.append(\n 'metadata'")
|
||||
expect(mediaServer).toContain(
|
||||
'Accepting the direct FiveManage upload response',
|
||||
)
|
||||
expect(mediaServer).toContain('remote_id = remote_id')
|
||||
expect(mediaServer).toContain('url = uploaded_url')
|
||||
expect(mediaServer).not.toContain('"HEAD"')
|
||||
expect(mediaServer).not.toContain('authenticated upload-path lookup')
|
||||
})
|
||||
|
||||
it('allowlists the FiveManage API and media hosts', () => {
|
||||
expect(mediaServer).toContain('["api.fivemanage.com"] = true')
|
||||
expect(mediaServer).toContain('["fmapi.net"] = true')
|
||||
expect(mediaServer).toContain(
|
||||
'uploaded_host:lower() ~= "r2.fivemanage.com"',
|
||||
)
|
||||
})
|
||||
|
||||
it('validates and preserves the recorded memo size before upload', () => {
|
||||
expect(memoRecorder).toContain('sizeBytes: blob.size')
|
||||
expect(memoServer).toContain(
|
||||
'local size_bytes = tonumber(data.sizeBytes)',
|
||||
)
|
||||
expect(memoServer).toContain(
|
||||
'size_bytes < 1 or size_bytes > Config.Memos.MaximumBytes',
|
||||
)
|
||||
expect(memoServer).toContain('size_bytes = memo.size_bytes')
|
||||
expect(mediaServer).toContain('size = state.size_bytes')
|
||||
})
|
||||
})
|
||||
@@ -1,21 +0,0 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const mediaCapture = readFileSync(
|
||||
new URL('./components/PhoneMediaCapture.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const mediaServer = readFileSync(
|
||||
new URL('../../sky_phone/source/server/media.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
describe('media upload diagnostics contracts', () => {
|
||||
it('forwards browser upload failure context to the server log', () => {
|
||||
expect(mediaCapture).toContain("let debugStage = 'provider_request'")
|
||||
expect(mediaCapture).toContain('debugStatus: debug.status')
|
||||
expect(mediaServer).toContain('Client-reported upload failure')
|
||||
expect(mediaServer).toContain('diagnostic_text(data.debugMessage, 240)')
|
||||
})
|
||||
})
|
||||
@@ -1,83 +0,0 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
function source(path: string): string {
|
||||
return readFileSync(new URL(path, import.meta.url), 'utf8')
|
||||
}
|
||||
|
||||
const config = source('../../sky_phone/config/config.lua')
|
||||
const configDefault = source('../../sky_phone/source/shared/config_default.lua')
|
||||
const framework = source('../../sky_phone/source/bridge/server/framework.lua')
|
||||
const qbox = source('../../sky_phone/source/bridge/server/frameworks/qbox.lua')
|
||||
const configurator = source(
|
||||
'../../sky_phone/source/server/phone_configurator.lua',
|
||||
)
|
||||
const configuratorFixture = source('../testserver/configurator-fixture.cjs')
|
||||
|
||||
describe('fixed server permissions', () => {
|
||||
it('defines every protected phone capability only in config.lua', () => {
|
||||
expect(config).toContain('Config.CommandPermissions = {')
|
||||
for (const permission of [
|
||||
'phonepanel',
|
||||
'phonetestdata',
|
||||
'fliptokverify',
|
||||
'picstagramverify',
|
||||
'picstagramadmin',
|
||||
]) {
|
||||
expect(config).toMatch(new RegExp(`\\s${permission} = \\{`))
|
||||
}
|
||||
expect(config).not.toContain('AdminGroups =')
|
||||
expect(configDefault).not.toContain('Config.PhoneConfigurator')
|
||||
expect(configDefault).not.toContain('Config.CommandPermissions')
|
||||
expect(configDefault).not.toContain('AdminGroups =')
|
||||
})
|
||||
|
||||
it('keeps fixed permissions outside SQL and removes legacy group fields', () => {
|
||||
expect(configurator).toContain('key ~= "CommandPermissions"')
|
||||
expect(configurator).toContain('if key ~= "CommandPermissions" then')
|
||||
expect(configuratorFixture).toContain('delete config.CommandPermissions')
|
||||
for (const path of [
|
||||
'AdminPanel.AdminGroups',
|
||||
'TestData.AdminGroups',
|
||||
'FlipTok.AdminGroups',
|
||||
'Picstagram.AdminGroups',
|
||||
]) {
|
||||
expect(configurator).toContain(`["${path}"] = true`)
|
||||
}
|
||||
expect(configurator).toContain(
|
||||
'Phone Configurator enabled: file-based settings from config.lua',
|
||||
)
|
||||
expect(configurator).toContain(
|
||||
'(except Config.CommandPermissions) and media.lua are disabled',
|
||||
)
|
||||
})
|
||||
|
||||
it('authorizes Qbox through ACE before retaining framework group support', () => {
|
||||
expect(framework).toContain(
|
||||
'local groups = Config.CommandPermissions[permission]',
|
||||
)
|
||||
expect(qbox).toContain(
|
||||
'if IsPlayerAceAllowed(tostring(source), group) then',
|
||||
)
|
||||
expect(qbox.indexOf('IsPlayerAceAllowed')).toBeLessThan(
|
||||
qbox.indexOf('exports.qbx_core:HasGroup'),
|
||||
)
|
||||
})
|
||||
|
||||
it('uses stable permission identifiers for every protected operation', () => {
|
||||
const expectations = [
|
||||
['../../sky_phone/source/server/admin.lua', 'phonepanel'],
|
||||
['../../sky_phone/source/server/testdata.lua', 'phonetestdata'],
|
||||
['../../sky_phone/source/server/fliptok.lua', 'fliptokverify'],
|
||||
['../../sky_phone/source/server/picstagram.lua', 'picstagramverify'],
|
||||
['../../sky_phone/source/server/picstagram.lua', 'picstagramadmin'],
|
||||
] as const
|
||||
|
||||
for (const [path, permission] of expectations) {
|
||||
expect(source(path)).toContain(
|
||||
`Bridge.Framework.HasPermission(source, "${permission}")`,
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,37 +0,0 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const appSource = readFileSync(new URL('./App.vue', import.meta.url), 'utf8')
|
||||
const audioSource = readFileSync(
|
||||
new URL('./utils/phoneAudio.ts', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const mainCss = readFileSync(
|
||||
new URL('./assets/main.css', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const musicSource = readFileSync(
|
||||
new URL('./stores/music.ts', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
describe('phone audio output contract', () => {
|
||||
it('applies the hardware volume to app media and live YouTube playback', () => {
|
||||
expect(appSource).toContain('installPhoneAudioController()')
|
||||
expect(appSource).toContain('setPhoneOutputVolume(volume / 100)')
|
||||
expect(appSource).toContain(
|
||||
'if (radio.data.connected) void radio.setVolume(volume)',
|
||||
)
|
||||
expect(audioSource).toContain(
|
||||
"document.addEventListener('play', onMediaPlay, true)",
|
||||
)
|
||||
expect(audioSource).toContain('localVolume * outputVolume')
|
||||
expect(musicSource).toContain('registerPhoneMediaElement(new Audio())')
|
||||
expect(musicSource).toContain('store.volume * getPhoneOutputVolume() * 100')
|
||||
})
|
||||
|
||||
it('renders the hardware speaker symbol in grey', () => {
|
||||
expect(mainCss).toMatch(/\.phone-volume-hud\s*\{[\s\S]*?color:\s*#a9abb2;/)
|
||||
})
|
||||
})
|
||||
@@ -3,27 +3,15 @@ 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',
|
||||
).replace(/\r\n/g, '\n')
|
||||
const readFrontendFile = (path: string) =>
|
||||
readFileSync(new URL(path, import.meta.url), 'utf8')
|
||||
readFileSync(new URL(`../../sky_phone/${path}`, import.meta.url), 'utf8')
|
||||
|
||||
const inventoryAdapters = [
|
||||
['jaksam', 'source/bridge/server/inventory/jaksam.lua'],
|
||||
['ox', 'source/bridge/server/inventory/ox.lua'],
|
||||
['qb', 'source/bridge/server/inventory/qb.lua'],
|
||||
['lj', 'source/bridge/server/inventory/qb.lua'],
|
||||
['qs', 'source/bridge/server/inventory/qs.lua'],
|
||||
['ps', 'source/bridge/server/inventory/ps.lua'],
|
||||
['codem', 'source/bridge/server/inventory/codem.lua'],
|
||||
['tgiann', 'source/bridge/server/inventory/tgiann.lua'],
|
||||
['core', 'source/bridge/server/inventory/core.lua'],
|
||||
['jpr', 'source/bridge/server/inventory/jpr.lua'],
|
||||
['origen', 'source/bridge/server/inventory/origen.lua'],
|
||||
['ak47', 'source/bridge/server/inventory/ak47.lua'],
|
||||
['one', 'source/bridge/server/inventory/one.lua'],
|
||||
['mf', 'source/bridge/server/inventory/mf.lua'],
|
||||
['smx', 'source/bridge/server/inventory/smx.lua'],
|
||||
['hex', 'source/bridge/server/inventory/esx.lua'],
|
||||
@@ -44,246 +32,31 @@ describe('phone inventory contracts', () => {
|
||||
const phoneServer = readResourceFile('source/server/phone.lua')
|
||||
|
||||
expect(phoneServer).toContain(
|
||||
'Bridge.Inventory.RegisterUsableItem(item_name, function(...)',
|
||||
)
|
||||
expect(phoneServer).toContain('if Config.Phone.Item == item_name then')
|
||||
expect(phoneServer).toContain(
|
||||
'if not Bridge.Inventory.RegisterUsableItem(item_name, function(...)',
|
||||
'Bridge.Inventory.RegisterUsableItem(Config.Phone.Item, open_phone)',
|
||||
)
|
||||
expect(phoneServer).toContain('if not usable_registered then')
|
||||
})
|
||||
|
||||
it('uses One Inventory slot ids and authoritative slot reads', () => {
|
||||
const adapter = readResourceFile(
|
||||
'source/bridge/server/inventory/one.lua',
|
||||
)
|
||||
|
||||
expect(adapter).toContain('inventory:GetSlotIdsWithItem(')
|
||||
expect(adapter).toContain(
|
||||
'local normalized = Bridge.Inventory.GetSlot(source, slot_id)',
|
||||
)
|
||||
expect(adapter).not.toContain('inventory:SearchInventory(')
|
||||
expect(adapter).toContain(
|
||||
'inventory:SetItemMetadata(source, slot.slot, requested_metadata) == false',
|
||||
)
|
||||
})
|
||||
|
||||
it('serializes and rate-limits phone bootstrap requests on both sides', () => {
|
||||
const phoneClient = readResourceFile('source/client/main.lua')
|
||||
const phoneServer = readResourceFile('source/server/phone.lua')
|
||||
|
||||
expect(phoneClient).toContain('local function request_phone_open(')
|
||||
expect(phoneClient).toMatch(
|
||||
/request_phone_open\(callback_name\)[\s\S]*?open_requested = true[\s\S]*?Bridge\.Callbacks\.Trigger\(callback_name, \{\}\)/,
|
||||
)
|
||||
expect(phoneClient).toMatch(
|
||||
/RegisterNetEvent\("sky_phone:device:error"[\s\S]*?if not is_open then[\s\S]*?open_requested = false/,
|
||||
)
|
||||
expect(phoneServer).toContain('local phone_open_in_progress = {}')
|
||||
expect(phoneServer).toContain(
|
||||
'SkyPhone.AllowOperation(source, "phone_open", request_limit, 60)',
|
||||
)
|
||||
expect(phoneServer).toContain(
|
||||
'pcall(perform_phone_open, source, used_item)',
|
||||
)
|
||||
})
|
||||
|
||||
it('auto-detects registered inventories and forces metadata-free adapters into compatible modes', () => {
|
||||
it('auto-detects HEX and limits count-based ESX inventories to metadata-free modes', () => {
|
||||
const inventoryBridge = readResourceFile(
|
||||
'source/bridge/server/inventory.lua',
|
||||
)
|
||||
|
||||
expect(inventoryBridge).toContain(
|
||||
'{ name = "hex", resource = "hex_4_inventory", framework = "esx", metadata = false },',
|
||||
)
|
||||
expect(inventoryBridge).toContain(
|
||||
'GetResourceState(adapter.resource) == "started"',
|
||||
)
|
||||
expect(inventoryBridge).toContain('configured_inventory = adapter.name')
|
||||
expect(inventoryBridge).toContain('selected_adapter.metadata == false')
|
||||
expect(inventoryBridge).toContain('Config.Phone.Unique = false')
|
||||
expect(inventoryBridge).toContain('Config.Sim.Enabled = false')
|
||||
expect(inventoryBridge).toContain(
|
||||
'does not support item metadata; unique phones and physical SIM cards were disabled automatically',
|
||||
)
|
||||
expect(inventoryBridge).toContain(
|
||||
'AddEventHandler("sky_phone:configurator:serverUpdated"',
|
||||
)
|
||||
expect(inventoryBridge).not.toContain(
|
||||
'cannot store unique phone or physical SIM metadata',
|
||||
'GetResourceState("hex_4_inventory") == "started"',
|
||||
)
|
||||
expect(inventoryBridge).toContain('configured_inventory = "hex"')
|
||||
expect(inventoryBridge).toContain('Config.Phone.Unique ~= false')
|
||||
expect(inventoryBridge).toContain('Config.Sim.Enabled ~= false')
|
||||
})
|
||||
|
||||
it('provides the LB IsOpen export alias from the authoritative client state', () => {
|
||||
const phoneClient = readResourceFile('source/client/main.lua')
|
||||
const phoneBridge = readResourceFile('source/bridge/phones/client/lb.lua')
|
||||
|
||||
expect(phoneBridge).toContain(
|
||||
expect(phoneClient).toContain(
|
||||
'SkyPhoneCompatibility.RegisterExportAlias("lb-phone", "IsOpen"',
|
||||
)
|
||||
expect(phoneClient).toContain('open = is_open')
|
||||
expect(phoneBridge).toContain('return get_phone_state_value("open")')
|
||||
})
|
||||
|
||||
it('provides the LB equipped phone number exports from authoritative device state', () => {
|
||||
const phoneClient = readResourceFile('source/client/main.lua')
|
||||
const phoneServer = readResourceFile('source/server/phone.lua')
|
||||
const clientBridge = readResourceFile('source/bridge/phones/client/lb.lua')
|
||||
const serverBridge = readResourceFile('source/bridge/phones/server/lb.lua')
|
||||
const serverLifecycle = readResourceFile(
|
||||
'source/bridge/phones/server/lifecycle.lua',
|
||||
)
|
||||
|
||||
expect(clientBridge).toMatch(
|
||||
/"GetEquippedPhoneNumber",\s+phone\.GetEquippedPhoneNumber/,
|
||||
)
|
||||
expect(phoneClient).toContain('return device_payload.device.sim.number')
|
||||
expect(phoneClient).toContain(
|
||||
'Bridge.Callbacks.Trigger("sky_phone:device:equipped-number", {})',
|
||||
)
|
||||
expect(phoneServer).toContain(
|
||||
'Bridge.Callbacks.Register("sky_phone:device:equipped-number", function(source)',
|
||||
)
|
||||
expect(phoneServer).toContain(
|
||||
'function SkyPhone.GetEquippedPhoneNumber(player)',
|
||||
)
|
||||
expect(phoneServer).toContain(
|
||||
'cache_equipped_phone_number(source, identifier, device.phone_number)',
|
||||
)
|
||||
expect(phoneServer).toContain(
|
||||
'equipped_phone_sources[phone_number] = source',
|
||||
)
|
||||
const equippedNumberExport = phoneServer.match(
|
||||
/function SkyPhone\.GetEquippedPhoneNumber\(player\)([\s\S]*?)\nfunction SkyPhone\.GetSourceFromNumber/,
|
||||
)?.[1]
|
||||
const equippedNumberResolver = phoneServer.match(
|
||||
/local function resolve_equipped_phone_number\(source\)([\s\S]*?)\nfunction SkyPhone\.GetEquippedPhoneNumber/,
|
||||
)?.[1]
|
||||
expect(equippedNumberExport).toBeDefined()
|
||||
expect(equippedNumberResolver).toBeDefined()
|
||||
expect(equippedNumberExport).toContain('type(player) == "number"')
|
||||
expect(equippedNumberExport).toContain(
|
||||
'online_source_for_identifier(player)',
|
||||
)
|
||||
expect(phoneServer).toContain(
|
||||
'Bridge.Inventory.GetSlotsWithItem(source, Config.Phone.Item)',
|
||||
)
|
||||
expect(phoneServer).toContain(
|
||||
'return resolve_equipped_phone_number(player)',
|
||||
)
|
||||
expect(equippedNumberExport).not.toContain('tonumber(player)')
|
||||
expect(equippedNumberExport).not.toContain('equipped_phone_numbers[player]')
|
||||
expect(equippedNumberResolver).not.toContain('return cached_number')
|
||||
expect(phoneServer).toContain(
|
||||
'player_source and resolve_equipped_phone_number(player_source) == normalized',
|
||||
)
|
||||
expect(serverBridge).toMatch(
|
||||
/"GetEquippedPhoneNumber",\s+phone\.GetEquippedPhoneNumber/,
|
||||
)
|
||||
expect(serverBridge).toMatch(
|
||||
/"GetSourceFromNumber",\s+phone\.GetSourceFromNumber/,
|
||||
)
|
||||
expect(phoneServer).toContain(
|
||||
'TriggerEvent("sky_phone:server:phoneNumberChanged", source, phone_number)',
|
||||
)
|
||||
expect(serverLifecycle).toContain(
|
||||
'SkyPhoneCompatibility.EmitServerProviderStop(LB_PROVIDER_NAME)',
|
||||
)
|
||||
expect(serverLifecycle).toContain(
|
||||
'SkyPhoneCompatibility.EmitServerProviderStart(LB_PROVIDER_NAME)',
|
||||
)
|
||||
})
|
||||
|
||||
it('maps LB client lifecycle and state contracts', () => {
|
||||
const phoneClient = readResourceFile('source/client/main.lua')
|
||||
const phoneBridge = readResourceFile('source/bridge/phones/client/lb.lua')
|
||||
|
||||
expect(phoneBridge).toContain(
|
||||
'SkyPhoneCompatibility.RegisterExportAlias("lb-phone", "ToggleOpen"',
|
||||
)
|
||||
expect(phoneClient).toContain(
|
||||
'local result = Bridge.Callbacks.Trigger(callback_name, {})',
|
||||
)
|
||||
expect(phoneClient).toMatch(
|
||||
/result\.success == true[\s\S]*open_requested = false[\s\S]*open_without_focus = false[\s\S]*return false/,
|
||||
)
|
||||
expect(phoneBridge).toContain(
|
||||
'SkyPhoneCompatibility.RegisterExportAlias("lb-phone", "IsPhoneOnScreen"',
|
||||
)
|
||||
expect(phoneBridge).toContain(
|
||||
'SkyPhoneCompatibility.RegisterExportAlias("lb-phone", "IsInCall"',
|
||||
)
|
||||
expect(phoneBridge).toContain(
|
||||
'SkyPhoneCompatibility.RegisterExportAlias("lb-phone", "FormatNumber", client_bridge.FormatNumber)',
|
||||
)
|
||||
expect(phoneClient).toContain(
|
||||
'TriggerEvent("sky_phone:client:phoneNumberChanged", next_number)',
|
||||
)
|
||||
expect(phoneClient).toContain(
|
||||
'TriggerEvent("sky_phone:client:phoneToggled", true)',
|
||||
)
|
||||
expect(phoneClient).toContain(
|
||||
'TriggerEvent("sky_phone:client:phoneToggled", false)',
|
||||
)
|
||||
expect(phoneBridge).toContain(
|
||||
'TriggerEvent("lb-phone:numberChanged", phone_number)',
|
||||
)
|
||||
expect(phoneBridge).toContain('TriggerEvent("lb-phone:phoneToggled", open)')
|
||||
})
|
||||
|
||||
it('keeps vendor contracts outside the phone business core', () => {
|
||||
const corePaths = [
|
||||
'source/client/main.lua',
|
||||
'source/client/camera.lua',
|
||||
'source/client/custom_apps.lua',
|
||||
'source/server/phone.lua',
|
||||
'source/server/sim.lua',
|
||||
'source/server/media.lua',
|
||||
]
|
||||
|
||||
for (const path of corePaths) {
|
||||
expect(readResourceFile(path)).not.toMatch(
|
||||
/lb-phone|17mov|high-phone|qs-smartphone|yseries|SkyPhoneCompatibility/,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('maps the LB custom-app delete lifecycle from the App Store to Lua', () => {
|
||||
const appStore = readFrontendFile('stores/app-store.ts')
|
||||
const customApps = readResourceFile('source/client/custom_apps.lua')
|
||||
const compatibility = readResourceFile('source/bridge/phones/shared/lb.lua')
|
||||
|
||||
expect(appStore).toContain("event: 'delete'")
|
||||
expect(customApps).toContain('and lifecycle_event ~= "delete"')
|
||||
expect(customApps).toContain(
|
||||
'invoke_or_defer_hook(app, "onDelete", lifecycle_payload, deferred_hooks)',
|
||||
)
|
||||
expect(compatibility).toContain('onDelete = app_data.onDelete')
|
||||
})
|
||||
|
||||
it('emits exact LB observer events after authoritative state changes', () => {
|
||||
const phonePersistence = readResourceFile(
|
||||
'source/server/phone_persistence.lua',
|
||||
)
|
||||
const simServer = readResourceFile('source/server/sim.lua')
|
||||
const mediaServer = readResourceFile('source/server/media.lua')
|
||||
const phoneBridge = readResourceFile(
|
||||
'source/bridge/phones/server/lifecycle.lua',
|
||||
)
|
||||
|
||||
expect(simServer).toContain(
|
||||
'TriggerEvent("sky_phone:server:phoneNumberGenerated", source, sim.phone_number)',
|
||||
)
|
||||
expect(phonePersistence).toContain(
|
||||
'TriggerEvent("sky_phone:server:factoryReset", source, phone_number)',
|
||||
)
|
||||
expect(mediaServer).toContain(
|
||||
'TriggerEvent("sky_phone:server:galleryMediaDeleted", src, phone_number, deleted_link)',
|
||||
)
|
||||
expect(phoneBridge).toContain(
|
||||
'TriggerEvent("lb-phone:phoneNumberGenerated"',
|
||||
)
|
||||
expect(phoneBridge).toContain('TriggerEvent("lb-phone:factoryReset"')
|
||||
expect(phoneBridge).toContain('TriggerEvent("lb-phone:deletedFromGallery"')
|
||||
expect(phoneClient).toContain('return is_open')
|
||||
})
|
||||
|
||||
it('opens from a configurable F1 mapping without client-provided device identity', () => {
|
||||
@@ -292,57 +65,17 @@ describe('phone inventory contracts', () => {
|
||||
const phoneServer = readResourceFile('source/server/phone.lua')
|
||||
|
||||
expect(config).toContain('Keybind = "F1"')
|
||||
expect(phoneClient).toContain('refresh_phone_key_mapping = function()')
|
||||
expect(phoneClient).toContain(
|
||||
'RegisterKeyMapping(command_name, locale.Controls.OpenPhone, "keyboard", key_name)',
|
||||
'RegisterKeyMapping("sky_phone_toggle", locale.Controls.OpenPhone, "keyboard", Config.Phone.Keybind)',
|
||||
)
|
||||
expect(phoneClient).toContain(
|
||||
'if active_key_mapping_command == command_name then',
|
||||
)
|
||||
expect(phoneClient).toContain(
|
||||
'request_phone_open("sky_phone:device:open-request")',
|
||||
'Bridge.Callbacks.Trigger("sky_phone:device:open-request", {})',
|
||||
)
|
||||
expect(phoneServer).toContain(
|
||||
'Bridge.Callbacks.Register("sky_phone:device:open-request", function(source)',
|
||||
)
|
||||
})
|
||||
|
||||
it('applies development command changes immediately without a resource restart', () => {
|
||||
const phoneClient = readResourceFile('source/client/main.lua')
|
||||
|
||||
expect(phoneClient).toContain('local active_development_command = nil')
|
||||
expect(phoneClient).toContain('refresh_development_command = function()')
|
||||
expect(phoneClient).toContain(
|
||||
'local command_name = Config.Phone.DevelopmentCommand and Config.Command or nil',
|
||||
)
|
||||
expect(phoneClient).toContain('RegisterCommand(command_name, function()')
|
||||
expect(phoneClient).toContain(
|
||||
'if active_development_command == command_name and Config.Phone.DevelopmentCommand then',
|
||||
)
|
||||
expect(phoneClient).toContain(
|
||||
'TriggerEvent("chat:removeSuggestion", "/" .. active_development_command)',
|
||||
)
|
||||
expect(phoneClient).toMatch(
|
||||
/AddEventHandler\("sky_phone:configurator:updated", function\(\)[\s\S]*?refresh_development_command\(\)/,
|
||||
)
|
||||
})
|
||||
|
||||
it('opens a running live activity with Space without affecting normal gameplay', () => {
|
||||
const phoneClient = readResourceFile('source/client/main.lua')
|
||||
|
||||
expect(phoneClient).toContain(
|
||||
'RegisterCommand("sky_phone_live_activity_open"',
|
||||
)
|
||||
expect(phoneClient).toContain(
|
||||
'if not live_activity_active or is_open or open_requested then',
|
||||
)
|
||||
expect(phoneClient).toContain(
|
||||
'RegisterKeyMapping(\n "sky_phone_live_activity_open"',
|
||||
)
|
||||
expect(phoneClient).toContain('"SPACE"')
|
||||
expect(phoneClient).toContain('RegisterNUICallback("ui:live-activity"')
|
||||
})
|
||||
|
||||
it('keeps a server-selected unique handset as the preferred hotkey device', () => {
|
||||
const phoneServer = readResourceFile('source/server/phone.lua')
|
||||
|
||||
@@ -357,7 +90,7 @@ describe('phone inventory contracts', () => {
|
||||
const phoneServer = readResourceFile('source/server/phone.lua')
|
||||
const migration = readResourceFile('source/server/db_migrate.lua')
|
||||
|
||||
expect(phoneServer).toContain('if Config.Phone.Unique == false then')
|
||||
expect(phoneServer).toContain('if not unique_phones then')
|
||||
expect(phoneServer).toContain('return map_character_device(source, slot)')
|
||||
expect(phoneServer).toContain('FROM `sky_phone_character_devices`')
|
||||
expect(phoneServer).toContain('WHERE `owner_identifier` = ?')
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const rootDirectory = join(import.meta.dirname, '..')
|
||||
const appSource = readFileSync(join(rootDirectory, 'src/App.vue'), 'utf8')
|
||||
const navigationSource = readFileSync(
|
||||
join(rootDirectory, '../sky_phone/source/client/navigation.lua'),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
describe('neutral phone navigation contract', () => {
|
||||
it('synchronizes installed renderer apps before acknowledging an opened phone', () => {
|
||||
expect(appSource).toContain("return nuiCall('navigation:state'")
|
||||
expect(appSource).toContain(
|
||||
"void syncNavigationState().then(() => nuiCall('ui:opened'))",
|
||||
)
|
||||
expect(navigationSource).toContain(
|
||||
'RegisterNUICallback("navigation:state"',
|
||||
)
|
||||
})
|
||||
|
||||
it('routes only installed apps and closes only the requested current app', () => {
|
||||
expect(appSource).toContain("event.data?.type === 'navigation:open-app'")
|
||||
expect(appSource).toContain('appStore.isInstalled(data.appId)')
|
||||
expect(appSource).toContain("event.data?.type === 'navigation:close-app'")
|
||||
expect(appSource).toContain('currentApp === data.appId')
|
||||
expect(navigationSource).toContain('if not installed_apps[normalized_app_id] then')
|
||||
expect(navigationSource).toContain('if current_app_id ~= normalized_app_id then')
|
||||
})
|
||||
|
||||
it('defers command-driven app routes until setup or device unlock completes', () => {
|
||||
expect(appSource).toContain('if (setupRequired.value || isLocked.value)')
|
||||
expect(appSource).toContain('pendingUnlockRoute.value = requestedRoute')
|
||||
expect(appSource).toContain("void router.replace(requestedRoute ?? '/')")
|
||||
})
|
||||
})
|
||||
@@ -15,12 +15,6 @@ const developmentRoutes: RouteRecordRaw[] = import.meta.env.DEV
|
||||
name: 'development-sky-ui',
|
||||
path: '/development/sky-ui/:demo?',
|
||||
},
|
||||
{
|
||||
component: () =>
|
||||
import('@/views/development/PhoneDynamicIslandGallery.vue'),
|
||||
name: 'development-dynamic-islands',
|
||||
path: '/development/dynamic-islands',
|
||||
},
|
||||
]
|
||||
: []
|
||||
|
||||
|
||||
@@ -1,269 +0,0 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import type {
|
||||
AdminAuditEntry,
|
||||
AdminActivityResponse,
|
||||
AdminBootstrap,
|
||||
AdminCallActivity,
|
||||
AdminConfigurator,
|
||||
AdminConfiguratorChange,
|
||||
AdminCredential,
|
||||
AdminMessageActivity,
|
||||
AdminPlayerDetail,
|
||||
AdminPlayerSummary,
|
||||
AdminStats,
|
||||
} from '@/types/admin'
|
||||
import { nuiCall, type NuiResponse } from '@/utils/nui'
|
||||
|
||||
const EMPTY_STATS: AdminStats = {
|
||||
accounts: 0,
|
||||
activeDevices: 0,
|
||||
auditEntries: 0,
|
||||
auditToday: 0,
|
||||
callsToday: 0,
|
||||
devices: 0,
|
||||
linkedDevices: 0,
|
||||
messagesToday: 0,
|
||||
online: 0,
|
||||
simDevices: 0,
|
||||
}
|
||||
|
||||
function disabledAppsFromConfigurator(
|
||||
configurator: AdminConfigurator,
|
||||
): string[] {
|
||||
const apps = configurator.sections
|
||||
.flatMap((section) => section.fields)
|
||||
.find((field) => field.scope === 'config' && field.path === 'Apps')?.value
|
||||
if (!apps || typeof apps !== 'object' || Array.isArray(apps)) return []
|
||||
|
||||
return Object.entries(apps)
|
||||
.filter(([, enabled]) => enabled === false)
|
||||
.map(([appId]) => appId)
|
||||
.sort()
|
||||
}
|
||||
|
||||
export const useAdminStore = defineStore('admin', {
|
||||
state: () => ({
|
||||
actionKey: '',
|
||||
activityKey: '',
|
||||
audit: [] as AdminAuditEntry[],
|
||||
configurator: null as AdminConfigurator | null,
|
||||
configuratorLoading: false,
|
||||
detailLoading: false,
|
||||
disabledApps: [] as string[],
|
||||
error: '',
|
||||
initialized: false,
|
||||
loading: false,
|
||||
players: [] as AdminPlayerSummary[],
|
||||
revealedCredentials: {} as Record<string, AdminCredential>,
|
||||
deviceActivity: {} as Record<
|
||||
string,
|
||||
{ calls?: AdminCallActivity[]; messages?: AdminMessageActivity[] }
|
||||
>,
|
||||
selectedPlayer: null as AdminPlayerDetail | null,
|
||||
stats: { ...EMPTY_STATS },
|
||||
}),
|
||||
actions: {
|
||||
async load(): Promise<boolean> {
|
||||
this.loading = true
|
||||
const response = await nuiCall<AdminBootstrap>('admin:bootstrap')
|
||||
this.loading = false
|
||||
if (!response.success || !response.data) {
|
||||
this.error = response.error ?? 'request_failed'
|
||||
return false
|
||||
}
|
||||
this.players = response.data.players
|
||||
this.disabledApps = Array.isArray(response.data.disabledApps)
|
||||
? response.data.disabledApps
|
||||
: []
|
||||
this.stats = response.data.stats
|
||||
this.audit = response.data.audit
|
||||
this.error = ''
|
||||
this.initialized = true
|
||||
return true
|
||||
},
|
||||
async openPlayer(source: number): Promise<boolean> {
|
||||
this.detailLoading = true
|
||||
this.revealedCredentials = {}
|
||||
const response = await nuiCall<AdminPlayerDetail>('admin:player', {
|
||||
source,
|
||||
})
|
||||
this.detailLoading = false
|
||||
if (!response.success || !response.data) {
|
||||
this.error = response.error ?? 'request_failed'
|
||||
return false
|
||||
}
|
||||
this.selectedPlayer = response.data
|
||||
this.error = ''
|
||||
return true
|
||||
},
|
||||
closePlayer(): void {
|
||||
this.selectedPlayer = null
|
||||
this.revealedCredentials = {}
|
||||
},
|
||||
async saveApps(
|
||||
source: number,
|
||||
imei: string,
|
||||
revision: number,
|
||||
changes: Array<{ appId: string; installed: boolean }>,
|
||||
): Promise<NuiResponse<AdminPlayerDetail>> {
|
||||
this.actionKey = `${imei}:save`
|
||||
const response = await nuiCall<AdminPlayerDetail>('admin:save-apps', {
|
||||
changes,
|
||||
imei,
|
||||
revision,
|
||||
source,
|
||||
})
|
||||
this.actionKey = ''
|
||||
if (response.success && response.data) {
|
||||
this.selectedPlayer = response.data
|
||||
this.error = ''
|
||||
} else {
|
||||
this.error = response.error ?? 'request_failed'
|
||||
}
|
||||
return response
|
||||
},
|
||||
async revealPassword(
|
||||
source: number,
|
||||
imei: string,
|
||||
): Promise<NuiResponse<AdminCredential>> {
|
||||
this.actionKey = `${imei}:password`
|
||||
const response = await nuiCall<AdminCredential>('admin:reveal-password', {
|
||||
imei,
|
||||
source,
|
||||
})
|
||||
this.actionKey = ''
|
||||
if (response.success && response.data) {
|
||||
this.revealedCredentials[imei] = response.data
|
||||
this.error = ''
|
||||
} else {
|
||||
this.error = response.error ?? 'request_failed'
|
||||
}
|
||||
return response
|
||||
},
|
||||
async loadActivity(
|
||||
source: number,
|
||||
imei: string,
|
||||
kind: 'messages' | 'calls',
|
||||
): Promise<boolean> {
|
||||
this.activityKey = `${imei}:${kind}`
|
||||
const response = await nuiCall<AdminActivityResponse>('admin:activity', {
|
||||
imei,
|
||||
kind,
|
||||
source,
|
||||
})
|
||||
this.activityKey = ''
|
||||
if (!response.success || !response.data) {
|
||||
this.error = response.error ?? 'request_failed'
|
||||
return false
|
||||
}
|
||||
const activity = this.deviceActivity[imei] ?? {}
|
||||
if (response.data.kind === 'messages') {
|
||||
activity.messages = response.data.entries
|
||||
} else {
|
||||
activity.calls = response.data.entries
|
||||
}
|
||||
this.deviceActivity[imei] = activity
|
||||
this.error = ''
|
||||
return true
|
||||
},
|
||||
async loadConfigurator(): Promise<boolean> {
|
||||
this.configuratorLoading = true
|
||||
const response = await nuiCall<AdminConfigurator>('admin:configurator')
|
||||
this.configuratorLoading = false
|
||||
if (!response.success || !response.data) {
|
||||
this.error = response.error ?? 'request_failed'
|
||||
return false
|
||||
}
|
||||
this.configurator = response.data
|
||||
this.disabledApps = disabledAppsFromConfigurator(response.data)
|
||||
this.error = ''
|
||||
return true
|
||||
},
|
||||
async saveConfigurator(
|
||||
changes: AdminConfiguratorChange[],
|
||||
): Promise<NuiResponse<AdminConfigurator>> {
|
||||
const current = this.configurator
|
||||
if (!current) return { error: 'request_failed', success: false }
|
||||
|
||||
this.actionKey = 'configurator:save'
|
||||
const response = await nuiCall<AdminConfigurator>(
|
||||
'admin:save-configurator',
|
||||
{
|
||||
changes,
|
||||
revision: current.revision,
|
||||
},
|
||||
)
|
||||
this.actionKey = ''
|
||||
if (response.success && response.data) {
|
||||
this.configurator = response.data
|
||||
this.disabledApps = disabledAppsFromConfigurator(response.data)
|
||||
this.error = ''
|
||||
} else {
|
||||
if (response.data) this.configurator = response.data
|
||||
this.error = response.error ?? 'request_failed'
|
||||
}
|
||||
return response
|
||||
},
|
||||
async resetPasscode(
|
||||
source: number,
|
||||
imei: string,
|
||||
): Promise<NuiResponse<AdminPlayerDetail>> {
|
||||
this.actionKey = `${imei}:reset-passcode`
|
||||
const response = await nuiCall<AdminPlayerDetail>(
|
||||
'admin:reset-passcode',
|
||||
{ imei, source },
|
||||
)
|
||||
this.actionKey = ''
|
||||
if (response.success && response.data) {
|
||||
this.selectedPlayer = response.data
|
||||
delete this.revealedCredentials[imei]
|
||||
this.error = ''
|
||||
} else {
|
||||
this.error = response.error ?? 'request_failed'
|
||||
}
|
||||
return response
|
||||
},
|
||||
async changeNumber(
|
||||
source: number,
|
||||
imei: string,
|
||||
phoneNumber: string,
|
||||
): Promise<NuiResponse<AdminPlayerDetail>> {
|
||||
this.actionKey = `${imei}:change-number`
|
||||
const response = await nuiCall<AdminPlayerDetail>('admin:change-number', {
|
||||
imei,
|
||||
phoneNumber,
|
||||
source,
|
||||
})
|
||||
this.actionKey = ''
|
||||
if (response.success && response.data) {
|
||||
this.selectedPlayer = response.data
|
||||
delete this.deviceActivity[imei]
|
||||
this.error = ''
|
||||
} else {
|
||||
this.error = response.error ?? 'request_failed'
|
||||
}
|
||||
return response
|
||||
},
|
||||
async factoryReset(
|
||||
source: number,
|
||||
imei: string,
|
||||
): Promise<NuiResponse<AdminPlayerDetail>> {
|
||||
this.actionKey = `${imei}:factory-reset`
|
||||
const response = await nuiCall<AdminPlayerDetail>('admin:factory-reset', {
|
||||
imei,
|
||||
source,
|
||||
})
|
||||
this.actionKey = ''
|
||||
if (response.success && response.data) {
|
||||
this.selectedPlayer = response.data
|
||||
delete this.deviceActivity[imei]
|
||||
delete this.revealedCredentials[imei]
|
||||
this.error = ''
|
||||
} else {
|
||||
this.error = response.error ?? 'request_failed'
|
||||
}
|
||||
return response
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -64,63 +64,6 @@ describe('app store', () => {
|
||||
expect(apps.isInstalled('snake')).toBe(false)
|
||||
expect(apps.isInstalled('health')).toBe(true)
|
||||
expect(apps.isInstalled('citywarn')).toBe(true)
|
||||
expect(apps.homeLayout.dock).toEqual([
|
||||
'phone',
|
||||
'messages',
|
||||
'camera',
|
||||
'clock',
|
||||
])
|
||||
for (const dockAppId of ['phone', 'messages', 'camera', 'clock']) {
|
||||
expect(apps.homeLayout.grid).not.toContain(dockAppId)
|
||||
}
|
||||
})
|
||||
|
||||
it('removes server-disabled apps without forgetting device claims', () => {
|
||||
const apps = useAppStoreStore()
|
||||
|
||||
apps.hydrate({ claimedApps: ['weather', 'snake'] }, ['weather'])
|
||||
|
||||
expect(apps.isAvailable('weather')).toBe(false)
|
||||
expect(apps.isInstalled('weather')).toBe(false)
|
||||
expect(apps.homeLayout.grid).not.toContain('weather')
|
||||
expect(apps.claimedApps).toContain('weather')
|
||||
expect(apps.isInstalled('snake')).toBe(true)
|
||||
|
||||
apps.hydrate({ claimedApps: apps.claimedApps }, [])
|
||||
expect(apps.isInstalled('weather')).toBe(true)
|
||||
})
|
||||
|
||||
it('drops the retired admin app from persisted phone layouts', () => {
|
||||
const apps = useAppStoreStore()
|
||||
|
||||
apps.hydrate({ claimedApps: ['admin'] })
|
||||
expect(apps.homeLayout.grid).not.toContain('admin')
|
||||
})
|
||||
|
||||
it('migrates current layouts so dock apps are not repeated in the grid', () => {
|
||||
const apps = useAppStoreStore()
|
||||
|
||||
apps.hydrate({
|
||||
homeLayout: {
|
||||
dock: ['phone', 'messages', 'camera', 'clock'],
|
||||
grid: ['phone', 'messages', 'calculator', 'camera', 'clock'],
|
||||
hidden: [],
|
||||
pageCount: 1,
|
||||
version: HOME_LAYOUT_VERSION,
|
||||
},
|
||||
})
|
||||
|
||||
expect(apps.homeLayout.dock).toEqual([
|
||||
'phone',
|
||||
'messages',
|
||||
'camera',
|
||||
'clock',
|
||||
])
|
||||
for (const dockAppId of ['phone', 'messages', 'camera', 'clock']) {
|
||||
expect(apps.homeLayout.grid).not.toContain(dockAppId)
|
||||
}
|
||||
expect(apps.homeLayout.grid).toContain('calculator')
|
||||
expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('removes old automatic apps unless the player installed them', () => {
|
||||
@@ -377,7 +320,7 @@ describe('app store', () => {
|
||||
const apps = useAppStoreStore()
|
||||
apps.hydrate(null)
|
||||
mocks.phone.saveDeviceNamespace.mockClear()
|
||||
const sourceIndex = apps.homeLayout.grid.indexOf('calculator')
|
||||
const sourceIndex = apps.homeLayout.grid.indexOf('phone')
|
||||
expect(sourceIndex).toBeGreaterThanOrEqual(0)
|
||||
|
||||
expect(
|
||||
@@ -386,7 +329,7 @@ describe('app store', () => {
|
||||
HOME_GRID_PAGE_SIZE,
|
||||
]),
|
||||
).toBe(true)
|
||||
expect(apps.homeLayout.grid[HOME_GRID_PAGE_SIZE]).toBe('calculator')
|
||||
expect(apps.homeLayout.grid[HOME_GRID_PAGE_SIZE]).toBe('phone')
|
||||
expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
@@ -443,18 +386,18 @@ describe('app store', () => {
|
||||
mocks.phone.saveDeviceNamespace.mockClear()
|
||||
|
||||
const notesIndex = apps.homeLayout.grid.indexOf('notes')
|
||||
const settingsIndex = apps.homeLayout.grid.indexOf('settings')
|
||||
const clockIndex = apps.homeLayout.grid.indexOf('clock')
|
||||
const folderId = apps.createHomeFolder(
|
||||
'grid',
|
||||
notesIndex,
|
||||
'grid',
|
||||
settingsIndex,
|
||||
clockIndex,
|
||||
'Utilities',
|
||||
)
|
||||
|
||||
expect(folderId).toBeTruthy()
|
||||
expect(getHomeFolder(apps.homeLayout, folderId!)?.apps).toEqual([
|
||||
'settings',
|
||||
'clock',
|
||||
'notes',
|
||||
])
|
||||
const mailIndex = apps.homeLayout.grid.indexOf('mail')
|
||||
@@ -462,7 +405,7 @@ describe('app store', () => {
|
||||
apps.moveHomeFolderApp(folderId!, 2, 0)
|
||||
apps.renameHomeFolder(folderId!, 'Work')
|
||||
expect(getHomeFolder(apps.homeLayout, folderId!)).toMatchObject({
|
||||
apps: ['mail', 'notes', 'settings'],
|
||||
apps: ['mail', 'notes', 'clock'],
|
||||
name: 'Work',
|
||||
})
|
||||
|
||||
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
parseHomeLayout,
|
||||
reflowHomeGridForWidgetChange,
|
||||
renameHomeFolder,
|
||||
removeDockGridDuplicates,
|
||||
removeHomeApp,
|
||||
restoreHomeApp,
|
||||
type HomeArea,
|
||||
@@ -45,49 +44,27 @@ const pendingInstallations = new WeakMap<
|
||||
Map<LaunchablePhoneAppId, PendingInstallation>
|
||||
>()
|
||||
|
||||
function isAppDisabled(
|
||||
appId: LaunchablePhoneAppId,
|
||||
disabledApps: readonly LaunchablePhoneAppId[],
|
||||
): boolean {
|
||||
return disabledApps.includes(appId)
|
||||
}
|
||||
|
||||
function getDefaultGridIds(
|
||||
disabledApps: readonly LaunchablePhoneAppId[] = [],
|
||||
): LaunchablePhoneAppId[] {
|
||||
return PHONE_APPS.filter(
|
||||
(app) => app.dockOrder === null && !isAppDisabled(app.id, disabledApps),
|
||||
)
|
||||
function getDefaultGridIds(): LaunchablePhoneAppId[] {
|
||||
return [...PHONE_APPS]
|
||||
.sort((a, b) => a.gridOrder - b.gridOrder)
|
||||
.map((app) => app.id)
|
||||
}
|
||||
|
||||
function getDefaultDockIds(
|
||||
disabledApps: readonly LaunchablePhoneAppId[] = [],
|
||||
): LaunchablePhoneAppId[] {
|
||||
return PHONE_APPS.filter(
|
||||
(app) => app.dockOrder !== null && !isAppDisabled(app.id, disabledApps),
|
||||
)
|
||||
function getDefaultDockIds(): LaunchablePhoneAppId[] {
|
||||
return PHONE_APPS.filter((app) => app.dockOrder !== null)
|
||||
.sort((a, b) => (a.dockOrder ?? 0) - (b.dockOrder ?? 0))
|
||||
.map((app) => app.id)
|
||||
}
|
||||
|
||||
function getDefaultInstalledIds(
|
||||
disabledApps: readonly LaunchablePhoneAppId[] = [],
|
||||
): LaunchablePhoneAppId[] {
|
||||
return PHONE_APPS.filter((app) => {
|
||||
if (app.adminOnly || isAppDisabled(app.id, disabledApps)) return false
|
||||
return isExternalPhoneApp(app)
|
||||
function getDefaultInstalledIds(): LaunchablePhoneAppId[] {
|
||||
return PHONE_APPS.filter((app) =>
|
||||
isExternalPhoneApp(app)
|
||||
? app.defaultInstalled
|
||||
: DEFAULT_INSTALLED_PHONE_APP_IDS.has(app.id)
|
||||
}).map((app) => app.id)
|
||||
: DEFAULT_INSTALLED_PHONE_APP_IDS.has(app.id),
|
||||
).map((app) => app.id)
|
||||
}
|
||||
|
||||
function isProtectedHomeApp(
|
||||
appId: LaunchablePhoneAppId,
|
||||
disabledApps: readonly LaunchablePhoneAppId[],
|
||||
): boolean {
|
||||
if (isAppDisabled(appId, disabledApps)) return false
|
||||
function isProtectedHomeApp(appId: LaunchablePhoneAppId): boolean {
|
||||
const app = getPhoneApp(appId)
|
||||
return app
|
||||
? !isPhoneAppRemovable(app)
|
||||
@@ -125,7 +102,6 @@ function hasUninstalledBuiltinApp(
|
||||
export const useAppStoreStore = defineStore('app-store', {
|
||||
state: () => ({
|
||||
claimedApps: [] as LaunchablePhoneAppId[],
|
||||
disabledApps: [] as LaunchablePhoneAppId[],
|
||||
uninstalledApps: [] as LaunchablePhoneAppId[],
|
||||
homeLayout: createDefaultHomeLayout(
|
||||
getDefaultInstalledIds(),
|
||||
@@ -152,7 +128,6 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
return true
|
||||
},
|
||||
claimApp(id: LaunchablePhoneAppId): void {
|
||||
if (!this.isAvailable(id)) return
|
||||
this.uninstalledApps = this.uninstalledApps.filter(
|
||||
(appId) => appId !== id,
|
||||
)
|
||||
@@ -173,7 +148,6 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
this.installingApps = {}
|
||||
},
|
||||
installApp(id: LaunchablePhoneAppId): void {
|
||||
if (!this.isAvailable(id)) return
|
||||
const installed = this.isInstalled(id)
|
||||
if (
|
||||
this.installingApps[id] ||
|
||||
@@ -215,10 +189,6 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
)
|
||||
return
|
||||
}
|
||||
if (!this.isAvailable(id)) {
|
||||
delete this.installingApps[id]
|
||||
return
|
||||
}
|
||||
if (reportInstall && !isExternalPhoneApp(getPhoneApp(id))) {
|
||||
delete this.installingApps[id]
|
||||
console.error(
|
||||
@@ -248,14 +218,8 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
installations.set(id, { deviceImei, timer, token })
|
||||
pendingInstallations.set(this, installations)
|
||||
},
|
||||
hydrate(payload: unknown, disabledApps: unknown = []): void {
|
||||
hydrate(payload: unknown): void {
|
||||
this.cancelPendingInstalls()
|
||||
this.disabledApps = Array.isArray(disabledApps)
|
||||
? disabledApps.filter(
|
||||
(id): id is LaunchablePhoneAppId =>
|
||||
typeof id === 'string' && isPhoneAppId(id),
|
||||
)
|
||||
: []
|
||||
const data = payload as {
|
||||
claimedApps?: unknown
|
||||
homeLayout?: unknown
|
||||
@@ -272,15 +236,13 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
layoutVersion === 5 ||
|
||||
layoutVersion === 6
|
||||
this.claimedApps = Array.isArray(data?.claimedApps)
|
||||
? data.claimedApps.filter((id): id is LaunchablePhoneAppId => {
|
||||
if (typeof id !== 'string') return false
|
||||
const app = getPhoneApp(id)
|
||||
if (app?.adminOnly) return false
|
||||
return (
|
||||
isPhoneAppId(id) ||
|
||||
(supportsPersistedExternalApps && isValidExternalPhoneAppId(id))
|
||||
)
|
||||
})
|
||||
? data.claimedApps.filter(
|
||||
(id): id is LaunchablePhoneAppId =>
|
||||
typeof id === 'string' &&
|
||||
(isPhoneAppId(id) ||
|
||||
(supportsPersistedExternalApps &&
|
||||
isValidExternalPhoneAppId(id))),
|
||||
)
|
||||
: []
|
||||
this.uninstalledApps = Array.isArray(data?.uninstalledApps)
|
||||
? data.uninstalledApps.filter((id): id is LaunchablePhoneAppId => {
|
||||
@@ -290,36 +252,25 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
})
|
||||
: []
|
||||
const installedIds = [
|
||||
...new Set([
|
||||
...getDefaultInstalledIds(this.disabledApps),
|
||||
...this.claimedApps,
|
||||
]),
|
||||
].filter(
|
||||
(id) =>
|
||||
!this.uninstalledApps.includes(id) && this.isAvailable(id),
|
||||
)
|
||||
...new Set([...getDefaultInstalledIds(), ...this.claimedApps]),
|
||||
].filter((id) => !this.uninstalledApps.includes(id))
|
||||
const removedLegacyDefaults = hasUninstalledBuiltinApp(
|
||||
data?.homeLayout,
|
||||
installedIds,
|
||||
)
|
||||
const defaults = createDefaultHomeLayout(
|
||||
installedIds,
|
||||
getDefaultGridIds(this.disabledApps),
|
||||
getDefaultDockIds(this.disabledApps),
|
||||
getDefaultGridIds(),
|
||||
getDefaultDockIds(),
|
||||
)
|
||||
const parsedHomeLayout = parseHomeLayout(
|
||||
this.homeLayout = parseHomeLayout(
|
||||
data?.homeLayout,
|
||||
defaults,
|
||||
installedIds,
|
||||
false,
|
||||
)
|
||||
const normalizedHomeLayout = removeDockGridDuplicates(parsedHomeLayout)
|
||||
const removedDockGridDuplicates =
|
||||
normalizedHomeLayout !== parsedHomeLayout
|
||||
this.homeLayout = normalizedHomeLayout
|
||||
const protectedHiddenAppIds = this.homeLayout.hidden.filter((appId) =>
|
||||
isProtectedHomeApp(appId, this.disabledApps),
|
||||
)
|
||||
const protectedHiddenAppIds =
|
||||
this.homeLayout.hidden.filter(isProtectedHomeApp)
|
||||
for (const appId of protectedHiddenAppIds) {
|
||||
this.homeLayout = restoreHomeApp(this.homeLayout, appId)
|
||||
}
|
||||
@@ -341,7 +292,6 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
this.hydrated = true
|
||||
if (
|
||||
protectedHiddenAppIds.length ||
|
||||
removedDockGridDuplicates ||
|
||||
removedLegacyDefaults ||
|
||||
layoutVersion === 2 ||
|
||||
layoutVersion === 3 ||
|
||||
@@ -351,15 +301,10 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
this.persist()
|
||||
}
|
||||
},
|
||||
isAvailable(appId: LaunchablePhoneAppId): boolean {
|
||||
return !isAppDisabled(appId, this.disabledApps)
|
||||
},
|
||||
isInstalled(appId: LaunchablePhoneAppId): boolean {
|
||||
if (!this.isAvailable(appId)) return false
|
||||
const app = getPhoneApp(appId)
|
||||
if (app?.adminOnly) return false
|
||||
if (this.uninstalledApps.includes(appId)) return false
|
||||
if (this.claimedApps.includes(appId)) return true
|
||||
const app = getPhoneApp(appId)
|
||||
if (!app) return false
|
||||
return isExternalPhoneApp(app)
|
||||
? app.defaultInstalled
|
||||
@@ -367,26 +312,23 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
},
|
||||
reconcileCatalog(): void {
|
||||
const installedIds = [
|
||||
...new Set([
|
||||
...getDefaultInstalledIds(this.disabledApps),
|
||||
...this.claimedApps,
|
||||
]),
|
||||
].filter(
|
||||
(id) =>
|
||||
!this.uninstalledApps.includes(id) && this.isAvailable(id),
|
||||
)
|
||||
...new Set([...getDefaultInstalledIds(), ...this.claimedApps]),
|
||||
].filter((id) => !this.uninstalledApps.includes(id))
|
||||
const defaults = createDefaultHomeLayout(
|
||||
installedIds,
|
||||
getDefaultGridIds(this.disabledApps),
|
||||
getDefaultDockIds(this.disabledApps),
|
||||
getDefaultGridIds(),
|
||||
getDefaultDockIds(),
|
||||
)
|
||||
const previous = JSON.stringify(this.homeLayout)
|
||||
this.homeLayout = removeDockGridDuplicates(
|
||||
parseHomeLayout(this.homeLayout, defaults, installedIds, false),
|
||||
this.homeLayout = parseHomeLayout(
|
||||
this.homeLayout,
|
||||
defaults,
|
||||
installedIds,
|
||||
false,
|
||||
)
|
||||
|
||||
for (const appId of [...this.homeLayout.hidden]) {
|
||||
if (isProtectedHomeApp(appId, this.disabledApps)) {
|
||||
if (isProtectedHomeApp(appId)) {
|
||||
this.homeLayout = restoreHomeApp(this.homeLayout, appId)
|
||||
}
|
||||
}
|
||||
@@ -396,7 +338,6 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
}
|
||||
},
|
||||
recordLaunch(appId: LaunchablePhoneAppId): void {
|
||||
if (!this.isAvailable(appId)) return
|
||||
this.launchCounts[appId] = (this.launchCounts[appId] ?? 0) + 1
|
||||
this.persist()
|
||||
},
|
||||
@@ -532,13 +473,12 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
return true
|
||||
},
|
||||
removeHomeApp(appId: LaunchablePhoneAppId): void {
|
||||
if (isProtectedHomeApp(appId, this.disabledApps)) return
|
||||
if (isProtectedHomeApp(appId)) return
|
||||
|
||||
this.homeLayout = removeHomeApp(this.homeLayout, appId)
|
||||
this.persist()
|
||||
},
|
||||
restoreHomeApp(appId: LaunchablePhoneAppId): void {
|
||||
if (!this.isAvailable(appId)) return
|
||||
this.homeLayout = restoreHomeApp(this.homeLayout, appId)
|
||||
this.persist()
|
||||
},
|
||||
@@ -554,18 +494,6 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
}
|
||||
this.homeLayout = removeHomeApp(this.homeLayout, appId)
|
||||
this.persist()
|
||||
if (isExternalPhoneApp(app)) {
|
||||
void nuiCall('custom-app:lifecycle', {
|
||||
appId,
|
||||
event: 'delete',
|
||||
}).then((response) => {
|
||||
if (!response.success) {
|
||||
console.error(
|
||||
`[Custom apps] Delete lifecycle failed for ${appId}: ${response.error ?? 'request_failed'}`,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
|
||||
@@ -7,11 +7,6 @@ import type {
|
||||
MusicTrack,
|
||||
} from '@/types/music'
|
||||
import { nuiCall } from '@/utils/nui'
|
||||
import {
|
||||
getPhoneOutputVolume,
|
||||
registerPhoneMediaElement,
|
||||
subscribePhoneOutputVolume,
|
||||
} from '@/utils/phoneAudio'
|
||||
|
||||
export type YouTubePlayer = {
|
||||
destroy: () => void
|
||||
@@ -53,7 +48,7 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
const audio = registerPhoneMediaElement(new Audio())
|
||||
const audio = new Audio()
|
||||
audio.preload = 'auto'
|
||||
const YOUTUBE_API_TIMEOUT_MS = 12000
|
||||
let audioBound = false
|
||||
@@ -64,11 +59,6 @@ let youtubeApiPromise: Promise<YouTubeApi> | null = null
|
||||
let youtubePlayer: YouTubePlayer | null = null
|
||||
let youtubeProgressTimer: number | null = null
|
||||
|
||||
subscribePhoneOutputVolume((volume) => {
|
||||
const localVolume = useMusicStore().volume
|
||||
youtubePlayer?.setVolume(localVolume * volume * 100)
|
||||
})
|
||||
|
||||
export function musicTrackKey(
|
||||
track: Pick<MusicTrack, 'id' | 'source'>,
|
||||
): string {
|
||||
@@ -286,7 +276,7 @@ async function loadYouTubeTrack(videoId: string): Promise<void> {
|
||||
const api = await loadYouTubeApi()
|
||||
const store = useMusicStore()
|
||||
if (youtubePlayer) {
|
||||
youtubePlayer.setVolume(store.volume * getPhoneOutputVolume() * 100)
|
||||
youtubePlayer.setVolume(store.volume * 100)
|
||||
youtubePlayer.loadVideoById(videoId)
|
||||
youtubePlayer.playVideo()
|
||||
startYoutubeProgress()
|
||||
@@ -329,9 +319,7 @@ async function loadYouTubeTrack(videoId: string): Promise<void> {
|
||||
},
|
||||
onReady: (event) => {
|
||||
youtubePlayer = event.target
|
||||
event.target.setVolume(
|
||||
useMusicStore().volume * getPhoneOutputVolume() * 100,
|
||||
)
|
||||
event.target.setVolume(useMusicStore().volume * 100)
|
||||
event.target.playVideo()
|
||||
startYoutubeProgress()
|
||||
resolve()
|
||||
@@ -566,7 +554,7 @@ export const useMusicStore = defineStore('music', {
|
||||
setVolume(value: number): void {
|
||||
this.volume = Math.max(0, Math.min(1, value))
|
||||
audio.volume = this.volume
|
||||
youtubePlayer?.setVolume(this.volume * getPhoneOutputVolume() * 100)
|
||||
youtubePlayer?.setVolume(this.volume * 100)
|
||||
},
|
||||
stop(): void {
|
||||
stopActiveMedia()
|
||||
|
||||
@@ -10,22 +10,14 @@ type LuaToken = {
|
||||
}
|
||||
|
||||
const frontendSourceDirectory = fileURLToPath(new URL('../', import.meta.url))
|
||||
const localeDirectory = fileURLToPath(
|
||||
new URL('../../../sky_phone/config/locales/', import.meta.url),
|
||||
const englishLocaleSource = readFileSync(
|
||||
new URL('../../../sky_phone/config/locales/en.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const localeSources = new Map(
|
||||
readdirSync(localeDirectory, { withFileTypes: true })
|
||||
.filter((entry) => entry.isFile() && entry.name.endsWith('.lua'))
|
||||
.map((entry) => [
|
||||
entry.name.replace(/\.lua$/, ''),
|
||||
readFileSync(join(localeDirectory, entry.name), 'utf8'),
|
||||
]),
|
||||
const germanLocaleSource = readFileSync(
|
||||
new URL('../../../sky_phone/config/locales/de.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const englishLocaleSource = localeSources.get('en')
|
||||
const germanLocaleSource = localeSources.get('de')
|
||||
if (!englishLocaleSource || !germanLocaleSource) {
|
||||
throw new Error('The bundled English and German phone locales are required.')
|
||||
}
|
||||
const phoneStoreSource = readFileSync(
|
||||
new URL('./phone.ts', import.meta.url),
|
||||
'utf8',
|
||||
@@ -126,29 +118,6 @@ function collectLuaLocaleValues(source: string): Map<string, string> {
|
||||
}
|
||||
|
||||
parseTable([])
|
||||
while (position < tokens.length) {
|
||||
const assignment = tokens.findIndex(
|
||||
(token, index) => index >= position && token.kind === '=',
|
||||
)
|
||||
if (assignment < 0) break
|
||||
const nui = tokens.findIndex(
|
||||
(token, index) =>
|
||||
index >= position && index < assignment && token.value === 'Nui',
|
||||
)
|
||||
if (nui < 0) {
|
||||
position = assignment + 1
|
||||
continue
|
||||
}
|
||||
const path = [
|
||||
'Nui',
|
||||
...tokens
|
||||
.slice(nui + 1, assignment)
|
||||
.filter((token) => token.kind === 'word')
|
||||
.map((token) => token.value),
|
||||
]
|
||||
position = assignment + 1
|
||||
parseValue(path)
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
@@ -156,10 +125,6 @@ function collectPlaceholders(value: string): string[] {
|
||||
return [...new Set(value.match(/\{[A-Za-z0-9_]+\}/g) ?? [])].sort()
|
||||
}
|
||||
|
||||
function collectNumberTokens(value: string): string[] {
|
||||
return value.match(/\d+/g) ?? []
|
||||
}
|
||||
|
||||
function collectDefaultLocalePaths(source: string): Set<string> {
|
||||
const ast = ts.createSourceFile(
|
||||
'phone.ts',
|
||||
@@ -235,26 +200,10 @@ function collectFrontendFiles(directory: string): string[] {
|
||||
}
|
||||
|
||||
describe('phone locale contract', () => {
|
||||
const localeValues = new Map(
|
||||
[...localeSources].map(([locale, source]) => [
|
||||
locale,
|
||||
collectLuaLocaleValues(source),
|
||||
]),
|
||||
)
|
||||
const englishValues = localeValues.get('en')!
|
||||
const germanValues = localeValues.get('de')!
|
||||
const translatedLocaleValues = [...localeValues].filter(
|
||||
([locale]) => locale !== 'en',
|
||||
)
|
||||
const englishValues = collectLuaLocaleValues(englishLocaleSource)
|
||||
const germanValues = collectLuaLocaleValues(germanLocaleSource)
|
||||
const englishPaths = new Set(englishValues.keys())
|
||||
|
||||
it.each([...localeSources])(
|
||||
'registers the %s locale under its file name',
|
||||
(locale, source) => {
|
||||
expect(source.match(/^Locales\["([^"]+)"\]/)?.[1]).toBe(locale)
|
||||
},
|
||||
)
|
||||
|
||||
it('keeps every bundled frontend fallback in en.lua', () => {
|
||||
const missing = [...collectDefaultLocalePaths(phoneStoreSource)].filter(
|
||||
(path) => !englishPaths.has(`Nui.${path}`),
|
||||
@@ -299,52 +248,26 @@ describe('phone locale contract', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it.each(translatedLocaleValues)(
|
||||
'keeps %s structurally aligned with English',
|
||||
(_locale, values) => {
|
||||
expect([...values.keys()].sort()).toEqual([...englishPaths].sort())
|
||||
},
|
||||
)
|
||||
it('keeps German structurally aligned with English', () => {
|
||||
const germanPaths = new Set(germanValues.keys())
|
||||
|
||||
it.each(translatedLocaleValues)(
|
||||
'keeps %s interpolation placeholders aligned with English',
|
||||
(_locale, values) => {
|
||||
const mismatches = [...englishValues].flatMap(
|
||||
([path, englishValue]) => {
|
||||
const translatedValue = values.get(path)
|
||||
return translatedValue !== undefined &&
|
||||
JSON.stringify(collectPlaceholders(translatedValue)) !==
|
||||
JSON.stringify(collectPlaceholders(englishValue))
|
||||
? [
|
||||
`${path}: ${collectPlaceholders(englishValue).join(', ')} != ${collectPlaceholders(translatedValue).join(', ')}`,
|
||||
]
|
||||
: []
|
||||
},
|
||||
)
|
||||
expect([...germanPaths].sort()).toEqual([...englishPaths].sort())
|
||||
})
|
||||
|
||||
expect(mismatches).toEqual([])
|
||||
},
|
||||
)
|
||||
it('keeps German interpolation placeholders aligned with English', () => {
|
||||
const mismatches = [...englishValues].flatMap(([path, englishValue]) => {
|
||||
const germanValue = germanValues.get(path)
|
||||
return germanValue !== undefined &&
|
||||
JSON.stringify(collectPlaceholders(germanValue)) !==
|
||||
JSON.stringify(collectPlaceholders(englishValue))
|
||||
? [
|
||||
`${path}: ${collectPlaceholders(englishValue).join(', ')} != ${collectPlaceholders(germanValue).join(', ')}`,
|
||||
]
|
||||
: []
|
||||
})
|
||||
|
||||
it.each(translatedLocaleValues)(
|
||||
'keeps %s numeric source values intact',
|
||||
(_locale, values) => {
|
||||
const mismatches = [...englishValues].flatMap(([path, englishValue]) => {
|
||||
const expected = collectNumberTokens(englishValue)
|
||||
if (!expected.length) return []
|
||||
|
||||
const remaining = collectNumberTokens(values.get(path) ?? '')
|
||||
for (const token of expected) {
|
||||
const index = remaining.indexOf(token)
|
||||
if (index >= 0) remaining.splice(index, 1)
|
||||
else return [`${path}: missing numeric token ${token}`]
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
expect(mismatches).toEqual([])
|
||||
},
|
||||
)
|
||||
expect(mismatches).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps standard app names German and custom game names unchanged', () => {
|
||||
const standardAppNames = {
|
||||
|
||||
+39
-580
@@ -33,13 +33,11 @@ export type PasscodeResponseData = {
|
||||
export type PhoneOpenPayload = {
|
||||
account?: DeviceBootstrap['account']
|
||||
device?: PhoneDevice
|
||||
disabledApps?: string[]
|
||||
fallbackLocales?: LocaleTree
|
||||
lang?: string
|
||||
locales?: LocaleTree
|
||||
memos?: DeviceBootstrap['memos']
|
||||
notes?: DeviceBootstrap['notes']
|
||||
phoneNumberFormat?: DeviceBootstrap['phoneNumberFormat']
|
||||
player?: DeviceBootstrap['player']
|
||||
security?: DeviceSecurity
|
||||
token?: string
|
||||
@@ -640,8 +638,7 @@ const cryptoFallbackLocales = {
|
||||
invalid_handle: 'Use 3–20 letters, numbers, dots or underscores.',
|
||||
handle_taken: 'That VaultX handle is already taken.',
|
||||
profile_exists: 'This character already owns a VaultX profile.',
|
||||
invalid_password:
|
||||
'Use 8–72 characters with uppercase, lowercase, a number, and a special character.',
|
||||
invalid_password: 'Password must be 8–72 characters.',
|
||||
password_mismatch: 'The passwords do not match.',
|
||||
accept_terms: 'Confirm that this is a fictional in-game wallet.',
|
||||
invalid_credentials: 'The password is incorrect.',
|
||||
@@ -808,384 +805,7 @@ const citywarnFallbackLocales = {
|
||||
},
|
||||
}
|
||||
|
||||
const adminPanelFallbackLocales = {
|
||||
name: 'Phone Admin',
|
||||
subtitle: 'Administration',
|
||||
navigation: 'Admin navigation',
|
||||
refresh: 'Refresh admin data',
|
||||
loading: 'Loading protected data...',
|
||||
tabs: {
|
||||
overview: 'Overview',
|
||||
players: 'Players',
|
||||
devices: 'Devices',
|
||||
apps: 'Apps',
|
||||
accounts: 'Accounts',
|
||||
messages: 'Messages',
|
||||
calls: 'Calls',
|
||||
moderation: 'Moderation',
|
||||
audit: 'Audit',
|
||||
configurator: 'Phone configurator',
|
||||
},
|
||||
overview: {
|
||||
eyebrow: 'Server',
|
||||
title: 'Dashboard',
|
||||
body: 'Players, devices, apps, and phone data.',
|
||||
stats: 'Server phone statistics',
|
||||
online: 'Online',
|
||||
devices: 'Devices',
|
||||
accounts: 'Accounts',
|
||||
audit: 'Audit entries',
|
||||
control: 'Navigation',
|
||||
features: 'Modules',
|
||||
featuresBody: 'Open an administration module.',
|
||||
recent: 'Recent activity',
|
||||
playerFeature: 'Identity, finances, job, and duty',
|
||||
deviceFeature: 'IMEI, SIM, number, and activity',
|
||||
appFeature: 'Install or remove phone apps',
|
||||
accountFeature: 'Account access and protected credentials',
|
||||
messageFeature: 'Review recent SMS activity',
|
||||
callFeature: 'Review recent call activity',
|
||||
moderationFeature: 'Reset access, number, or device data',
|
||||
auditFeature: 'Review sensitive admin actions',
|
||||
configuratorFeature: 'Manage config.lua and media.lua through SQL',
|
||||
},
|
||||
statistics: {
|
||||
eyebrow: 'Live data',
|
||||
title: 'Phone statistics',
|
||||
body: 'Current usage and device status, refreshed every 15 seconds.',
|
||||
today: 'Activity today',
|
||||
todayBody: 'Phone and admin activity since midnight.',
|
||||
messagesToday: 'Messages',
|
||||
callsToday: 'Calls',
|
||||
auditToday: 'Admin actions',
|
||||
coverage: 'Device coverage',
|
||||
coverageBody: 'Current share of all stored phones.',
|
||||
linkedDevices: 'Account linked',
|
||||
simDevices: 'SIM assigned',
|
||||
activeDevices: 'Updated in 24 hours',
|
||||
ofDevices: '{count} of {total} devices',
|
||||
},
|
||||
configurator: {
|
||||
context: 'Runtime configuration',
|
||||
eyebrow: 'System tool',
|
||||
sections: 'Configuration',
|
||||
search: 'Search settings or paths',
|
||||
configScope: 'config.lua',
|
||||
mediaScope: 'media.lua',
|
||||
noResults: 'No matching settings',
|
||||
loading: 'Loading SQL configuration...',
|
||||
title: 'Phone configurator',
|
||||
body: 'Manage phone and media settings from the protected admin workspace.',
|
||||
disabledTitle: 'SQL configuration is not active',
|
||||
disabledBody:
|
||||
'Enable the configurator at the beginning of config.lua and restart sky_phone. Until then, file values remain active and editing is locked.',
|
||||
manualSave: 'Manual save',
|
||||
refreshNotice:
|
||||
'Nothing is written automatically. The green check verifies config.lua and media.lua in SQL and refreshes the active server, client, media and UI configuration immediately.',
|
||||
fieldCount: '{count} fields',
|
||||
secretConfigured: 'Secret configured · enter a replacement',
|
||||
invalidValue: 'Check the highlighted table or number value.',
|
||||
saved: 'SQL configuration saved and applied.',
|
||||
descriptions: {
|
||||
featureToggle: 'Turns {name} on or off.',
|
||||
boolean: 'Controls whether {name} is allowed.',
|
||||
number: 'Sets the numeric value for {name}.',
|
||||
text: 'Sets the text value used for {name}.',
|
||||
optionalText:
|
||||
'Sets the optional value for {name}; switch it off to disable it.',
|
||||
list: 'Manages all entries used for {name}.',
|
||||
table: 'Groups the related settings for {name}.',
|
||||
credential: 'Stores the protected credential used by {name}.',
|
||||
url: 'Sets the URL or endpoint used by {name}.',
|
||||
hosts: 'Defines which domains are allowed for {name}.',
|
||||
milliseconds: 'Sets the timing for {name} in milliseconds.',
|
||||
seconds: 'Sets the timing for {name} in seconds.',
|
||||
rateLimit: 'Limits how many {name} actions are allowed per minute.',
|
||||
byteLimit: 'Sets the maximum data size allowed for {name}.',
|
||||
textLimit: 'Sets the maximum text length allowed for {name}.',
|
||||
distance: 'Sets the world distance used for {name}.',
|
||||
coordinates: 'Sets the world coordinates or orientation for {name}.',
|
||||
gameAsset: 'Sets the GTA model or prop used for {name}.',
|
||||
animation: 'Sets the animation asset used for {name}.',
|
||||
access: 'Defines the jobs, groups or permission level for {name}.',
|
||||
integration: 'Selects the connected framework or provider for {name}.',
|
||||
path: 'Sets the storage or resource path used for {name}.',
|
||||
color: 'Sets the interface color used for {name}.',
|
||||
displayText: 'Sets the text shown to players for {name}.',
|
||||
phoneNumber: 'Sets the phone or service number used for {name}.',
|
||||
routing: 'Controls how incoming requests are routed for {name}.',
|
||||
command: 'Sets the chat command used to open or run {name}.',
|
||||
locale: 'Selects the language used for {name}.',
|
||||
debug: 'Controls detailed diagnostic output for {name}.',
|
||||
mediaQuality: 'Sets the media quality or volume used for {name}.',
|
||||
amount: 'Sets the maximum or displayed amount for {name}.',
|
||||
},
|
||||
table: {
|
||||
list: 'List',
|
||||
table: 'Key table',
|
||||
vector: 'Vector',
|
||||
entry: 'Entry',
|
||||
general: 'General',
|
||||
subtabs: {
|
||||
Dictionaries: 'Dictionaries',
|
||||
Clips: 'Clips',
|
||||
Transforms: 'Transforms',
|
||||
RateLimits: 'Rate Limits',
|
||||
Publishers: 'Publishers',
|
||||
ExternalPingResources: 'External Ping Resources',
|
||||
Markets: 'Markets',
|
||||
TrustedAdapters: 'Trusted Adapters',
|
||||
AllowedDisappearTimers: 'Allowed Disappear Timers',
|
||||
MusicTracks: 'Music Tracks',
|
||||
VehicleImages: 'Vehicle Images',
|
||||
Custom: 'Custom',
|
||||
Valet: 'Valet',
|
||||
AutoPriority: 'Automatic Priority',
|
||||
Camera: 'Camera',
|
||||
Categories: 'Categories',
|
||||
Districts: 'Districts',
|
||||
PhotoGradients: 'Photo Gradients',
|
||||
LbPhone: 'LB Phone',
|
||||
Tracks: 'Tracks',
|
||||
Props: 'Props',
|
||||
CustomLocations: 'Custom Locations',
|
||||
Animation: 'Animation',
|
||||
ReportReasons: 'Report Reasons',
|
||||
DisplayName: 'Display Name',
|
||||
Hud: 'HUD',
|
||||
Badge: 'Badge',
|
||||
LockedChannels: 'Locked Channels',
|
||||
NumberGroups: 'Number Groups',
|
||||
CustomFare: 'Custom Fare',
|
||||
DriverJobs: 'Driver Jobs',
|
||||
Services: 'Services',
|
||||
QuickLocations: 'Quick Locations',
|
||||
AllowedJobs: 'Allowed Jobs',
|
||||
Websites: 'Websites',
|
||||
},
|
||||
addRow: 'Add row',
|
||||
addField: 'Add field',
|
||||
remove: 'Remove',
|
||||
emptyList: 'No rows yet. Add the first row with plus.',
|
||||
emptyTable: 'No fields yet. Add the first key below.',
|
||||
keyPlaceholder: 'New key',
|
||||
convertToList: 'Use list',
|
||||
convertToMap: 'Use typed key table',
|
||||
convertToTable: 'Use key table',
|
||||
types: {
|
||||
string: 'Text',
|
||||
number: 'Number',
|
||||
boolean: 'Switch',
|
||||
list: 'List',
|
||||
table: 'Table',
|
||||
},
|
||||
},
|
||||
},
|
||||
players: {
|
||||
eyebrow: 'Active sessions',
|
||||
title: 'Online players',
|
||||
online: 'Online now',
|
||||
empty: 'No players found',
|
||||
emptyBody: 'Adjust the search or refresh the live player list.',
|
||||
},
|
||||
search: {
|
||||
players: 'Search name, ID, job, or number',
|
||||
apps: 'Search apps',
|
||||
clear: 'Clear search',
|
||||
},
|
||||
detail: {
|
||||
character: 'Character profile',
|
||||
data: 'Player data overview',
|
||||
cash: 'Cash',
|
||||
bank: 'Bank',
|
||||
job: 'Job',
|
||||
duty: 'Duty',
|
||||
onDuty: 'On duty',
|
||||
offDuty: 'Off duty',
|
||||
identity: 'Identity',
|
||||
playerData: 'Player data',
|
||||
identifier: 'Character identifier',
|
||||
birthdate: 'Birthdate',
|
||||
grade: 'Job grade',
|
||||
unknown: 'Unknown',
|
||||
},
|
||||
devices: {
|
||||
eyebrow: 'Device control',
|
||||
title: 'Phones',
|
||||
body: 'Inspect every phone assigned to the selected player.',
|
||||
choose: 'Choose phone',
|
||||
empty: 'No phone found',
|
||||
emptyBody: 'This player currently has no phone device that can be managed.',
|
||||
noNumber: 'No phone number',
|
||||
noSim: 'No SIM',
|
||||
imei: 'IMEI',
|
||||
updated: 'Last activity',
|
||||
apps: 'Claimed apps',
|
||||
account: 'Linked account',
|
||||
},
|
||||
credentials: {
|
||||
eyebrow: 'Protected data',
|
||||
title: 'Credentials',
|
||||
email: 'iFruit email',
|
||||
password: 'iFruit password',
|
||||
reveal: 'Reveal',
|
||||
copy: 'Copy password',
|
||||
copied: 'Password copied.',
|
||||
noAccount: 'No iFruit account is linked to this phone.',
|
||||
passcode: 'Device passcode',
|
||||
passcodeHashed: '{length}-digit PIN · securely hashed and not recoverable',
|
||||
passcodeDisabled: 'No passcode configured',
|
||||
revealTitle: 'Reveal protected password?',
|
||||
revealBody:
|
||||
'This action is server-authorized, rate-limited, and written to the admin audit log.',
|
||||
cancel: 'Cancel',
|
||||
confirmReveal: 'Reveal password',
|
||||
},
|
||||
apps: {
|
||||
eyebrow: 'Remote management',
|
||||
title: 'App access',
|
||||
description:
|
||||
'Stage app access for this device. Nothing changes until you save.',
|
||||
installed: 'Installed',
|
||||
available: 'Available',
|
||||
protected: 'System app',
|
||||
changes: '{count} pending changes',
|
||||
},
|
||||
activity: {
|
||||
protected: 'Protected activity',
|
||||
messagesTitle: 'Messages',
|
||||
messagesBody: 'Recent SMS activity for the selected SIM.',
|
||||
callsTitle: 'Calls',
|
||||
callsBody: 'Recent call activity for the selected SIM.',
|
||||
loading: 'Loading activity...',
|
||||
incoming: 'Incoming',
|
||||
outgoing: 'Outgoing',
|
||||
mediaMessage: '{type} message',
|
||||
noMessages: 'No message activity found.',
|
||||
noCalls: 'No call activity found.',
|
||||
status: {
|
||||
completed: 'Completed',
|
||||
missed: 'Missed',
|
||||
rejected: 'Rejected',
|
||||
busy: 'Busy',
|
||||
unanswered: 'Unanswered',
|
||||
cancelled: 'Cancelled',
|
||||
failed: 'Failed',
|
||||
ringing: 'Ringing',
|
||||
},
|
||||
},
|
||||
moderation: {
|
||||
eyebrow: 'Device administration',
|
||||
title: 'Moderation actions',
|
||||
body: 'Every action is server-authorized, rate-limited, and audited.',
|
||||
resetPasscode: 'Reset passcode',
|
||||
resetPasscodeBody: 'Remove the device PIN and clear failed attempts.',
|
||||
changeNumber: 'Change number',
|
||||
changeNumberBody: 'Assign a new unique number to the current SIM.',
|
||||
factoryReset: 'Factory reset',
|
||||
factoryResetBody: 'Clear local device data and disconnect the account.',
|
||||
saveFirst: 'Save or discard pending app changes first.',
|
||||
phoneNumber: 'New phone number',
|
||||
phoneNumberPlaceholder: 'Enter the full configured number',
|
||||
typeToConfirm: 'Type {word} to confirm the factory reset.',
|
||||
confirmWord: 'RESET',
|
||||
cancel: 'Cancel',
|
||||
'reset-passcodeSuccess': 'Passcode reset.',
|
||||
'change-numberSuccess': 'Phone number changed.',
|
||||
'factory-resetSuccess': 'Phone factory reset completed.',
|
||||
dialogs: {
|
||||
'reset-passcodeTitle': 'Reset device passcode?',
|
||||
'reset-passcodeBody':
|
||||
'The player can unlock this phone without the previous PIN afterward.',
|
||||
'change-numberTitle': 'Change phone number?',
|
||||
'change-numberBody':
|
||||
'The new number must match the configured server number format and be unique.',
|
||||
'factory-resetTitle': 'Factory reset this phone?',
|
||||
'factory-resetBody':
|
||||
'This clears local device data, app settings, security, and the linked account. This cannot be undone.',
|
||||
},
|
||||
confirm: {
|
||||
'reset-passcode': 'Reset passcode',
|
||||
'change-number': 'Change number',
|
||||
'factory-reset': 'Factory reset',
|
||||
},
|
||||
},
|
||||
editor: {
|
||||
brand: 'SKY PHONE',
|
||||
workspace: 'ADMIN',
|
||||
players: 'Player directory',
|
||||
audit: 'Audit log',
|
||||
selectPlayer:
|
||||
'Select a player to inspect identity, devices, credentials, and app access.',
|
||||
save: 'Save changes',
|
||||
saveHint: 'Apply pending changes',
|
||||
saved: 'Changes saved.',
|
||||
unsaved: 'Unsaved changes',
|
||||
close: 'Close admin panel',
|
||||
refresh: 'Refresh live data',
|
||||
online: 'LIVE',
|
||||
profile: 'PROFILE',
|
||||
financial: 'FINANCIAL',
|
||||
device: 'DEVICE',
|
||||
security: 'SECURITY',
|
||||
noAutoSave: 'Manual save',
|
||||
noAutoSaveBody: 'Changes stay local until the green check is pressed.',
|
||||
discardTitle: 'Discard unsaved changes?',
|
||||
discardBody:
|
||||
'Your staged app or configuration changes have not been saved.',
|
||||
keepEditing: 'Keep editing',
|
||||
discard: 'Discard changes',
|
||||
saveFailed: 'Some changes could not be saved.',
|
||||
noSelection: 'No player selected',
|
||||
},
|
||||
audit: {
|
||||
eyebrow: 'Accountability',
|
||||
title: 'Audit trail',
|
||||
body: 'Sensitive reveals and remote app changes are recorded here.',
|
||||
empty: 'No admin actions yet',
|
||||
emptyBody: 'Protected actions will appear here after they are performed.',
|
||||
by: '{actor} · target ID {target}',
|
||||
actions: {
|
||||
grant_app: 'App installed',
|
||||
revoke_app: 'App removed',
|
||||
reveal_account_password: 'Password revealed',
|
||||
view_messages: 'Messages viewed',
|
||||
view_calls: 'Calls viewed',
|
||||
reset_passcode: 'Passcode reset',
|
||||
change_number: 'Phone number changed',
|
||||
factory_reset: 'Phone factory reset',
|
||||
save_configuration: 'Configuration saved',
|
||||
},
|
||||
},
|
||||
errors: {
|
||||
not_authorized: 'You do not have access to the admin panel.',
|
||||
rate_limited: 'Too many admin requests. Please wait.',
|
||||
player_unavailable: 'That player is no longer online.',
|
||||
device_not_owned: 'That phone no longer belongs to the selected player.',
|
||||
invalid_app: 'That app is not registered on the server.',
|
||||
app_protected: 'This system app cannot be removed.',
|
||||
revision_conflict:
|
||||
'The data changed in the meantime. Reopen the section and try again.',
|
||||
configurator_disabled: 'Enable the phone configurator in config.lua first.',
|
||||
invalid_field: 'That configuration field is no longer available.',
|
||||
invalid_value: 'A configuration value is invalid.',
|
||||
account_not_found: 'No iFruit account is linked to this phone.',
|
||||
invalid_phone_number:
|
||||
'Enter a phone number in the configured server format.',
|
||||
phone_number_unchanged: 'This SIM already uses that phone number.',
|
||||
phone_number_taken: 'That phone number is already assigned.',
|
||||
no_sim: 'This phone has no SIM that can be changed.',
|
||||
passcode_not_set: 'This phone has no passcode configured.',
|
||||
device_not_found: 'This phone no longer exists.',
|
||||
metadata_unsupported: 'The phone inventory metadata could not be updated.',
|
||||
invalid_request: 'The admin request was invalid.',
|
||||
request_failed: 'The admin request failed.',
|
||||
default: 'The admin panel is temporarily unavailable.',
|
||||
},
|
||||
}
|
||||
|
||||
const defaultLocales: LocaleTree = {
|
||||
AdminPanel: adminPanelFallbackLocales,
|
||||
Apps: {
|
||||
citywarn: citywarnFallbackLocales,
|
||||
crypto: cryptoFallbackLocales,
|
||||
@@ -2466,7 +2086,7 @@ const defaultLocales: LocaleTree = {
|
||||
to: 'To:',
|
||||
connectPrivately: 'Connect privately',
|
||||
newChatBody:
|
||||
"Enter another person's Dark-ID or invitation code. You can share your own ID from your profile.",
|
||||
'Enter an exact Dark-ID or invitation code. Unknown identities require confirmation.',
|
||||
darkIdOrInvite: 'Dark-ID or invitation code',
|
||||
continue: 'Continue',
|
||||
contacts: 'DarkChat Contacts',
|
||||
@@ -2812,190 +2432,46 @@ const defaultLocales: LocaleTree = {
|
||||
'neon-drop': 'Neon block-dropping puzzle',
|
||||
},
|
||||
previews: {
|
||||
citywarn: {
|
||||
first: 'Live alerts',
|
||||
second: 'Safety zones',
|
||||
third: 'Incident updates',
|
||||
},
|
||||
crypto: {
|
||||
first: 'Synthetic markets',
|
||||
second: 'Portfolio',
|
||||
third: 'Wallet transfers',
|
||||
},
|
||||
health: {
|
||||
first: 'Activity rings',
|
||||
second: 'Medical ID',
|
||||
third: 'Health records',
|
||||
},
|
||||
'weazel-news': {
|
||||
first: 'Top stories',
|
||||
second: 'Local reports',
|
||||
third: 'Breaking news',
|
||||
},
|
||||
companies: {
|
||||
first: 'Business directory',
|
||||
second: 'Job requests',
|
||||
third: 'Services',
|
||||
},
|
||||
music: {
|
||||
first: 'Now playing',
|
||||
second: 'Playlists',
|
||||
third: 'Music library',
|
||||
},
|
||||
picstagram: {
|
||||
first: 'Photo feed',
|
||||
second: 'Stories',
|
||||
third: 'Profiles',
|
||||
},
|
||||
feather: {
|
||||
first: 'Short posts',
|
||||
second: 'Following feed',
|
||||
third: 'Conversations',
|
||||
},
|
||||
fliptok: {
|
||||
first: 'Video feed',
|
||||
second: 'Creator tools',
|
||||
third: 'Trends',
|
||||
},
|
||||
flare: {
|
||||
first: 'Discover people',
|
||||
second: 'Matches',
|
||||
third: 'Live moments',
|
||||
},
|
||||
calendar: {
|
||||
first: 'Upcoming events',
|
||||
second: 'Day planner',
|
||||
third: 'Reminders',
|
||||
},
|
||||
radio: {
|
||||
first: 'Live channels',
|
||||
second: 'Team radio',
|
||||
third: 'Favorites',
|
||||
},
|
||||
'local-pages': {
|
||||
first: 'Local pages',
|
||||
second: 'Reviews',
|
||||
third: 'City discovery',
|
||||
},
|
||||
crewlink: {
|
||||
first: 'Crew roster',
|
||||
second: 'Shared locations',
|
||||
third: 'Coordination',
|
||||
},
|
||||
phone: {
|
||||
first: 'Recent calls',
|
||||
second: 'Contacts',
|
||||
third: 'Voicemail',
|
||||
},
|
||||
messages: {
|
||||
first: 'Conversations',
|
||||
second: 'Media sharing',
|
||||
third: 'Quick replies',
|
||||
},
|
||||
darkchat: {
|
||||
first: 'Private chats',
|
||||
second: 'Secure groups',
|
||||
third: 'Invitations',
|
||||
},
|
||||
garage: {
|
||||
first: 'Vehicle list',
|
||||
second: 'Parking locations',
|
||||
third: 'Valet',
|
||||
},
|
||||
house: {
|
||||
first: 'Property access',
|
||||
second: 'Residents',
|
||||
third: 'Management',
|
||||
},
|
||||
map: {
|
||||
first: 'Live navigation',
|
||||
second: 'Nearby places',
|
||||
third: 'Route guidance',
|
||||
},
|
||||
skyride: {
|
||||
first: 'Ride booking',
|
||||
second: 'Driver tracking',
|
||||
third: 'Trip history',
|
||||
},
|
||||
banking: {
|
||||
first: 'Account balance',
|
||||
second: 'Transfers',
|
||||
third: 'Transactions',
|
||||
},
|
||||
billing: {
|
||||
first: 'Open invoices',
|
||||
second: 'Payment requests',
|
||||
third: 'Payment history',
|
||||
},
|
||||
citywarn: { first: 'Live alerts', second: 'Safety zones', third: 'Incident updates' },
|
||||
crypto: { first: 'Synthetic markets', second: 'Portfolio', third: 'Wallet transfers' },
|
||||
health: { first: 'Activity rings', second: 'Medical ID', third: 'Health records' },
|
||||
'weazel-news': { first: 'Top stories', second: 'Local reports', third: 'Breaking news' },
|
||||
companies: { first: 'Business directory', second: 'Job requests', third: 'Services' },
|
||||
music: { first: 'Now playing', second: 'Playlists', third: 'Music library' },
|
||||
picstagram: { first: 'Photo feed', second: 'Stories', third: 'Profiles' },
|
||||
feather: { first: 'Short posts', second: 'Following feed', third: 'Conversations' },
|
||||
fliptok: { first: 'Video feed', second: 'Creator tools', third: 'Trends' },
|
||||
flare: { first: 'Discover people', second: 'Matches', third: 'Live moments' },
|
||||
calendar: { first: 'Upcoming events', second: 'Day planner', third: 'Reminders' },
|
||||
radio: { first: 'Live channels', second: 'Team radio', third: 'Favorites' },
|
||||
'local-pages': { first: 'Local pages', second: 'Reviews', third: 'City discovery' },
|
||||
crewlink: { first: 'Crew roster', second: 'Shared locations', third: 'Coordination' },
|
||||
phone: { first: 'Recent calls', second: 'Contacts', third: 'Voicemail' },
|
||||
messages: { first: 'Conversations', second: 'Media sharing', third: 'Quick replies' },
|
||||
darkchat: { first: 'Private chats', second: 'Secure groups', third: 'Invitations' },
|
||||
garage: { first: 'Vehicle list', second: 'Parking locations', third: 'Valet' },
|
||||
house: { first: 'Property access', second: 'Residents', third: 'Management' },
|
||||
map: { first: 'Live navigation', second: 'Nearby places', third: 'Route guidance' },
|
||||
skyride: { first: 'Ride booking', second: 'Driver tracking', third: 'Trip history' },
|
||||
banking: { first: 'Account balance', second: 'Transfers', third: 'Transactions' },
|
||||
billing: { first: 'Open invoices', second: 'Payment requests', third: 'Payment history' },
|
||||
mail: { first: 'Inbox', second: 'Attachments', third: 'Mailboxes' },
|
||||
notes: { first: 'Notes', second: 'Checklists', third: 'Pinned ideas' },
|
||||
memos: {
|
||||
first: 'Voice recordings',
|
||||
second: 'Playback',
|
||||
third: 'Favorites',
|
||||
},
|
||||
calculator: {
|
||||
first: 'Basic calculation',
|
||||
second: 'Scientific tools',
|
||||
third: 'History',
|
||||
},
|
||||
camera: {
|
||||
first: 'Photo mode',
|
||||
second: 'Video capture',
|
||||
third: 'Zoom controls',
|
||||
},
|
||||
memos: { first: 'Voice recordings', second: 'Playback', third: 'Favorites' },
|
||||
calculator: { first: 'Basic calculation', second: 'Scientific tools', third: 'History' },
|
||||
camera: { first: 'Photo mode', second: 'Video capture', third: 'Zoom controls' },
|
||||
clock: { first: 'World clock', second: 'Alarms', third: 'Timers' },
|
||||
weather: {
|
||||
first: 'Current weather',
|
||||
second: 'Hourly forecast',
|
||||
third: 'Seven-day outlook',
|
||||
},
|
||||
photos: {
|
||||
first: 'Media library',
|
||||
second: 'Albums',
|
||||
third: 'Shared media',
|
||||
},
|
||||
settings: {
|
||||
first: 'Device controls',
|
||||
second: 'Privacy',
|
||||
third: 'Personalization',
|
||||
},
|
||||
weather: { first: 'Current weather', second: 'Hourly forecast', third: 'Seven-day outlook' },
|
||||
photos: { first: 'Media library', second: 'Albums', third: 'Shared media' },
|
||||
settings: { first: 'Device controls', second: 'Privacy', third: 'Personalization' },
|
||||
snake: { first: 'High score', second: 'Speed', third: 'Classic grid' },
|
||||
memory: {
|
||||
first: 'Matched pairs',
|
||||
second: 'Best time',
|
||||
third: 'Card themes',
|
||||
},
|
||||
'number-merge': {
|
||||
first: 'Highest tile',
|
||||
second: 'Score',
|
||||
third: 'Strategy grid',
|
||||
},
|
||||
minesweeper: {
|
||||
first: 'Mine counter',
|
||||
second: 'Best time',
|
||||
third: 'Difficulty',
|
||||
},
|
||||
'tower-stack': {
|
||||
first: 'Tower height',
|
||||
second: 'Perfect drops',
|
||||
third: 'High score',
|
||||
},
|
||||
'sky-flappy': {
|
||||
first: 'Flight score',
|
||||
second: 'Best run',
|
||||
third: 'Obstacles',
|
||||
},
|
||||
citymarkt: {
|
||||
first: 'Listings',
|
||||
second: 'Categories',
|
||||
third: 'Saved offers',
|
||||
},
|
||||
'neon-drop': {
|
||||
first: 'Lines cleared',
|
||||
second: 'Level',
|
||||
third: 'Neon pieces',
|
||||
},
|
||||
memory: { first: 'Matched pairs', second: 'Best time', third: 'Card themes' },
|
||||
'number-merge': { first: 'Highest tile', second: 'Score', third: 'Strategy grid' },
|
||||
minesweeper: { first: 'Mine counter', second: 'Best time', third: 'Difficulty' },
|
||||
'tower-stack': { first: 'Tower height', second: 'Perfect drops', third: 'High score' },
|
||||
'sky-flappy': { first: 'Flight score', second: 'Best run', third: 'Obstacles' },
|
||||
citymarkt: { first: 'Listings', second: 'Categories', third: 'Saved offers' },
|
||||
'neon-drop': { first: 'Lines cleared', second: 'Level', third: 'Neon pieces' },
|
||||
},
|
||||
search: {
|
||||
recommended: 'Recommended',
|
||||
@@ -3560,8 +3036,6 @@ const defaultLocales: LocaleTree = {
|
||||
viewRides: 'View Ride Options',
|
||||
change: 'Change',
|
||||
requestRide: 'Request SkyRide',
|
||||
playerDriverNotice:
|
||||
'SkyRide matches you with real player drivers. A driver must be online and accept your request.',
|
||||
serviceMeta: '{eta} min away · {seats} seats',
|
||||
distanceMeters: '{distance} m',
|
||||
distanceKilometers: '{distance} km',
|
||||
@@ -3632,8 +3106,7 @@ const defaultLocales: LocaleTree = {
|
||||
cancelled: 'Cancelled',
|
||||
},
|
||||
statusBody: {
|
||||
searching:
|
||||
'Your request is waiting for an available player driver to accept it.',
|
||||
searching: 'We are matching you with a nearby driver.',
|
||||
accepted: 'Your driver is preparing to pick you up.',
|
||||
driver_arriving: 'Your driver is on the way to your pickup.',
|
||||
arrived: 'Your driver is waiting at the pickup point.',
|
||||
@@ -4539,11 +4012,6 @@ const defaultLocales: LocaleTree = {
|
||||
invalid_media_type: 'The uploaded media type is invalid.',
|
||||
invalid_upload: 'The upload could not be verified.',
|
||||
invalid_upload_token: 'The upload session is no longer valid.',
|
||||
media_provider_failed: 'The camera upload service is unavailable.',
|
||||
media_provider_rate_limited:
|
||||
'The camera upload service is busy. Try again shortly.',
|
||||
media_provider_unauthorized:
|
||||
'The configured FiveManage API key was rejected.',
|
||||
missing_config: 'Camera uploads are not configured.',
|
||||
microphone_unavailable:
|
||||
'Allow microphone access or mute the microphone before recording.',
|
||||
@@ -5715,15 +5183,6 @@ export const usePhoneStore = defineStore('phone', {
|
||||
this.cameraLandscape = false
|
||||
this.isOpen = false
|
||||
},
|
||||
setLocale(
|
||||
lang: string,
|
||||
locales: LocaleTree,
|
||||
fallbackLocales: LocaleTree,
|
||||
): void {
|
||||
this.lang = lang
|
||||
this.locales = locales
|
||||
this.fallbackLocales = fallbackLocales
|
||||
},
|
||||
open(payload: PhoneOpenPayload = {}): void {
|
||||
const nextImei = payload.device?.imei ?? this.device?.imei ?? null
|
||||
const nextToken = payload.token ?? this.deviceSessionToken
|
||||
|
||||
@@ -16,15 +16,10 @@ describe('test data seeding contracts', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('refreshes the test-data command when its runtime configuration changes', () => {
|
||||
expect(testData).toContain('local function refresh_test_data_command()')
|
||||
expect(testData).toContain(
|
||||
'active_test_data_command = Config.TestData.Enabled and Config.TestData.Command or nil',
|
||||
)
|
||||
expect(testData).toContain(
|
||||
'AddEventHandler("sky_phone:configurator:serverUpdated", refresh_test_data_command)',
|
||||
)
|
||||
expect(testData).not.toContain('RegisterCommand(Config.TestData.Command')
|
||||
it('returns before registering the test-data command when disabled', () => {
|
||||
expect(
|
||||
testData.indexOf('if not Config.TestData.Enabled then'),
|
||||
).toBeLessThan(testData.indexOf('RegisterCommand(Config.TestData.Command'))
|
||||
})
|
||||
|
||||
it('moves an existing player SIM before attaching it to the selected phone', () => {
|
||||
|
||||
@@ -1,187 +0,0 @@
|
||||
export type AdminStats = {
|
||||
accounts: number
|
||||
activeDevices: number
|
||||
auditEntries: number
|
||||
auditToday: number
|
||||
callsToday: number
|
||||
devices: number
|
||||
linkedDevices: number
|
||||
messagesToday: number
|
||||
online: number
|
||||
simDevices: number
|
||||
}
|
||||
|
||||
export type AdminPlayerSummary = {
|
||||
deviceCount: number
|
||||
grade: number
|
||||
identifier: string
|
||||
job: string
|
||||
name: string
|
||||
onDuty: boolean
|
||||
phoneNumber: string | null
|
||||
serverName: string
|
||||
source: number
|
||||
}
|
||||
|
||||
export type AdminDevice = {
|
||||
account: {
|
||||
email: string
|
||||
id: number
|
||||
passwordAvailable: boolean
|
||||
} | null
|
||||
apps: {
|
||||
claimed: string[]
|
||||
revision: number
|
||||
uninstalled: string[]
|
||||
}
|
||||
createdAt: string
|
||||
imei: string
|
||||
name: string
|
||||
number: string | null
|
||||
security: {
|
||||
enabled: boolean
|
||||
failedAttempts: number
|
||||
length: number | null
|
||||
lockedUntil: number
|
||||
}
|
||||
simRegistered: boolean
|
||||
simType: string | null
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export type AdminPlayerDetail = {
|
||||
birthdate: string
|
||||
devices: AdminDevice[]
|
||||
firstName: string
|
||||
identifier: string
|
||||
job: {
|
||||
grade: number
|
||||
gradeLabel: string
|
||||
label: string
|
||||
name: string
|
||||
onDuty: boolean
|
||||
}
|
||||
lastName: string
|
||||
money: {
|
||||
bank: number
|
||||
cash: number
|
||||
currency: string
|
||||
}
|
||||
name: string
|
||||
serverName: string
|
||||
source: number
|
||||
}
|
||||
|
||||
export type AdminAuditEntry = {
|
||||
action: string
|
||||
actorName: string
|
||||
createdAt: string
|
||||
details: Record<string, unknown>
|
||||
deviceImei: string | null
|
||||
id: number
|
||||
targetIdentifier: string
|
||||
targetSource: number | null
|
||||
}
|
||||
|
||||
export type AdminBootstrap = {
|
||||
audit: AdminAuditEntry[]
|
||||
disabledApps: string[]
|
||||
players: AdminPlayerSummary[]
|
||||
stats: AdminStats
|
||||
}
|
||||
|
||||
export type AdminCredential = {
|
||||
email: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export type AdminMessageActivity = {
|
||||
body: string
|
||||
createdAt: string
|
||||
direction: 'incoming' | 'outgoing'
|
||||
id: string
|
||||
messageType: string
|
||||
otherNumber: string
|
||||
readAt: string | null
|
||||
}
|
||||
|
||||
export type AdminCallActivity = {
|
||||
answeredAt: string | null
|
||||
direction: 'incoming' | 'outgoing'
|
||||
durationSeconds: number
|
||||
endedAt: string | null
|
||||
id: string
|
||||
otherNumber: string
|
||||
startedAt: string
|
||||
status: string
|
||||
}
|
||||
|
||||
export type AdminActivityResponse =
|
||||
| { entries: AdminMessageActivity[]; kind: 'messages' }
|
||||
| { entries: AdminCallActivity[]; kind: 'calls' }
|
||||
|
||||
export type AdminConfiguratorField = {
|
||||
configured?: boolean
|
||||
label: string
|
||||
path: string
|
||||
structure?: AdminConfiguratorStructure
|
||||
scope: 'config' | 'media'
|
||||
sensitive: boolean
|
||||
type: 'boolean' | 'json' | 'number' | 'string' | 'stringOrFalse'
|
||||
value: unknown
|
||||
}
|
||||
|
||||
export type AdminConfiguratorStructure =
|
||||
| {
|
||||
kind: 'list'
|
||||
items: AdminConfiguratorStructure[]
|
||||
template?: AdminConfiguratorStructure
|
||||
}
|
||||
| {
|
||||
fields: Record<string, AdminConfiguratorStructure>
|
||||
kind: 'table'
|
||||
mutableKeys?: boolean
|
||||
template?: AdminConfiguratorStructure
|
||||
}
|
||||
| {
|
||||
entries: Array<{
|
||||
key: number | string
|
||||
keyType: 'number' | 'string'
|
||||
structure: AdminConfiguratorStructure
|
||||
}>
|
||||
keyType?: 'number' | 'string'
|
||||
kind: 'map'
|
||||
template?: AdminConfiguratorStructure
|
||||
}
|
||||
| {
|
||||
kind: 'value'
|
||||
valueType: 'boolean' | 'number' | 'string'
|
||||
}
|
||||
| {
|
||||
kind: 'optionalString'
|
||||
}
|
||||
| {
|
||||
kind: 'vector'
|
||||
vectorType: 'vector2' | 'vector3' | 'vector4'
|
||||
}
|
||||
|
||||
export type AdminConfiguratorSection = {
|
||||
fields: AdminConfiguratorField[]
|
||||
id: string
|
||||
label: string
|
||||
scope: 'config' | 'media'
|
||||
}
|
||||
|
||||
export type AdminConfigurator = {
|
||||
enabled: boolean
|
||||
revision: number
|
||||
sections: AdminConfiguratorSection[]
|
||||
updatedAt: string | null
|
||||
updatedBy: string | null
|
||||
}
|
||||
|
||||
export type AdminConfiguratorChange = {
|
||||
path: string
|
||||
scope: 'config' | 'media'
|
||||
value: unknown
|
||||
}
|
||||
@@ -68,7 +68,6 @@ export type AppLaunchOrigin = {
|
||||
}
|
||||
|
||||
type PhoneAppDefinitionBase = {
|
||||
adminOnly?: boolean
|
||||
category: PhoneAppCategory
|
||||
dockOrder: number | null
|
||||
gridOrder: number
|
||||
@@ -160,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: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Note } from '@/utils/notes'
|
||||
import type { MemoDto } from '@/types/memos'
|
||||
import type { PhoneNumberFormat, PhoneSim } from '@/types/phone'
|
||||
import type { PhoneSim } from '@/types/phone'
|
||||
|
||||
export type DeviceDataEntry<T = unknown> = {
|
||||
payload: T
|
||||
@@ -50,7 +50,6 @@ export type DeviceBootstrap = {
|
||||
device: PhoneDevice
|
||||
memos: MemoDto[]
|
||||
notes: Note[]
|
||||
phoneNumberFormat: PhoneNumberFormat
|
||||
player: PhonePlayerIdentity
|
||||
security: DeviceSecurity
|
||||
token: string
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
export type DynamicIslandActivity =
|
||||
| 'call'
|
||||
| 'incoming-call'
|
||||
| 'music'
|
||||
| 'recording'
|
||||
| 'stopwatch'
|
||||
| 'timer'
|
||||
@@ -70,6 +70,7 @@ export type MediaImportResult = {
|
||||
}
|
||||
|
||||
export type UploadReady = {
|
||||
captureToken: string
|
||||
correlationId: string
|
||||
mediaType: MediaType
|
||||
photo?: {
|
||||
|
||||
@@ -39,6 +39,7 @@ export type MemoRecordingMetadata = {
|
||||
export type MemoUploadReady = {
|
||||
requestId: string
|
||||
correlationId: string
|
||||
captureToken: string
|
||||
presignedUrl: string
|
||||
uploadTimeoutMs?: number
|
||||
}
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
export type SimType = 'registered' | 'anonymous'
|
||||
|
||||
export type PhoneNumberFormat = {
|
||||
groups: number[]
|
||||
length: number
|
||||
}
|
||||
|
||||
export type PhoneSim = {
|
||||
id: string
|
||||
number: string
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const sourceDirectory = fileURLToPath(new URL('..', import.meta.url))
|
||||
const tokensSource = readFileSync(new URL('./tokens.css', import.meta.url), 'utf8')
|
||||
const mainCssSource = readFileSync(
|
||||
new URL('../assets/main.css', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
function styleSources(directory: string): Array<{
|
||||
file: string
|
||||
source: string
|
||||
}> {
|
||||
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||
const path = join(directory, entry.name)
|
||||
if (entry.isDirectory()) return styleSources(path)
|
||||
if (!/\.(?:css|vue)$/.test(entry.name)) return []
|
||||
return [{ file: path, source: readFileSync(path, 'utf8') }]
|
||||
})
|
||||
}
|
||||
|
||||
describe('Inter font contract', () => {
|
||||
it('bundles normal and italic variable fonts for every supported weight', () => {
|
||||
expect(
|
||||
existsSync(new URL('../assets/fonts/InterVariable.woff2', import.meta.url)),
|
||||
).toBe(true)
|
||||
expect(
|
||||
existsSync(
|
||||
new URL('../assets/fonts/InterVariable-Italic.woff2', import.meta.url),
|
||||
),
|
||||
).toBe(true)
|
||||
expect(tokensSource.match(/@font-face/g)).toHaveLength(2)
|
||||
expect(tokensSource.match(/font-weight:\s*100 900;/g)).toHaveLength(2)
|
||||
expect(tokensSource).toContain("--sky-font-family: 'Inter', Arial, sans-serif;")
|
||||
})
|
||||
|
||||
it('applies the shared font to the document and native form controls', () => {
|
||||
expect(mainCssSource).toMatch(
|
||||
/:root\s*\{[\s\S]*?font-family:\s*var\(--sky-font-family\);/,
|
||||
)
|
||||
expect(mainCssSource).toMatch(
|
||||
/button,\s*input,\s*textarea,\s*select\s*\{\s*font:\s*inherit;/,
|
||||
)
|
||||
})
|
||||
|
||||
it('does not bypass the shared token with a generic system UI stack', () => {
|
||||
const genericSystemStack =
|
||||
/font-family\s*:\s*(?:-apple-system|BlinkMacSystemFont|system-ui|ui-sans-serif|['"]Segoe UI['"])/
|
||||
const violations = styleSources(sourceDirectory)
|
||||
.filter(({ source }) => genericSystemStack.test(source))
|
||||
.map(({ file }) => file.slice(sourceDirectory.length + 1))
|
||||
|
||||
expect(violations).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -111,18 +111,6 @@
|
||||
color: var(--sky-text, #111827);
|
||||
}
|
||||
|
||||
.sky-glass.sky-button--glass {
|
||||
border-color: var(--sky-hairline, rgba(0, 0, 0, 0.2));
|
||||
background: var(--sky-glass, rgba(255, 255, 255, 0.75));
|
||||
color: var(--sky-text, #000000);
|
||||
box-shadow: var(--sky-shadow-glass);
|
||||
}
|
||||
|
||||
.sky-glass.sky-button--glass:active:not(:disabled) {
|
||||
background: var(--sky-glass, rgba(255, 255, 255, 0.75));
|
||||
filter: brightness(0.94);
|
||||
}
|
||||
|
||||
.sky-button--danger {
|
||||
background: var(--sky-danger, #dc2626);
|
||||
}
|
||||
@@ -2745,9 +2733,6 @@ label.sky-list-item__row {
|
||||
|
||||
.sky-fab--icon-only {
|
||||
width: var(--sky-touch-target, 44px);
|
||||
height: var(--sky-touch-target, 44px);
|
||||
flex: none;
|
||||
align-self: center;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@@ -2756,16 +2741,6 @@ label.sky-list-item__row {
|
||||
color: var(--sky-text, #000000);
|
||||
}
|
||||
|
||||
.sky-glass.sky-fab--glass {
|
||||
border: 1px solid var(--sky-hairline, rgba(0, 0, 0, 0.2));
|
||||
background: var(--sky-glass, rgba(255, 255, 255, 0.75));
|
||||
color: var(--sky-text, #000000);
|
||||
box-shadow: var(--sky-shadow-glass);
|
||||
}
|
||||
|
||||
.sky-fab--glass .sky-fab__accent-layer,
|
||||
.sky-fab--glass .sky-fab__dark-accent-layer,
|
||||
.sky-fab--glass .sky-fab__surface-layer,
|
||||
.sky-fab--neutral .sky-fab__accent-layer,
|
||||
.sky-fab--neutral .sky-fab__dark-accent-layer,
|
||||
.sky-fab--neutral .sky-fab__surface-layer {
|
||||
@@ -2780,10 +2755,6 @@ label.sky-list-item__row {
|
||||
background: var(--sky-glass-solid, rgba(247, 247, 248, 0.96));
|
||||
}
|
||||
|
||||
.sky-glass.sky-fab--glass:active:not(:disabled) {
|
||||
background: var(--sky-glass, rgba(255, 255, 255, 0.75));
|
||||
}
|
||||
|
||||
.sky-fab--disabled {
|
||||
cursor: default;
|
||||
opacity: 0.42;
|
||||
@@ -2854,8 +2825,6 @@ label.sky-list-item__row {
|
||||
.sky-glass--interactive {
|
||||
min-width: var(--sky-touch-target, 44px);
|
||||
min-height: var(--sky-touch-target, 44px);
|
||||
-webkit-backdrop-filter: blur(18px) saturate(145%);
|
||||
backdrop-filter: blur(18px) saturate(145%);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
|
||||
@@ -28,30 +28,6 @@ describe('SkyButton', () => {
|
||||
expect(html).toContain('Continue')
|
||||
})
|
||||
|
||||
it('renders interactive liquid glass buttons through the shared glass surface', async () => {
|
||||
const html = await renderToString(
|
||||
createSSRApp({
|
||||
render: () =>
|
||||
h(SkyButton, { glass: true, rounded: true }, () => 'Edit'),
|
||||
}),
|
||||
)
|
||||
|
||||
expect(html).toContain('sky-button--glass')
|
||||
expect(html).toContain('sky-glass')
|
||||
expect(html).toContain('sky-glass--interactive')
|
||||
|
||||
const controls = readFileSync(
|
||||
fileURLToPath(new URL('../controls.css', import.meta.url)),
|
||||
'utf8',
|
||||
)
|
||||
expect(controls).toMatch(
|
||||
/\.sky-glass\.sky-button--glass\s*\{[^}]*background:\s*var\(--sky-glass[^}]*box-shadow:\s*var\(--sky-shadow-glass\)/s,
|
||||
)
|
||||
expect(controls).toMatch(
|
||||
/\.sky-glass--interactive\s*\{[^}]*-webkit-backdrop-filter:\s*blur\(18px\) saturate\(145%\);[^}]*backdrop-filter:\s*blur\(18px\) saturate\(145%\);/s,
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps focus and pressed feedback on the contextual accent', () => {
|
||||
const uiDirectory = fileURLToPath(new URL('..', import.meta.url))
|
||||
const controls = readFileSync(`${uiDirectory}/controls.css`, 'utf8')
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import SkyGlass from './SkyGlass.vue'
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
const props = withDefaults(
|
||||
@@ -11,7 +9,6 @@ const props = withDefaults(
|
||||
clear?: boolean
|
||||
component?: 'a' | 'button'
|
||||
disabled?: boolean
|
||||
glass?: boolean
|
||||
href?: string
|
||||
iconOnly?: boolean
|
||||
inline?: boolean
|
||||
@@ -29,7 +26,6 @@ const props = withDefaults(
|
||||
clear: false,
|
||||
component: 'button',
|
||||
disabled: false,
|
||||
glass: false,
|
||||
href: undefined,
|
||||
iconOnly: false,
|
||||
inline: false,
|
||||
@@ -48,23 +44,6 @@ const emit = defineEmits<{
|
||||
click: [event: MouseEvent]
|
||||
}>()
|
||||
|
||||
const buttonClasses = computed(() => [
|
||||
`sky-button--${props.variant}`,
|
||||
{
|
||||
'sky-button--block': props.block,
|
||||
'sky-button--clear': props.clear,
|
||||
'sky-button--glass': props.glass,
|
||||
'sky-button--icon-only': props.iconOnly,
|
||||
'sky-button--inline': props.inline,
|
||||
'sky-button--large': props.large,
|
||||
'sky-button--outline': props.outline,
|
||||
'sky-button--raised': props.raised,
|
||||
'sky-button--rounded': props.rounded,
|
||||
'sky-button--small': props.small && !props.large,
|
||||
'sky-button--tonal': props.tonal,
|
||||
},
|
||||
])
|
||||
|
||||
const elementProps = computed<Record<string, unknown>>(() => {
|
||||
if (props.component === 'a') {
|
||||
return {
|
||||
@@ -92,25 +71,25 @@ function handleClick(event: MouseEvent): void {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SkyGlass
|
||||
v-if="glass"
|
||||
:component="component"
|
||||
v-bind="{ ...$attrs, ...elementProps }"
|
||||
class="sky-button"
|
||||
:class="buttonClasses"
|
||||
:disabled="disabled"
|
||||
:href="href"
|
||||
:type="type"
|
||||
@click="handleClick"
|
||||
>
|
||||
<slot />
|
||||
</SkyGlass>
|
||||
<component
|
||||
v-else
|
||||
:is="component"
|
||||
v-bind="{ ...$attrs, ...elementProps }"
|
||||
class="sky-button"
|
||||
:class="buttonClasses"
|
||||
:class="[
|
||||
`sky-button--${variant}`,
|
||||
{
|
||||
'sky-button--block': block,
|
||||
'sky-button--clear': clear,
|
||||
'sky-button--icon-only': iconOnly,
|
||||
'sky-button--inline': inline,
|
||||
'sky-button--large': large,
|
||||
'sky-button--outline': outline,
|
||||
'sky-button--raised': raised,
|
||||
'sky-button--rounded': rounded,
|
||||
'sky-button--small': small && !large,
|
||||
'sky-button--tonal': tonal,
|
||||
},
|
||||
]"
|
||||
@click="handleClick"
|
||||
>
|
||||
<slot />
|
||||
|
||||
@@ -73,33 +73,4 @@ describe('SkyFab', () => {
|
||||
/\.sky-glass\.sky-fab--neutral\s*\{[^}]*background:\s*var\(--sky-glass-solid/s,
|
||||
)
|
||||
})
|
||||
|
||||
it('offers a translucent glass variant for adjacent floating controls', async () => {
|
||||
const html = await renderToString(
|
||||
createSSRApp({
|
||||
render: () => h(SkyFab, { ariaLabel: 'Create', variant: 'glass' }),
|
||||
}),
|
||||
)
|
||||
|
||||
expect(html).toContain('sky-fab--glass')
|
||||
|
||||
const controls = readFileSync(
|
||||
fileURLToPath(new URL('../controls.css', import.meta.url)),
|
||||
'utf8',
|
||||
)
|
||||
expect(controls).toMatch(
|
||||
/\.sky-glass\.sky-fab--glass\s*\{[^}]*border:\s*1px solid var\(--sky-hairline[^}]*background:\s*var\(--sky-glass[^}]*box-shadow:\s*var\(--sky-shadow-glass\)/s,
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps icon-only fabs perfectly square inside stretching toolbars', () => {
|
||||
const controls = readFileSync(
|
||||
fileURLToPath(new URL('../controls.css', import.meta.url)),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
expect(controls).toMatch(
|
||||
/\.sky-fab--icon-only\s*\{[^}]*width:\s*var\(--sky-touch-target, 44px\);[^}]*height:\s*var\(--sky-touch-target, 44px\);[^}]*flex:\s*none;[^}]*align-self:\s*center;/s,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,7 +14,7 @@ const props = withDefaults(
|
||||
text?: string
|
||||
textPosition?: 'after' | 'before'
|
||||
type?: 'button' | 'reset' | 'submit'
|
||||
variant?: 'glass' | 'neutral' | 'primary'
|
||||
variant?: 'neutral' | 'primary'
|
||||
}>(),
|
||||
{
|
||||
ariaLabel: '',
|
||||
@@ -72,7 +72,6 @@ function handleClick(event: MouseEvent): void {
|
||||
class="sky-fab"
|
||||
:class="{
|
||||
'sky-fab--disabled': disabled,
|
||||
'sky-fab--glass': variant === 'glass',
|
||||
'sky-fab--icon-only': !hasText,
|
||||
'sky-fab--neutral': variant === 'neutral',
|
||||
'sky-fab--with-text': hasText,
|
||||
|
||||
Vendored
+3
-2
@@ -525,8 +525,9 @@
|
||||
height: var(--sky-widget-label-height);
|
||||
overflow: hidden;
|
||||
color: var(--sky-widget-label-color, #fff);
|
||||
font-family: var(--sky-font-family);
|
||||
font-size: var(--sky-home-label-font-size);
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', sans-serif;
|
||||
font-size: 11.5px;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.15px;
|
||||
line-height: var(--sky-widget-label-height);
|
||||
|
||||
@@ -1,19 +1,3 @@
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url('../assets/fonts/InterVariable.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: italic;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url('../assets/fonts/InterVariable-Italic.woff2') format('woff2');
|
||||
}
|
||||
|
||||
:root {
|
||||
--sky-device-pixel-ratio: 1;
|
||||
--sky-hairline-scale: 1;
|
||||
@@ -42,7 +26,9 @@
|
||||
--sky-font-title: 17px;
|
||||
--sky-font-medium-title: 24px;
|
||||
--sky-font-large-title: 34px;
|
||||
--sky-font-family: 'Inter', Arial, sans-serif;
|
||||
--sky-font-family:
|
||||
-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'SF UI Text',
|
||||
'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
--sky-transition-fast: 100ms;
|
||||
--sky-transition-normal: 200ms;
|
||||
--sky-ease-out: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
@@ -69,10 +55,8 @@
|
||||
--sky-shadow-thumb:
|
||||
0 0.5px 4px rgba(0, 0, 0, 0.12), 0 6px 13px rgba(0, 0, 0, 0.12);
|
||||
--sky-glass-highlight-color: rgba(255, 255, 255, 0.5);
|
||||
--sky-home-label-font-size: 13px;
|
||||
--sky-home-label-height: 16px;
|
||||
--sky-widget-label-gap: 5px;
|
||||
--sky-widget-label-height: var(--sky-home-label-height);
|
||||
--sky-widget-label-height: 15px;
|
||||
--sky-widget-radius-small: 23px;
|
||||
--sky-widget-radius-medium: 25px;
|
||||
--sky-widget-radius-large: 28px;
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { AdminConfiguratorStructure } from '@/types/admin'
|
||||
|
||||
import {
|
||||
configuratorDescriptionKey,
|
||||
configuratorPathName,
|
||||
describeConfiguratorValue,
|
||||
} from './adminConfiguratorDescription'
|
||||
|
||||
describe('admin configurator descriptions', () => {
|
||||
it('selects specific descriptions before generic value descriptions', () => {
|
||||
expect(configuratorDescriptionKey('Bridge.CallbackTimeout', 15000)).toBe(
|
||||
'milliseconds',
|
||||
)
|
||||
expect(configuratorDescriptionKey('Media.RequestTimeoutMs', 10000)).toBe(
|
||||
'milliseconds',
|
||||
)
|
||||
expect(configuratorDescriptionKey('Radio.AllowedJobs', [])).toBe('access')
|
||||
expect(configuratorDescriptionKey('FiveManage.ApiKey', '')).toBe(
|
||||
'credential',
|
||||
)
|
||||
})
|
||||
|
||||
it('describes structured values from their schema', () => {
|
||||
const vector: AdminConfiguratorStructure = {
|
||||
kind: 'vector',
|
||||
vectorType: 'vector3',
|
||||
}
|
||||
const table: AdminConfiguratorStructure = {
|
||||
fields: {},
|
||||
kind: 'table',
|
||||
}
|
||||
expect(configuratorDescriptionKey('Location', {}, vector)).toBe(
|
||||
'coordinates',
|
||||
)
|
||||
expect(configuratorDescriptionKey('Settings', {}, table)).toBe('table')
|
||||
})
|
||||
|
||||
it('passes a readable field name to the localized template', () => {
|
||||
const translate = (key: string, params?: Record<string, string>) =>
|
||||
`${key}:${params?.name}`
|
||||
expect(
|
||||
describeConfiguratorValue(
|
||||
translate,
|
||||
'CustomApps.MaximumStorageBytesPerApp',
|
||||
262144,
|
||||
),
|
||||
).toBe('configurator.descriptions.byteLimit:Maximum Storage Bytes Per App')
|
||||
expect(
|
||||
describeConfiguratorValue(translate, 'Radio.AllowedJobs[2]', 'police'),
|
||||
).toBe('configurator.descriptions.access:Allowed Jobs #2')
|
||||
})
|
||||
|
||||
it('humanizes subtab keys', () => {
|
||||
expect(configuratorPathName('ExternalPingResources')).toBe(
|
||||
'External Ping Resources',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,103 +0,0 @@
|
||||
import type { AdminConfiguratorStructure } from '@/types/admin'
|
||||
|
||||
type ConfiguratorDescriptionTranslator = (
|
||||
key: string,
|
||||
params?: Record<string, string>,
|
||||
) => string
|
||||
|
||||
export type AdminConfiguratorDescribe = (
|
||||
path: string,
|
||||
value: unknown,
|
||||
structure?: AdminConfiguratorStructure,
|
||||
label?: string,
|
||||
) => string
|
||||
|
||||
const DESCRIPTION_RULES: Array<[RegExp, string]> = [
|
||||
[/(?:^|\.)(?:apikey|token|password|secret)$/i, 'credential'],
|
||||
[/(?:base|manifest|image|icon)?url$/i, 'url'],
|
||||
[/(?:allowed)?(?:gif|media)?hosts?$/i, 'hosts'],
|
||||
[
|
||||
/(?:timeout|timeoutms|milliseconds|durationms|intervalms|pollms)$/i,
|
||||
'milliseconds',
|
||||
],
|
||||
[/(?:timeoutseconds|seconds)$/i, 'seconds'],
|
||||
[/perminute$/i, 'rateLimit'],
|
||||
[/(?:maximum|max).*bytes/i, 'byteLimit'],
|
||||
[/(?:maximum|max).*length$|length$/i, 'textLimit'],
|
||||
[/(?:distance)$/i, 'distance'],
|
||||
[/(?:location|position|rotation|coords|coordinates)$/i, 'coordinates'],
|
||||
[/(?:model|prop|propmodel|customprop|replacementprop)$/i, 'gameAsset'],
|
||||
[/(?:dictionary|dictionaries|clip|clips|pedclip|propclip)$/i, 'animation'],
|
||||
[
|
||||
/(?:permissions?|admingroups?|allowedjobs?|jobs?|minimumgrade|requiredace)$/i,
|
||||
'access',
|
||||
],
|
||||
[/(?:framework|inventory|provider|voiceprovider|adapter)$/i, 'integration'],
|
||||
[/(?:path)$/i, 'path'],
|
||||
[/(?:color|colour|accent)$/i, 'color'],
|
||||
[
|
||||
/(?:label|name|title|description|address|district|locationlabel|devicename)$/i,
|
||||
'displayText',
|
||||
],
|
||||
[/(?:number|callernumber|numberprefix)$/i, 'phoneNumber'],
|
||||
[/(?:routing)$/i, 'routing'],
|
||||
[/(?:command)$/i, 'command'],
|
||||
[/(?:locale)$/i, 'locale'],
|
||||
[/(?:debug)$/i, 'debug'],
|
||||
[/(?:enabled|active|public|verified)$/i, 'featureToggle'],
|
||||
[/(?:quality|bitratekbps|volume)$/i, 'mediaQuality'],
|
||||
[
|
||||
/(?:pagesize|batchsize|limit|count|maxselection|maximumplayers|samples|decimals)$/i,
|
||||
'amount',
|
||||
],
|
||||
]
|
||||
|
||||
export function configuratorPathName(path: string): string {
|
||||
const listEntry = path.match(/^(.*)\[(\d+)\]$/)
|
||||
const source = listEntry?.[1] ?? path
|
||||
const segment = source.split('.').at(-1) ?? source
|
||||
const name = segment
|
||||
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
|
||||
.replace(/([a-z\d])([A-Z])/g, '$1 $2')
|
||||
.replace(/[_-]+/g, ' ')
|
||||
.trim()
|
||||
return listEntry ? `${name} #${listEntry[2]}` : name
|
||||
}
|
||||
|
||||
export function configuratorDescriptionKey(
|
||||
path: string,
|
||||
value: unknown,
|
||||
structure?: AdminConfiguratorStructure,
|
||||
): string {
|
||||
const segment =
|
||||
path
|
||||
.replace(/\[\d+\]$/, '')
|
||||
.split('.')
|
||||
.at(-1) ?? path
|
||||
const semanticRule = DESCRIPTION_RULES.find(([pattern]) =>
|
||||
pattern.test(segment),
|
||||
)
|
||||
if (semanticRule) return semanticRule[1]
|
||||
|
||||
if (structure?.kind === 'vector') return 'coordinates'
|
||||
if (structure?.kind === 'list' || Array.isArray(value)) return 'list'
|
||||
if (structure?.kind === 'map' || structure?.kind === 'table') return 'table'
|
||||
if (structure?.kind === 'optionalString') return 'optionalText'
|
||||
if (value !== null && typeof value === 'object') return 'table'
|
||||
if (typeof value === 'boolean') return 'boolean'
|
||||
if (typeof value === 'number') return 'number'
|
||||
return 'text'
|
||||
}
|
||||
|
||||
export function describeConfiguratorValue(
|
||||
translate: ConfiguratorDescriptionTranslator,
|
||||
path: string,
|
||||
value: unknown,
|
||||
structure?: AdminConfiguratorStructure,
|
||||
label?: string,
|
||||
): string {
|
||||
return translate(
|
||||
`configurator.descriptions.${configuratorDescriptionKey(path, value, structure)}`,
|
||||
{ name: label?.trim() || configuratorPathName(path) },
|
||||
)
|
||||
}
|
||||
@@ -13,10 +13,7 @@ const previewModules = import.meta.glob<string>(
|
||||
|
||||
const APP_STORE_PREVIEW_IMAGES = Object.fromEntries(
|
||||
Object.entries(previewModules).map(([path, imageUrl]) => {
|
||||
const appId = path
|
||||
.split('/')
|
||||
.at(-1)
|
||||
?.replace(/\.jpg$/, '')
|
||||
const appId = path.split('/').at(-1)?.replace(/\.jpg$/, '')
|
||||
if (!appId) throw new Error(`Invalid App Store preview path: ${path}`)
|
||||
return [appId, imageUrl]
|
||||
}),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { readdirSync, readFileSync } from 'node:fs'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
@@ -17,8 +17,7 @@ function escapeRegExp(value: string): string {
|
||||
describe('App Store preview catalog', () => {
|
||||
it('contains a real captured screenshot for every built-in store app', () => {
|
||||
const storeAppIds = PHONE_APPS.filter(
|
||||
(app) =>
|
||||
app.kind !== 'external' && app.id !== 'app-store' && !app.adminOnly,
|
||||
(app) => app.kind !== 'external' && app.id !== 'app-store',
|
||||
).map((app) => app.id)
|
||||
|
||||
expect([...APP_STORE_PREVIEW_IMAGE_IDS].sort()).toEqual(storeAppIds.sort())
|
||||
@@ -26,8 +25,7 @@ describe('App Store preview catalog', () => {
|
||||
|
||||
it('provides specialized preview data for every built-in store app', () => {
|
||||
const storeAppIds = PHONE_APPS.filter(
|
||||
(app) =>
|
||||
app.kind !== 'external' && app.id !== 'app-store' && !app.adminOnly,
|
||||
(app) => app.kind !== 'external' && app.id !== 'app-store',
|
||||
).map((app) => app.id)
|
||||
|
||||
expect([...PREVIEWABLE_BUILTIN_APP_IDS].sort()).toEqual(
|
||||
@@ -41,21 +39,24 @@ describe('App Store preview catalog', () => {
|
||||
expect(visualSignatures.size).toBe(storeAppIds.length)
|
||||
})
|
||||
|
||||
it('localizes every specialized preview in the fallback and every configured locale', () => {
|
||||
const localeDirectory = fileURLToPath(
|
||||
new URL('../../../sky_phone/config/locales/', import.meta.url),
|
||||
)
|
||||
it('localizes every specialized preview in the fallback, English and German locales', () => {
|
||||
const localeSources = [
|
||||
readFileSync(
|
||||
fileURLToPath(new URL('../stores/phone.ts', import.meta.url)),
|
||||
'utf8',
|
||||
),
|
||||
...readdirSync(localeDirectory)
|
||||
.filter((fileName) => fileName.endsWith('.lua'))
|
||||
.sort()
|
||||
.map((fileName) =>
|
||||
readFileSync(`${localeDirectory}/${fileName}`, 'utf8'),
|
||||
readFileSync(
|
||||
fileURLToPath(
|
||||
new URL('../../../sky_phone/config/locales/en.lua', import.meta.url),
|
||||
),
|
||||
'utf8',
|
||||
),
|
||||
readFileSync(
|
||||
fileURLToPath(
|
||||
new URL('../../../sky_phone/config/locales/de.lua', import.meta.url),
|
||||
),
|
||||
'utf8',
|
||||
),
|
||||
]
|
||||
|
||||
for (const appId of PREVIEWABLE_BUILTIN_APP_IDS) {
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
moveHomeFolderApp,
|
||||
parseHomeLayout,
|
||||
reflowHomeGridForWidgetChange,
|
||||
removeDockGridDuplicates,
|
||||
removeHomeApp,
|
||||
renameHomeFolder,
|
||||
restoreHomeApp,
|
||||
@@ -79,40 +78,6 @@ describe('home layout', () => {
|
||||
expect(layout.version).toBe(HOME_LAYOUT_VERSION)
|
||||
})
|
||||
|
||||
it('removes dock apps from the grid and normalizes affected folders', () => {
|
||||
const layout: HomeLayout = {
|
||||
...defaults,
|
||||
dock: [
|
||||
'phone',
|
||||
{
|
||||
apps: ['messages', 'mail'],
|
||||
id: 'folder-abcdef',
|
||||
name: 'Dock',
|
||||
type: 'folder',
|
||||
},
|
||||
null,
|
||||
null,
|
||||
],
|
||||
grid: [
|
||||
'phone',
|
||||
{
|
||||
apps: ['messages', 'notes'],
|
||||
id: 'folder-ghijkl',
|
||||
name: 'Grid',
|
||||
type: 'folder',
|
||||
},
|
||||
'mail',
|
||||
'clock',
|
||||
...Array.from({ length: HOME_GRID_PAGE_SIZE - 4 }, () => null),
|
||||
],
|
||||
}
|
||||
|
||||
const normalized = removeDockGridDuplicates(layout)
|
||||
|
||||
expect(normalized.grid.slice(0, 3)).toEqual(['notes', 'clock', null])
|
||||
expect(normalized.dock).toEqual(layout.dock)
|
||||
})
|
||||
|
||||
it('migrates compact persisted arrays and appends newly installed apps', () => {
|
||||
const layout = parseHomeLayout(
|
||||
{
|
||||
|
||||
@@ -505,37 +505,6 @@ export function createDefaultHomeLayout(
|
||||
}
|
||||
}
|
||||
|
||||
export function removeDockGridDuplicates(layout: HomeLayout): HomeLayout {
|
||||
const dockAppIds = new Set<LaunchablePhoneAppId>()
|
||||
for (const item of layout.dock) {
|
||||
if (typeof item === 'string') dockAppIds.add(item)
|
||||
if (isHomeFolder(item)) {
|
||||
for (const appId of item.apps) dockAppIds.add(appId)
|
||||
}
|
||||
}
|
||||
if (!dockAppIds.size) return layout
|
||||
|
||||
let changed = false
|
||||
const grid = layout.grid.map((item): HomeSlot => {
|
||||
if (typeof item === 'string') {
|
||||
if (!dockAppIds.has(item)) return item
|
||||
changed = true
|
||||
return null
|
||||
}
|
||||
if (!isHomeFolder(item)) return null
|
||||
|
||||
const apps = item.apps.filter((appId) => !dockAppIds.has(appId))
|
||||
if (apps.length === item.apps.length) return cloneItem(item)
|
||||
changed = true
|
||||
return normalizeFolder({ ...item, apps })
|
||||
})
|
||||
if (!changed) return layout
|
||||
|
||||
const next = cloneLayout(layout)
|
||||
next.grid = compactGridPages(grid)
|
||||
return next
|
||||
}
|
||||
|
||||
export function parseHomeLayout(
|
||||
value: unknown,
|
||||
defaults: HomeLayout,
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { runInNewContext } from 'node:vm'
|
||||
|
||||
import type { ExternalPhoneAppDefinition } from '@/types/apps'
|
||||
import {
|
||||
createLbPhoneFrameDocument,
|
||||
createLbPhoneHostSettings,
|
||||
getLbPhoneCallbackResource,
|
||||
getLbPhoneStorageKey,
|
||||
readLbPhoneStorage,
|
||||
usesLbPhoneHostRuntime,
|
||||
writeLbPhoneStorage,
|
||||
} from '@/utils/lbPhoneAppBridge'
|
||||
import { DEFAULT_PHONE_PREFERENCES } from '@/utils/preferences'
|
||||
|
||||
@@ -94,10 +90,9 @@ describe('LB Phone app bridge', () => {
|
||||
|
||||
it('injects the LB runtime and asset base before the vendor bundle', () => {
|
||||
const html =
|
||||
'<!doctype html><html><head><script>globalThis.previewMode = !window.invokeNative</script><script type="module" src="/ui/dist/assets/index.js"></script></head><body></body></html>'
|
||||
'<!doctype html><html><head><script type="module" src="/ui/dist/assets/index.js"></script></head><body></body></html>'
|
||||
const document = createLbPhoneFrameDocument(html, {
|
||||
appName: 'snake-game',
|
||||
localStorage: { theme: 'dark' },
|
||||
resourceName: 'snake_app',
|
||||
settings: createLbPhoneHostSettings({
|
||||
deviceName: '</script><script>window.injected=true</script>',
|
||||
@@ -114,18 +109,8 @@ describe('LB Phone app bridge', () => {
|
||||
)
|
||||
expect(document).toContain('globalThis.fetchNui = async')
|
||||
expect(document).toContain('globalThis.onNuiEvent = globalThis.useNuiEvent')
|
||||
expect(document).toContain('globalThis.createCall = globalThis.CreateCall')
|
||||
expect(document).toContain('globalThis.createSMS = globalThis.CreateSMS')
|
||||
expect(document).toContain('globalThis.invokeNative = () => undefined')
|
||||
expect(document).toContain(
|
||||
"Object.defineProperty(globalThis, 'localStorage'",
|
||||
)
|
||||
expect(document).toContain('"localStorage":{"theme":"dark"}')
|
||||
expect(document).toContain('https://cfx-nui-snake_app/ui/dist/')
|
||||
expect(document).not.toContain('</script><script>window.injected=true')
|
||||
expect(document.indexOf('globalThis.invokeNative')).toBeLessThan(
|
||||
document.indexOf('globalThis.previewMode'),
|
||||
)
|
||||
|
||||
const openingTag = '<script>'
|
||||
const runtimeStart = document.indexOf(openingTag)
|
||||
@@ -139,100 +124,4 @@ describe('LB Phone app bridge', () => {
|
||||
const runtime = document.slice(runtimeStart + openingTag.length, runtimeEnd)
|
||||
expect(() => new Function(runtime)).not.toThrow()
|
||||
})
|
||||
|
||||
it('applies the LB iframe layout contract before revealing vendor apps', () => {
|
||||
const document = createLbPhoneFrameDocument(
|
||||
'<!doctype html><html><head></head><body style="visibility:hidden"></body></html>',
|
||||
{
|
||||
appName: 'radio-app',
|
||||
localStorage: {},
|
||||
resourceName: 'lb-radioapp',
|
||||
settings: createLbPhoneHostSettings({
|
||||
deviceName: 'Main phone',
|
||||
isDarkMode: true,
|
||||
language: 'en',
|
||||
preferences: DEFAULT_PHONE_PREFERENCES,
|
||||
securityEnabled: false,
|
||||
}),
|
||||
ui: 'https://cfx-nui-lb-radioapp/ui/dist/index.html',
|
||||
},
|
||||
)
|
||||
const runtime = /<script>([\s\S]*?)<\/script>/i.exec(document)?.[1]
|
||||
expect(runtime).toBeTruthy()
|
||||
|
||||
const messageListeners: Array<(event: { data: unknown }) => void> = []
|
||||
const readyListeners: Array<() => void> = []
|
||||
const documentElement = { dataset: {}, style: {} }
|
||||
const body = {
|
||||
dataset: {},
|
||||
style: { visibility: 'hidden' },
|
||||
}
|
||||
const sandbox = {
|
||||
addEventListener(
|
||||
eventName: string,
|
||||
listener: (event: { data: unknown }) => void,
|
||||
) {
|
||||
if (eventName === 'message') messageListeners.push(listener)
|
||||
},
|
||||
componentsLoaded: undefined as boolean | undefined,
|
||||
console,
|
||||
document: {
|
||||
addEventListener(eventName: string, listener: () => void) {
|
||||
if (eventName === 'DOMContentLoaded') readyListeners.push(listener)
|
||||
},
|
||||
body,
|
||||
documentElement,
|
||||
},
|
||||
parent: { postMessage() {} },
|
||||
}
|
||||
|
||||
runInNewContext(runtime ?? '', sandbox)
|
||||
expect(body.style.visibility).toBe('hidden')
|
||||
|
||||
expect(readyListeners).toHaveLength(1)
|
||||
readyListeners[0]?.()
|
||||
expect(documentElement.style).toMatchObject({
|
||||
height: '100%',
|
||||
margin: '0',
|
||||
padding: '0',
|
||||
width: '100%',
|
||||
})
|
||||
expect(body.dataset).toMatchObject({ device: 'phone', theme: 'dark' })
|
||||
expect(body.style).toMatchObject({
|
||||
height: '100%',
|
||||
margin: '0',
|
||||
padding: '0',
|
||||
visibility: 'visible',
|
||||
width: '100%',
|
||||
})
|
||||
|
||||
body.style.visibility = 'hidden'
|
||||
expect(messageListeners).toHaveLength(1)
|
||||
messageListeners[0]?.({ data: 'componentsLoaded' })
|
||||
expect(body.style.visibility).toBe('visible')
|
||||
expect(sandbox.componentsLoaded).toBe(true)
|
||||
})
|
||||
|
||||
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,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,15 +4,6 @@ import type { PhonePreferencesV1 } from '@/utils/preferences'
|
||||
const LB_PHONE_PROVIDER = 'lb_phone'
|
||||
const RESOURCE_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/
|
||||
const MAX_FRAME_DOCUMENT_BYTES = 1_048_576
|
||||
const MAX_STORAGE_BYTES = 65_536
|
||||
const MAX_STORAGE_ENTRIES = 128
|
||||
const MAX_STORAGE_KEY_LENGTH = 512
|
||||
const STORAGE_KEY_PREFIX = 'sky_phone:lb-app-storage:v1:'
|
||||
|
||||
export const LB_PHONE_STORAGE_MESSAGE_TYPE = 'sky-phone:lb-storage'
|
||||
export const LB_PHONE_ACTION_MESSAGE_TYPE = 'sky-phone:lb-action'
|
||||
|
||||
export type LbPhoneStorageSnapshot = Record<string, string>
|
||||
|
||||
export type LbPhoneHostSettings = {
|
||||
airplaneMode: boolean
|
||||
@@ -50,7 +41,6 @@ export type LbPhoneHostSettings = {
|
||||
|
||||
type LbPhoneFrameDocumentOptions = {
|
||||
appName: string
|
||||
localStorage: LbPhoneStorageSnapshot
|
||||
resourceName: string
|
||||
settings: LbPhoneHostSettings
|
||||
ui: string
|
||||
@@ -70,59 +60,6 @@ const settingsListeners = new Set();
|
||||
const resourcePattern = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/;
|
||||
const eventPattern = /^[A-Za-z0-9][A-Za-z0-9:._/-]{0,127}$/;
|
||||
|
||||
function createStorage(initialValues, onChange) {
|
||||
const values = new Map(Object.entries(initialValues ?? {}));
|
||||
const snapshot = () => Object.fromEntries(values);
|
||||
const storage = {
|
||||
clear() {
|
||||
if (values.size === 0) return;
|
||||
values.clear();
|
||||
onChange(snapshot());
|
||||
},
|
||||
getItem(key) {
|
||||
const normalizedKey = String(key);
|
||||
return values.has(normalizedKey) ? values.get(normalizedKey) : null;
|
||||
},
|
||||
key(index) {
|
||||
const normalizedIndex = Number(index);
|
||||
if (!Number.isInteger(normalizedIndex) || normalizedIndex < 0) return null;
|
||||
return Array.from(values.keys())[normalizedIndex] ?? null;
|
||||
},
|
||||
removeItem(key) {
|
||||
if (!values.delete(String(key))) return;
|
||||
onChange(snapshot());
|
||||
},
|
||||
setItem(key, value) {
|
||||
values.set(String(key), String(value));
|
||||
onChange(snapshot());
|
||||
}
|
||||
};
|
||||
Object.defineProperty(storage, 'length', {
|
||||
enumerable: true,
|
||||
get: () => values.size
|
||||
});
|
||||
return storage;
|
||||
}
|
||||
|
||||
const localStorageBridge = createStorage(config.localStorage, (storage) => {
|
||||
globalThis.parent.postMessage({
|
||||
appId: config.appName,
|
||||
protocolVersion: 1,
|
||||
storage,
|
||||
type: '${LB_PHONE_STORAGE_MESSAGE_TYPE}'
|
||||
}, '*');
|
||||
});
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
value: localStorageBridge
|
||||
});
|
||||
Object.defineProperty(globalThis, 'sessionStorage', {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
value: createStorage({}, () => undefined)
|
||||
});
|
||||
|
||||
function applySettings(nextSettings) {
|
||||
globalThis.settings = nextSettings;
|
||||
const theme = nextSettings?.display?.theme === 'dark' ? 'dark' : 'light';
|
||||
@@ -130,44 +67,10 @@ function applySettings(nextSettings) {
|
||||
if (document.body) document.body.dataset.theme = theme;
|
||||
}
|
||||
|
||||
function prepareDocument() {
|
||||
Object.assign(document.documentElement.style, {
|
||||
height: '100%',
|
||||
margin: '0',
|
||||
padding: '0',
|
||||
width: '100%'
|
||||
});
|
||||
if (!document.body) return;
|
||||
|
||||
document.body.dataset.device = 'phone';
|
||||
Object.assign(document.body.style, {
|
||||
height: '100%',
|
||||
margin: '0',
|
||||
padding: '0',
|
||||
visibility: 'visible',
|
||||
width: '100%'
|
||||
});
|
||||
}
|
||||
|
||||
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');
|
||||
@@ -208,11 +111,6 @@ globalThis.getSettings = async () => globalThis.settings;
|
||||
|
||||
globalThis.addEventListener('message', (event) => {
|
||||
const message = event.data;
|
||||
if (message === 'componentsLoaded') {
|
||||
globalThis.componentsLoaded = true;
|
||||
prepareDocument();
|
||||
return;
|
||||
}
|
||||
if (!message || typeof message !== 'object') return;
|
||||
|
||||
if (message.type === 'sky-phone:lb-settings') {
|
||||
@@ -234,10 +132,7 @@ globalThis.addEventListener('message', (event) => {
|
||||
});
|
||||
|
||||
applySettings(config.settings);
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
prepareDocument();
|
||||
applySettings(globalThis.settings);
|
||||
}, { once: true });
|
||||
document.addEventListener('DOMContentLoaded', () => applySettings(globalThis.settings), { once: true });
|
||||
`
|
||||
|
||||
function escapeAttribute(value: string): string {
|
||||
@@ -255,67 +150,6 @@ function serializeForInlineScript(value: unknown): string {
|
||||
.replace(/\u2029/g, '\\u2029')
|
||||
}
|
||||
|
||||
function normalizeStorageSnapshot(
|
||||
value: unknown,
|
||||
): LbPhoneStorageSnapshot | null {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const entries = Object.entries(value)
|
||||
if (entries.length > MAX_STORAGE_ENTRIES) return null
|
||||
|
||||
const normalized: LbPhoneStorageSnapshot = {}
|
||||
for (const [key, item] of entries) {
|
||||
if (
|
||||
key.length > MAX_STORAGE_KEY_LENGTH ||
|
||||
typeof item !== 'string' ||
|
||||
key === '__proto__' ||
|
||||
key === 'constructor' ||
|
||||
key === 'prototype'
|
||||
) {
|
||||
return null
|
||||
}
|
||||
normalized[key] = item
|
||||
}
|
||||
|
||||
return new TextEncoder().encode(JSON.stringify(normalized)).byteLength <=
|
||||
MAX_STORAGE_BYTES
|
||||
? normalized
|
||||
: null
|
||||
}
|
||||
|
||||
export function getLbPhoneStorageKey(appName: string): string {
|
||||
if (!RESOURCE_NAME_PATTERN.test(appName)) {
|
||||
throw new Error('invalid_lb_phone_storage_app')
|
||||
}
|
||||
return `${STORAGE_KEY_PREFIX}${appName}`
|
||||
}
|
||||
|
||||
export function readLbPhoneStorage(
|
||||
storage: Pick<Storage, 'getItem'>,
|
||||
appName: string,
|
||||
): LbPhoneStorageSnapshot {
|
||||
const serialized = storage.getItem(getLbPhoneStorageKey(appName))
|
||||
if (serialized === null) return {}
|
||||
|
||||
const normalized = normalizeStorageSnapshot(JSON.parse(serialized))
|
||||
if (!normalized) throw new Error('invalid_lb_phone_storage')
|
||||
return normalized
|
||||
}
|
||||
|
||||
export function writeLbPhoneStorage(
|
||||
storage: Pick<Storage, 'setItem'>,
|
||||
appName: string,
|
||||
value: unknown,
|
||||
): boolean {
|
||||
const normalized = normalizeStorageSnapshot(value)
|
||||
if (!normalized) return false
|
||||
|
||||
storage.setItem(getLbPhoneStorageKey(appName), JSON.stringify(normalized))
|
||||
return true
|
||||
}
|
||||
|
||||
export function usesLbPhoneHostRuntime(
|
||||
app: ExternalPhoneAppDefinition,
|
||||
): boolean {
|
||||
@@ -394,7 +228,6 @@ export function createLbPhoneFrameDocument(
|
||||
const baseUrl = new URL('.', options.ui).href
|
||||
const config = serializeForInlineScript({
|
||||
appName: options.appName,
|
||||
localStorage: options.localStorage,
|
||||
resourceName: options.resourceName,
|
||||
settings: options.settings,
|
||||
})
|
||||
|
||||
@@ -158,13 +158,6 @@ describe('media utilities', () => {
|
||||
expect(mediaErrorKey('profile_photo_required')).toBe(
|
||||
'profile_photo_required',
|
||||
)
|
||||
expect(mediaErrorKey('media_provider_failed')).toBe('media_provider_failed')
|
||||
expect(mediaErrorKey('media_provider_rate_limited')).toBe(
|
||||
'media_provider_rate_limited',
|
||||
)
|
||||
expect(mediaErrorKey('media_provider_unauthorized')).toBe(
|
||||
'media_provider_unauthorized',
|
||||
)
|
||||
expect(mediaErrorKey('private_provider_error')).toBe('request_failed')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -95,9 +95,6 @@ export function mediaErrorKey(error?: string): string {
|
||||
'invalid_import_url',
|
||||
'invalid_upload',
|
||||
'invalid_upload_token',
|
||||
'media_provider_failed',
|
||||
'media_provider_rate_limited',
|
||||
'media_provider_unauthorized',
|
||||
'missing_config',
|
||||
'import_media_not_allowed',
|
||||
'import_media_too_large',
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
configurePhoneNumberFormat,
|
||||
formatPhoneNumber,
|
||||
normalizePhoneNumber,
|
||||
} from './phone'
|
||||
import { formatPhoneNumber, normalizePhoneNumber } from './phone'
|
||||
|
||||
describe('phone numbers', () => {
|
||||
it('normalizes formatted ten digit values', () => {
|
||||
@@ -17,13 +13,4 @@ describe('phone numbers', () => {
|
||||
expect(formatPhoneNumber('5551')).toBe('555 1')
|
||||
expect(formatPhoneNumber(5551234567)).toBe('555 123 4567')
|
||||
})
|
||||
|
||||
it('uses the server-provided number length and display groups', () => {
|
||||
configurePhoneNumberFormat({ groups: [4, 3, 3], length: 10 })
|
||||
|
||||
expect(formatPhoneNumber('0171234567')).toBe('0171 234 567')
|
||||
expect(normalizePhoneNumber('0171 234 567')).toBe('0171234567')
|
||||
|
||||
configurePhoneNumberFormat()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,53 +1,12 @@
|
||||
import type { PhoneNumberFormat } from '@/types/phone'
|
||||
|
||||
export const PHONE_NUMBER_LENGTH = 10
|
||||
const DEFAULT_PHONE_NUMBER_FORMAT: PhoneNumberFormat = {
|
||||
groups: [3, 3, 4],
|
||||
length: PHONE_NUMBER_LENGTH,
|
||||
}
|
||||
let phoneNumberFormat: PhoneNumberFormat = DEFAULT_PHONE_NUMBER_FORMAT
|
||||
|
||||
export function configurePhoneNumberFormat(value?: PhoneNumberFormat): void {
|
||||
const length = value?.length
|
||||
const groups = value?.groups
|
||||
if (
|
||||
typeof length !== 'number' ||
|
||||
!Number.isInteger(length) ||
|
||||
length < 1 ||
|
||||
length > 24 ||
|
||||
!Array.isArray(groups) ||
|
||||
groups.length === 0 ||
|
||||
groups.some((group) => !Number.isInteger(group) || group < 1)
|
||||
) {
|
||||
phoneNumberFormat = DEFAULT_PHONE_NUMBER_FORMAT
|
||||
return
|
||||
}
|
||||
|
||||
phoneNumberFormat = {
|
||||
groups: [...groups],
|
||||
length,
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizePhoneNumber(value: string): string | null {
|
||||
const digits = value.replace(/\D/g, '')
|
||||
return digits.length === phoneNumberFormat.length ? digits : null
|
||||
return digits.length === PHONE_NUMBER_LENGTH ? digits : null
|
||||
}
|
||||
|
||||
export function formatPhoneNumber(value: string | number): string {
|
||||
const digits = String(value)
|
||||
.replace(/\D/g, '')
|
||||
.slice(0, phoneNumberFormat.length)
|
||||
const formatted: string[] = []
|
||||
let offset = 0
|
||||
|
||||
for (const size of phoneNumberFormat.groups) {
|
||||
const group = digits.slice(offset, offset + size)
|
||||
if (!group) break
|
||||
formatted.push(group)
|
||||
offset += size
|
||||
}
|
||||
if (offset < digits.length) formatted.push(digits.slice(offset))
|
||||
|
||||
return formatted.join(' ')
|
||||
const digits = String(value).replace(/\D/g, '').slice(0, PHONE_NUMBER_LENGTH)
|
||||
const groups = [digits.slice(0, 3), digits.slice(3, 6), digits.slice(6, 10)]
|
||||
return groups.filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
type PhoneAudioVolumeListener = (volume: number) => void
|
||||
|
||||
const mediaElements = new Map<HTMLMediaElement, boolean>()
|
||||
const mediaLocalVolumes = new WeakMap<HTMLMediaElement, number>()
|
||||
const volumeListeners = new Set<PhoneAudioVolumeListener>()
|
||||
|
||||
let documentListenerReferences = 0
|
||||
let outputVolume = 1
|
||||
|
||||
function clampVolume(volume: number): number {
|
||||
return Math.max(0, Math.min(1, Number.isFinite(volume) ? volume : 0))
|
||||
}
|
||||
|
||||
function applyMediaVolume(element: HTMLMediaElement): void {
|
||||
const localVolume = mediaLocalVolumes.get(element) ?? element.volume
|
||||
const nextVolume = clampVolume(localVolume * outputVolume)
|
||||
if (Math.abs(element.volume - nextVolume) < 0.001) return
|
||||
element.volume = nextVolume
|
||||
}
|
||||
|
||||
function onMediaVolumeChange(event: Event): void {
|
||||
const element = event.currentTarget as HTMLMediaElement
|
||||
const currentLocalVolume = mediaLocalVolumes.get(element) ?? element.volume
|
||||
const expectedVolume = clampVolume(currentLocalVolume * outputVolume)
|
||||
if (Math.abs(element.volume - expectedVolume) < 0.001) return
|
||||
mediaLocalVolumes.set(element, clampVolume(element.volume))
|
||||
applyMediaVolume(element)
|
||||
}
|
||||
|
||||
function onMediaPlay(event: Event): void {
|
||||
if (event.target instanceof HTMLMediaElement) {
|
||||
trackPhoneMediaElement(event.target, false)
|
||||
}
|
||||
}
|
||||
|
||||
function trackPhoneMediaElement<T extends HTMLMediaElement>(
|
||||
element: T,
|
||||
persistent: boolean,
|
||||
): T {
|
||||
if (mediaElements.has(element)) {
|
||||
if (persistent) mediaElements.set(element, true)
|
||||
return element
|
||||
}
|
||||
mediaElements.set(element, persistent)
|
||||
mediaLocalVolumes.set(element, clampVolume(element.volume))
|
||||
element.addEventListener('volumechange', onMediaVolumeChange)
|
||||
applyMediaVolume(element)
|
||||
return element
|
||||
}
|
||||
|
||||
export function getPhoneOutputVolume(): number {
|
||||
return outputVolume
|
||||
}
|
||||
|
||||
export function installPhoneAudioController(): () => void {
|
||||
documentListenerReferences += 1
|
||||
if (documentListenerReferences === 1) {
|
||||
document.addEventListener('play', onMediaPlay, true)
|
||||
document
|
||||
.querySelectorAll<HTMLMediaElement>('audio, video')
|
||||
.forEach((element) => trackPhoneMediaElement(element, false))
|
||||
}
|
||||
|
||||
return () => {
|
||||
documentListenerReferences = Math.max(0, documentListenerReferences - 1)
|
||||
if (documentListenerReferences === 0) {
|
||||
document.removeEventListener('play', onMediaPlay, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function registerPhoneMediaElement<T extends HTMLMediaElement>(
|
||||
element: T,
|
||||
): T {
|
||||
return trackPhoneMediaElement(element, true)
|
||||
}
|
||||
|
||||
export function setPhoneOutputVolume(volume: number): void {
|
||||
outputVolume = clampVolume(volume)
|
||||
for (const [element, persistent] of mediaElements) {
|
||||
if (!persistent && !element.isConnected && element.paused) {
|
||||
element.removeEventListener('volumechange', onMediaVolumeChange)
|
||||
mediaElements.delete(element)
|
||||
continue
|
||||
}
|
||||
applyMediaVolume(element)
|
||||
}
|
||||
for (const listener of volumeListeners) listener(outputVolume)
|
||||
}
|
||||
|
||||
export function subscribePhoneOutputVolume(
|
||||
listener: PhoneAudioVolumeListener,
|
||||
): () => void {
|
||||
volumeListeners.add(listener)
|
||||
return () => volumeListeners.delete(listener)
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
normalizePhoneViewportRect,
|
||||
readPhoneViewportGeometry,
|
||||
type PhoneViewportRect,
|
||||
} from '@/utils/phoneViewportGeometry'
|
||||
|
||||
type RectMeasurement = Pick<
|
||||
DOMRectReadOnly,
|
||||
'height' | 'left' | 'top' | 'width'
|
||||
>
|
||||
|
||||
function measuredRect(
|
||||
left: number,
|
||||
top: number,
|
||||
width: number,
|
||||
height: number,
|
||||
): RectMeasurement {
|
||||
return { height, left, top, width }
|
||||
}
|
||||
|
||||
function expectRectClose(
|
||||
actual: PhoneViewportRect,
|
||||
expected: PhoneViewportRect,
|
||||
): void {
|
||||
for (const key of [
|
||||
'bottom',
|
||||
'height',
|
||||
'left',
|
||||
'right',
|
||||
'top',
|
||||
'width',
|
||||
] as const) {
|
||||
expect(actual[key]).toBeCloseTo(expected[key], 6)
|
||||
}
|
||||
}
|
||||
|
||||
function createGeometryFixture(options: {
|
||||
canvasOffsetHeight: number
|
||||
canvasOffsetWidth: number
|
||||
rawCanvasRect: RectMeasurement
|
||||
wrapperRect: RectMeasurement
|
||||
}): { anchor: Element; element: (rect: RectMeasurement) => Element } {
|
||||
const wrapper = {
|
||||
getBoundingClientRect: () => options.wrapperRect,
|
||||
} as unknown as HTMLElement
|
||||
const canvas = {
|
||||
closest: (selector: string) =>
|
||||
selector === '.phone-resolution-wrapper' ? wrapper : null,
|
||||
getBoundingClientRect: () => options.rawCanvasRect,
|
||||
offsetHeight: options.canvasOffsetHeight,
|
||||
offsetWidth: options.canvasOffsetWidth,
|
||||
} as unknown as HTMLElement
|
||||
|
||||
return {
|
||||
anchor: {
|
||||
closest: (selector: string) =>
|
||||
selector === '.phone-resolution-canvas' ? canvas : null,
|
||||
} as unknown as Element,
|
||||
element: (rect) =>
|
||||
({ getBoundingClientRect: () => rect }) as unknown as Element,
|
||||
}
|
||||
}
|
||||
|
||||
describe('phone viewport geometry', () => {
|
||||
it('leaves modern Chrome measurements unchanged when the canvas BCR is already rendered', () => {
|
||||
const wrapper = measuredRect(1573.09, 126.25, 322.92, 698.832)
|
||||
const layer = measuredRect(1589.783336, 177.75, 289.533328, 603.2)
|
||||
|
||||
expectRectClose(normalizePhoneViewportRect(layer, wrapper, wrapper), {
|
||||
bottom: layer.top + layer.height,
|
||||
height: layer.height,
|
||||
left: layer.left,
|
||||
right: layer.left + layer.width,
|
||||
top: layer.top,
|
||||
width: layer.width,
|
||||
})
|
||||
})
|
||||
|
||||
it('calibrates live CEF 103 BCRs back inside the visible wrapper', () => {
|
||||
const wrapper = measuredRect(1573.09, 126.25, 322.92, 698.832)
|
||||
const rawCanvas = measuredRect(1899.87, 152.5, 389.98, 844)
|
||||
const rawLayer = measuredRect(1920.03, 214.25, 349.66, 728.5)
|
||||
const corrected = normalizePhoneViewportRect(rawLayer, rawCanvas, wrapper)
|
||||
|
||||
expect(corrected.left).toBeCloseTo(1589.7833360685163, 6)
|
||||
expect(corrected.width).toBeCloseTo(289.5333278629674, 6)
|
||||
expect(corrected.right).toBeCloseTo(1879.3166639314836, 6)
|
||||
expect(corrected.left).toBeGreaterThan(wrapper.left)
|
||||
expect(corrected.right).toBeLessThan(wrapper.left + wrapper.width)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['80%', 0.6624],
|
||||
['100%', 0.828],
|
||||
['120%', 0.9936],
|
||||
])(
|
||||
'normalizes fractional positions, sizes, and deltas at %s scaling',
|
||||
(_label, zoom) => {
|
||||
const rawCanvas = measuredRect(1900.125, 212.75, 390, 844)
|
||||
const wrapper = measuredRect(1530.5, 250.25, 390 * zoom, 844 * zoom)
|
||||
const rawStart = measuredRect(
|
||||
rawCanvas.left + 20.125,
|
||||
rawCanvas.top + 50.375,
|
||||
349.75,
|
||||
73.125,
|
||||
)
|
||||
const rawEnd = measuredRect(
|
||||
rawStart.left + 73.25,
|
||||
rawStart.top - 41.75,
|
||||
rawStart.width,
|
||||
rawStart.height,
|
||||
)
|
||||
const start = normalizePhoneViewportRect(rawStart, rawCanvas, wrapper)
|
||||
const end = normalizePhoneViewportRect(rawEnd, rawCanvas, wrapper)
|
||||
|
||||
expect(start.left).toBeCloseTo(wrapper.left + 20.125 * zoom, 6)
|
||||
expect(start.top).toBeCloseTo(wrapper.top + 50.375 * zoom, 6)
|
||||
expect(start.width).toBeCloseTo(349.75 * zoom, 6)
|
||||
expect(start.height).toBeCloseTo(73.125 * zoom, 6)
|
||||
expect(end.left - start.left).toBeCloseTo(73.25 * zoom, 6)
|
||||
expect(end.top - start.top).toBeCloseTo(-41.75 * zoom, 6)
|
||||
},
|
||||
)
|
||||
|
||||
it('reads visual scale and normalized element rects from a canvas anchor', () => {
|
||||
const fixture = createGeometryFixture({
|
||||
canvasOffsetHeight: 844,
|
||||
canvasOffsetWidth: 390,
|
||||
rawCanvasRect: measuredRect(1899.87, 152.5, 389.98, 844),
|
||||
wrapperRect: measuredRect(1573.09, 126.25, 322.92, 698.832),
|
||||
})
|
||||
const geometry = readPhoneViewportGeometry(fixture.anchor)
|
||||
const layer = fixture.element(measuredRect(1920.03, 214.25, 349.66, 728.5))
|
||||
|
||||
expect(geometry).not.toBeNull()
|
||||
expect(geometry?.scaleX).toBeCloseTo(0.828, 6)
|
||||
expect(geometry?.scaleY).toBeCloseTo(0.828, 6)
|
||||
expect(geometry?.rect(layer).left).toBeCloseTo(1589.7833360685163, 6)
|
||||
})
|
||||
|
||||
it('uses finite identity fallbacks for zero geometry and missing anchors', () => {
|
||||
const rawCanvas = measuredRect(100, 50, 0, 0)
|
||||
const corrected = normalizePhoneViewportRect(
|
||||
measuredRect(112.5, 58.25, 40, 20),
|
||||
rawCanvas,
|
||||
measuredRect(500, 300, 0, 0),
|
||||
)
|
||||
const fixture = createGeometryFixture({
|
||||
canvasOffsetHeight: 0,
|
||||
canvasOffsetWidth: 0,
|
||||
rawCanvasRect: rawCanvas,
|
||||
wrapperRect: measuredRect(500, 300, 0, 0),
|
||||
})
|
||||
const geometry = readPhoneViewportGeometry(fixture.anchor)
|
||||
|
||||
expect(corrected).toEqual({
|
||||
bottom: 328.25,
|
||||
height: 20,
|
||||
left: 512.5,
|
||||
right: 552.5,
|
||||
top: 308.25,
|
||||
width: 40,
|
||||
})
|
||||
expect(Object.values(corrected).every(Number.isFinite)).toBe(true)
|
||||
expect(geometry?.scaleX).toBe(1)
|
||||
expect(geometry?.scaleY).toBe(1)
|
||||
expect(readPhoneViewportGeometry(null)).toBeNull()
|
||||
expect(
|
||||
readPhoneViewportGeometry({ closest: () => null } as unknown as Element),
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,73 +0,0 @@
|
||||
export type PhoneViewportRect = {
|
||||
bottom: number
|
||||
height: number
|
||||
left: number
|
||||
right: number
|
||||
top: number
|
||||
width: number
|
||||
}
|
||||
|
||||
type RectMeasurement = Pick<
|
||||
DOMRectReadOnly,
|
||||
'height' | 'left' | 'top' | 'width'
|
||||
>
|
||||
|
||||
export type PhoneViewportGeometry = {
|
||||
readonly scaleX: number
|
||||
readonly scaleY: number
|
||||
rect(element: Element): PhoneViewportRect
|
||||
}
|
||||
|
||||
function positiveRatio(numerator: number, denominator: number): number {
|
||||
return Number.isFinite(numerator) &&
|
||||
Number.isFinite(denominator) &&
|
||||
numerator > 0 &&
|
||||
denominator > 0
|
||||
? numerator / denominator
|
||||
: 1
|
||||
}
|
||||
|
||||
export function normalizePhoneViewportRect(
|
||||
rawRect: RectMeasurement,
|
||||
rawCanvasRect: RectMeasurement,
|
||||
wrapperRect: RectMeasurement,
|
||||
): PhoneViewportRect {
|
||||
const factorX = positiveRatio(wrapperRect.width, rawCanvasRect.width)
|
||||
const factorY = positiveRatio(wrapperRect.height, rawCanvasRect.height)
|
||||
const left = wrapperRect.left + (rawRect.left - rawCanvasRect.left) * factorX
|
||||
const top = wrapperRect.top + (rawRect.top - rawCanvasRect.top) * factorY
|
||||
const width = rawRect.width * factorX
|
||||
const height = rawRect.height * factorY
|
||||
|
||||
return {
|
||||
bottom: top + height,
|
||||
height,
|
||||
left,
|
||||
right: left + width,
|
||||
top,
|
||||
width,
|
||||
}
|
||||
}
|
||||
|
||||
export function readPhoneViewportGeometry(
|
||||
anchor: Element | null,
|
||||
): PhoneViewportGeometry | null {
|
||||
const canvas = anchor?.closest<HTMLElement>('.phone-resolution-canvas')
|
||||
const wrapper = canvas?.closest<HTMLElement>('.phone-resolution-wrapper')
|
||||
if (!canvas || !wrapper) return null
|
||||
|
||||
const rawCanvasRect = canvas.getBoundingClientRect()
|
||||
const wrapperRect = wrapper.getBoundingClientRect()
|
||||
|
||||
return {
|
||||
scaleX: positiveRatio(wrapperRect.width, canvas.offsetWidth),
|
||||
scaleY: positiveRatio(wrapperRect.height, canvas.offsetHeight),
|
||||
rect(element: Element): PhoneViewportRect {
|
||||
return normalizePhoneViewportRect(
|
||||
element.getBoundingClientRect(),
|
||||
rawCanvasRect,
|
||||
wrapperRect,
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -2,16 +2,11 @@ import { describe, expect, it } from 'vitest'
|
||||
import type { LaunchablePhoneAppId } from '@/types/apps'
|
||||
import {
|
||||
DEFAULT_PHONE_PREFERENCES,
|
||||
PHONE_SCALE_STEP,
|
||||
PHONE_SETUP_LAST_STEP,
|
||||
parsePhonePreferences,
|
||||
WALLPAPER_IDS,
|
||||
} from './preferences'
|
||||
describe('preferences', () => {
|
||||
it('allows one-percent phone scale adjustments', () => {
|
||||
expect(PHONE_SCALE_STEP).toBe(1)
|
||||
})
|
||||
|
||||
it('starts Setup Assistant for a phone without saved settings', () => {
|
||||
const value = parsePhonePreferences(null)
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ export const WALLPAPER_IDS = [
|
||||
] as const
|
||||
export const PHONE_SCALE_MIN = 75
|
||||
export const PHONE_SCALE_MAX = 150
|
||||
export const PHONE_SCALE_STEP = 1
|
||||
export const PHONE_SCALE_STEP = 5
|
||||
export const PHONE_SETUP_LAST_STEP = 9
|
||||
|
||||
export type AppearanceMode = (typeof APPEARANCE_MODE_IDS)[number]
|
||||
|
||||
@@ -140,56 +140,4 @@ describe('springboard widget drag', () => {
|
||||
expect(delta.x).toBeCloseTo(100)
|
||||
expect(delta.y).toBeCloseTo(200)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['80% preview', 0.6624],
|
||||
['80% production clamp', 2 / 3],
|
||||
['100%', 0.828],
|
||||
['120%', 0.9936],
|
||||
])('keeps the rendered drag delta 1:1 at %s zoom', (_label, zoom) => {
|
||||
const layoutWidth = 350
|
||||
const layoutHeight = 808
|
||||
const viewportLeft = 128.25
|
||||
const viewportTop = 64.5
|
||||
const viewportWidth = layoutWidth * zoom
|
||||
const viewportHeight = layoutHeight * zoom
|
||||
const pointerStart = {
|
||||
x: viewportLeft + 48.5 * zoom,
|
||||
y: viewportTop + 132.25 * zoom,
|
||||
}
|
||||
const viewportDelta = { x: 73.25, y: -41.75 }
|
||||
const start = springboardViewportToLocal(
|
||||
pointerStart.x,
|
||||
pointerStart.y,
|
||||
viewportLeft,
|
||||
viewportTop,
|
||||
viewportWidth,
|
||||
viewportHeight,
|
||||
layoutWidth,
|
||||
layoutHeight,
|
||||
)
|
||||
const end = springboardViewportToLocal(
|
||||
pointerStart.x + viewportDelta.x,
|
||||
pointerStart.y + viewportDelta.y,
|
||||
viewportLeft,
|
||||
viewportTop,
|
||||
viewportWidth,
|
||||
viewportHeight,
|
||||
layoutWidth,
|
||||
layoutHeight,
|
||||
)
|
||||
const localDelta = springboardViewportDeltaToLocal(
|
||||
viewportDelta.x,
|
||||
viewportDelta.y,
|
||||
viewportWidth,
|
||||
viewportHeight,
|
||||
layoutWidth,
|
||||
layoutHeight,
|
||||
)
|
||||
|
||||
expect((end.x - start.x) * zoom).toBeCloseTo(viewportDelta.x, 6)
|
||||
expect((end.y - start.y) * zoom).toBeCloseTo(viewportDelta.y, 6)
|
||||
expect(localDelta.x * zoom).toBeCloseTo(viewportDelta.x, 6)
|
||||
expect(localDelta.y * zoom).toBeCloseTo(viewportDelta.y, 6)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { readPhoneViewportGeometry } from '@/utils/phoneViewportGeometry'
|
||||
|
||||
export type PageTurnDirection = -1 | 0 | 1
|
||||
|
||||
export type SpringboardEdgeTurn = {
|
||||
@@ -41,9 +39,7 @@ export function readSpringboardDragMetrics(
|
||||
'.springboard-page, .home-folder-panel',
|
||||
)
|
||||
if (!surface) return null
|
||||
const bounds =
|
||||
readPhoneViewportGeometry(surface)?.rect(surface) ??
|
||||
surface.getBoundingClientRect()
|
||||
const bounds = surface.getBoundingClientRect()
|
||||
return {
|
||||
layoutHeight: surface.offsetHeight,
|
||||
layoutWidth: surface.offsetWidth,
|
||||
|
||||
@@ -32,7 +32,7 @@ describe('phone tones', () => {
|
||||
}> = []
|
||||
vi.stubGlobal(
|
||||
'Audio',
|
||||
class extends EventTarget {
|
||||
class {
|
||||
currentTime = 7
|
||||
loop = false
|
||||
pause = pause
|
||||
@@ -42,7 +42,6 @@ describe('phone tones', () => {
|
||||
volume = 0
|
||||
|
||||
constructor(src: string) {
|
||||
super()
|
||||
this.src = src
|
||||
players.push(this)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { registerPhoneMediaElement } from '@/utils/phoneAudio'
|
||||
import type { AlarmSoundId } from '@/utils/alarms'
|
||||
import type { NotificationSoundId } from '@/utils/preferences'
|
||||
|
||||
@@ -496,8 +495,8 @@ export function playPhoneVibration(
|
||||
kind: PhoneVibrationKind,
|
||||
loop: boolean,
|
||||
): () => void {
|
||||
const player = registerPhoneMediaElement(
|
||||
new Audio(`${import.meta.env.BASE_URL}${VIBRATION_SOUND_PATHS[kind]}`),
|
||||
const player = new Audio(
|
||||
`${import.meta.env.BASE_URL}${VIBRATION_SOUND_PATHS[kind]}`,
|
||||
)
|
||||
let stopped = false
|
||||
player.loop = loop
|
||||
|
||||
@@ -28,7 +28,7 @@ const launchStyle = computed(() => {
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="app && !app.adminOnly"
|
||||
v-if="app"
|
||||
class="app-window"
|
||||
:class="{ 'app-window--citywarn': app.id === 'citywarn' }"
|
||||
:style="launchStyle"
|
||||
|
||||
@@ -19,31 +19,11 @@ const mainCss = readFileSync(
|
||||
new URL('../assets/main.css', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const tokensCss = readFileSync(
|
||||
new URL('../ui/tokens.css', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const foundationCss = readFileSync(
|
||||
new URL('../ui/foundation.css', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const builtInWallpaperCss = mainCss.slice(
|
||||
mainCss.indexOf('.wallpaper--midnight'),
|
||||
mainCss.indexOf('.wallpaper--custom'),
|
||||
)
|
||||
|
||||
describe('Springboard page swipe contract', () => {
|
||||
it('keeps app and widget labels on the larger shared home typography', () => {
|
||||
expect(tokensCss).toContain('--sky-home-label-font-size: 13px;')
|
||||
expect(tokensCss).toContain('--sky-home-label-height: 16px;')
|
||||
expect(mainCss).toMatch(
|
||||
/\.app-icon-label\s*\{[\s\S]*?font-size:\s*var\(--sky-home-label-font-size\);/,
|
||||
)
|
||||
expect(foundationCss).toMatch(
|
||||
/\.sky-widget-frame__label\s*\{[\s\S]*?font-size:\s*var\(--sky-home-label-font-size\);/,
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the built-in wallpapers visually restrained', () => {
|
||||
expect(builtInWallpaperCss).not.toMatch(/(?:conic|repeating-\w+)-gradient/)
|
||||
expect(builtInWallpaperCss.match(/radial-gradient/g)).toHaveLength(12)
|
||||
@@ -114,99 +94,36 @@ describe('Springboard page swipe contract', () => {
|
||||
expect(folderIconSource).toContain(':style="dragPointerStyle"')
|
||||
})
|
||||
|
||||
it('renders the home drag visual through the unzoomed phone portal', () => {
|
||||
expect(viewSource).toContain('source.cloneNode(true)')
|
||||
it('uses the same scale-aware in-phone drag path as widgets', () => {
|
||||
expect(viewSource).not.toContain('source.cloneNode(true)')
|
||||
expect(viewSource).not.toContain('<Teleport to="body">')
|
||||
expect(viewSource).not.toContain(':external-drag-visual')
|
||||
expect(viewSource).toContain(
|
||||
'<Teleport defer to="#phone-home-drag-portal">',
|
||||
"'springboard--home-dragging': draggingHomeApp !== null",
|
||||
)
|
||||
expect(viewSource).toContain(
|
||||
'<div ref="homeDragLayer" class="home-drag-layer" aria-hidden="true"></div>',
|
||||
)
|
||||
expect(viewSource).toContain('readPhoneViewportGeometry')
|
||||
expect(viewSource).toMatch(/const sourceBounds = \w+\.rect\(source\)/)
|
||||
expect(viewSource).toMatch(/const clipBounds = \w+\.rect\(\w+\)/)
|
||||
expect(viewSource).not.toContain('homeDragLocalPoint')
|
||||
expect(viewSource).not.toContain('springboardViewportToLocal')
|
||||
expect(viewSource).toContain("position.className = 'home-drag-position'")
|
||||
expect(viewSource).toMatch(
|
||||
/homeDragGrip = \{\s*x: event\.clientX - sourceBounds\.left,\s*y: event\.clientY - sourceBounds\.top,/,
|
||||
)
|
||||
expect(viewSource).toMatch(
|
||||
/ghost\.style\.transform = `scale\(\$\{\w+\.scaleX\}, \$\{\w+\.scaleY\}\)`/,
|
||||
)
|
||||
expect(
|
||||
viewSource.match(/:external-drag-visual="homeDragVisualActive"/g),
|
||||
).toHaveLength(4)
|
||||
expect(viewSource).toContain('updateHomeDragGhost(event)')
|
||||
expect(viewSource).toContain('const dropGhost = homeDragGhost')
|
||||
expect(viewSource).toContain('const dropOrigin = homeDragPreviewBounds')
|
||||
expect(viewSource).not.toContain('dropGhost?.getBoundingClientRect()')
|
||||
expect(viewSource).toContain('clearHomeDragGhost(dropGhost)')
|
||||
expect(viewSource).not.toContain('draggedElement?.getBoundingClientRect()')
|
||||
expect(viewSource).not.toContain('springboard--home-dragging')
|
||||
expect(appIconSource).toContain('externalDragVisual?: boolean')
|
||||
expect(folderIconSource).toContain('externalDragVisual?: boolean')
|
||||
expect(appIconSource).toContain('app-icon-item--drag-source')
|
||||
expect(folderIconSource).toContain('app-icon-item--drag-source')
|
||||
expect(viewSource).toContain('draggedElement?.getBoundingClientRect()')
|
||||
expect(appIconSource).not.toContain('externalDragVisual')
|
||||
expect(folderIconSource).not.toContain('externalDragVisual')
|
||||
expect(mainCss).not.toContain('.home-drag-layer')
|
||||
expect(mainCss).not.toContain('.home-drag-ghost')
|
||||
expect(mainCss).not.toContain('.app-icon-item--drag-source')
|
||||
expect(mainCss).toMatch(
|
||||
/\.phone-home-drag-portal\s*\{[^}]*pointer-events:\s*none;/s,
|
||||
/\.springboard--home-dragging \.springboard-page--apps\s*\{[^}]*overflow:\s*visible;/s,
|
||||
)
|
||||
expect(mainCss).toContain('.home-drag-position')
|
||||
expect(mainCss).toContain('.home-drag-ghost')
|
||||
expect(mainCss).toContain('.app-icon-item--drag-source')
|
||||
expect(mainCss).toMatch(/\.springboard\s*\{[\s\S]*?overflow:\s*hidden;/)
|
||||
expect(mainCss).toMatch(
|
||||
/\.springboard-page--apps\s*\{[^}]*overflow:\s*hidden;/s,
|
||||
)
|
||||
})
|
||||
|
||||
it('cleans up the external drag visual on every terminal path', () => {
|
||||
expect(
|
||||
viewSource.match(
|
||||
/@dragstart="startHomeDrag\('(grid|dock)', [^,]+, \$event\)"/g,
|
||||
),
|
||||
).toHaveLength(4)
|
||||
expect(viewSource.match(/@dragcancel="stopHomeDrag"/g)).toHaveLength(4)
|
||||
expect(viewSource).toContain(
|
||||
'if (expectedGhost && homeDragGhost !== expectedGhost) return',
|
||||
)
|
||||
expect(viewSource).toMatch(
|
||||
/if \(!dragged\) \{\s*clearHomeDragGhost\(\)\s*return/,
|
||||
)
|
||||
expect(viewSource).toMatch(
|
||||
/animateHomeItemDrop\(\s*draggedItem,\s*dropArea,\s*dropIndex,\s*dropOrigin,?\s*\)\.finally\(/,
|
||||
)
|
||||
expect(viewSource).toMatch(
|
||||
/function stopHomeDrag[\s\S]*?clearHomeDragGhost\(\)/,
|
||||
)
|
||||
expect(viewSource).toMatch(
|
||||
/if \(folderId\) \{[\s\S]*?clearHomeDragGhost\(\)/,
|
||||
)
|
||||
expect(viewSource).toMatch(
|
||||
/onBeforeUnmount\(\(\) => \{[\s\S]*?clearHomeDragGhost\(\)/,
|
||||
)
|
||||
})
|
||||
|
||||
it('targets the active visual page and lets grid or dock drags turn pages', () => {
|
||||
expect(viewSource).toContain(':data-home-page="page.page"')
|
||||
expect(viewSource).toContain(':data-home-target-offset="cell.targetOffset"')
|
||||
expect(viewSource).toContain('nearestGridDropTarget')
|
||||
expect(viewSource).toContain('moveHomeAppToGridPage')
|
||||
expect(viewSource).not.toContain("draggingHomeApp.value?.area === 'grid'")
|
||||
expect(viewSource).toContain('event.clientX < springboardBounds.left')
|
||||
expect(viewSource).toContain('event.clientY > springboardBounds.bottom')
|
||||
expect(viewSource).toContain('function homeDragViewportRect(')
|
||||
expect(viewSource).toContain('if (geometry) return geometry.rect(element)')
|
||||
expect(viewSource).toContain('homeDragViewportRect(pageElement, geometry)')
|
||||
expect(viewSource).toContain('homeDragViewportRect(springboard, geometry)')
|
||||
expect(viewSource).toContain('homeDragViewportRect(slot, geometry)')
|
||||
expect(viewSource).toContain('homeDragViewportRect(dock, geometry)')
|
||||
expect(viewSource).toMatch(
|
||||
/homeDragViewportRect\(\s*appElement,\s*readPhoneViewportGeometry\(appElement\),?\s*\)/,
|
||||
)
|
||||
expect(viewSource).toContain(
|
||||
'springboardBounds.left + (bounds.left - pageBounds.left)',
|
||||
)
|
||||
expect(viewSource).toContain('event.clientX < pageBounds.left')
|
||||
expect(viewSource).toContain('event.clientY > pageBounds.bottom')
|
||||
expect(viewSource).toContain('nearestSpringboardRectIndex')
|
||||
expect(viewSource).toContain("queueEdgePageTurn(lastHomePointer, 'app')")
|
||||
expect(viewSource).toContain("edgePageLocked = dragType === 'widget'")
|
||||
|
||||
@@ -41,11 +41,6 @@ import {
|
||||
type HomeItem,
|
||||
} from '@/utils/homeLayout'
|
||||
import type { ReorderDirection } from '@/utils/keyboard'
|
||||
import {
|
||||
readPhoneViewportGeometry,
|
||||
type PhoneViewportGeometry,
|
||||
type PhoneViewportRect,
|
||||
} from '@/utils/phoneViewportGeometry'
|
||||
import { layoutSpringboardHomePages } from '@/utils/springboardLayout'
|
||||
import {
|
||||
maximumRenderedWidgetPage,
|
||||
@@ -111,8 +106,6 @@ const draggingHomeApp = ref<{
|
||||
area: HomeArea
|
||||
index: number
|
||||
} | null>(null)
|
||||
const homeDragLayer = ref<HTMLElement | null>(null)
|
||||
const homeDragVisualActive = ref(false)
|
||||
const temporaryHomePage = ref<number | null>(null)
|
||||
const openedFolderId = ref<string | null>(null)
|
||||
const folderDraggingOutside = ref(false)
|
||||
@@ -132,11 +125,6 @@ let edgePageDirection = 0
|
||||
let edgePageLocked = false
|
||||
let folderHoverTimer: number | undefined
|
||||
let lastHomePointer: { clientX: number; clientY: number } | null = null
|
||||
let homeDragGhost: HTMLElement | null = null
|
||||
let homeDragGrip: { x: number; y: number } | null = null
|
||||
let homeDragGeometry: PhoneViewportGeometry | null = null
|
||||
let homeDragClipBounds: PhoneViewportRect | null = null
|
||||
let homeDragPreviewBounds: PhoneViewportRect | null = null
|
||||
|
||||
const installedApps = computed(() =>
|
||||
PHONE_APPS.filter((app) => appStore.isInstalled(app.id)),
|
||||
@@ -465,7 +453,7 @@ function startWidgetDrag(id: string, event: PointerEvent): void {
|
||||
).find((candidate) => candidate.dataset.widgetId === id)
|
||||
if (!widget) return
|
||||
pageTransitioning.value = false
|
||||
const bounds = homeDragViewportRect(widget)
|
||||
const bounds = widget.getBoundingClientRect()
|
||||
draggingWidgetId.value = id
|
||||
widgetDragGrip.value = {
|
||||
x: event.clientX - bounds.left,
|
||||
@@ -486,7 +474,7 @@ function updateWidgetDragPreview(event: {
|
||||
)
|
||||
const pageElement = grid?.closest<HTMLElement>('.springboard-page')
|
||||
if (!grid || !pageElement) return
|
||||
const renderedGridBounds = homeDragViewportRect(grid)
|
||||
const renderedGridBounds = grid.getBoundingClientRect()
|
||||
const gridStyle = getComputedStyle(grid)
|
||||
const scaleX =
|
||||
grid.offsetWidth > 0 ? renderedGridBounds.width / grid.offsetWidth : 1
|
||||
@@ -541,7 +529,7 @@ function clearTemporaryHomePage(keepCurrentPage = false): void {
|
||||
function resolveHomeEdgeTurn(event: { clientX: number }) {
|
||||
const springboard = document.querySelector<HTMLElement>('.springboard')
|
||||
if (!springboard) return null
|
||||
const bounds = homeDragViewportRect(springboard)
|
||||
const bounds = springboard.getBoundingClientRect()
|
||||
const renderedLastPage = appPages.value.length
|
||||
return resolveSpringboardHomeEdgeTurn(
|
||||
event.clientX,
|
||||
@@ -560,7 +548,7 @@ function queueEdgePageTurn(
|
||||
): void {
|
||||
const springboard = document.querySelector<HTMLElement>('.springboard')
|
||||
if (!springboard) return
|
||||
const bounds = homeDragViewportRect(springboard)
|
||||
const bounds = springboard.getBoundingClientRect()
|
||||
const turn =
|
||||
dragType === 'app'
|
||||
? resolveHomeEdgeTurn(event)
|
||||
@@ -745,166 +733,10 @@ async function saveWidgetConfig(
|
||||
if (configured) changePage(configured.page)
|
||||
}
|
||||
|
||||
function homeDragViewportRect(
|
||||
element: Element,
|
||||
geometry = homeDragGeometry ?? readPhoneViewportGeometry(element),
|
||||
): PhoneViewportRect {
|
||||
if (geometry) return geometry.rect(element)
|
||||
const bounds = element.getBoundingClientRect()
|
||||
return {
|
||||
bottom: bounds.bottom,
|
||||
height: bounds.height,
|
||||
left: bounds.left,
|
||||
right: bounds.right,
|
||||
top: bounds.top,
|
||||
width: bounds.width,
|
||||
}
|
||||
}
|
||||
|
||||
function viewportRectAt(
|
||||
left: number,
|
||||
top: number,
|
||||
width: number,
|
||||
height: number,
|
||||
): PhoneViewportRect {
|
||||
return {
|
||||
bottom: top + height,
|
||||
height,
|
||||
left,
|
||||
right: left + width,
|
||||
top,
|
||||
width,
|
||||
}
|
||||
}
|
||||
|
||||
function clearHomeDragGhost(expectedGhost?: HTMLElement | null): void {
|
||||
if (expectedGhost && homeDragGhost !== expectedGhost) return
|
||||
homeDragGhost?.remove()
|
||||
homeDragGhost = null
|
||||
homeDragGrip = null
|
||||
homeDragGeometry = null
|
||||
homeDragClipBounds = null
|
||||
homeDragPreviewBounds = null
|
||||
homeDragVisualActive.value = false
|
||||
const layer = homeDragLayer.value
|
||||
layer?.style.removeProperty('left')
|
||||
layer?.style.removeProperty('top')
|
||||
layer?.style.removeProperty('width')
|
||||
layer?.style.removeProperty('height')
|
||||
}
|
||||
|
||||
function updateHomeDragGhost(event: {
|
||||
clientX: number
|
||||
clientY: number
|
||||
}): void {
|
||||
const ghost = homeDragGhost
|
||||
const grip = homeDragGrip
|
||||
const clipBounds = homeDragClipBounds
|
||||
const previewBounds = homeDragPreviewBounds
|
||||
if (!ghost || !grip || !clipBounds || !previewBounds) return
|
||||
|
||||
const left = event.clientX - grip.x
|
||||
const top = event.clientY - grip.y
|
||||
ghost.style.transform = `translate3d(${left - clipBounds.left}px, ${top - clipBounds.top}px, 0)`
|
||||
homeDragPreviewBounds = viewportRectAt(
|
||||
left,
|
||||
top,
|
||||
previewBounds.width,
|
||||
previewBounds.height,
|
||||
)
|
||||
}
|
||||
|
||||
function createHomeDragGhost(event: PointerEvent): void {
|
||||
clearHomeDragGhost()
|
||||
const eventTarget =
|
||||
event.currentTarget instanceof Element
|
||||
? event.currentTarget
|
||||
: event.target instanceof Element
|
||||
? event.target
|
||||
: null
|
||||
const source = eventTarget?.closest<HTMLElement>('.app-icon-item')
|
||||
const layer = homeDragLayer.value
|
||||
const springboard = source?.closest<HTMLElement>('.springboard')
|
||||
const portal = layer?.closest<HTMLElement>('.phone-home-drag-portal')
|
||||
const geometry = readPhoneViewportGeometry(source ?? null)
|
||||
if (!source || !layer || !springboard || !portal || !geometry) return
|
||||
|
||||
const portalBounds = portal.getBoundingClientRect()
|
||||
const clipBounds = geometry.rect(springboard)
|
||||
const sourceBounds = geometry.rect(source)
|
||||
if (
|
||||
clipBounds.width <= 0 ||
|
||||
clipBounds.height <= 0 ||
|
||||
sourceBounds.width <= 0 ||
|
||||
sourceBounds.height <= 0
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
layer.style.left = `${clipBounds.left - portalBounds.left}px`
|
||||
layer.style.top = `${clipBounds.top - portalBounds.top}px`
|
||||
layer.style.width = `${clipBounds.width}px`
|
||||
layer.style.height = `${clipBounds.height}px`
|
||||
|
||||
const ghost = source.cloneNode(true) as HTMLElement
|
||||
ghost.classList.remove(
|
||||
'app-icon-item--dragging',
|
||||
'app-icon-item--editing',
|
||||
'app-icon-item--drag-source',
|
||||
'app-icon-item--drop-settling',
|
||||
'home-folder-item--dragging',
|
||||
'home-item--folder-hover',
|
||||
)
|
||||
ghost.classList.add('home-drag-ghost')
|
||||
ghost.removeAttribute('style')
|
||||
ghost
|
||||
.querySelector<HTMLElement>('.app-icon-button')
|
||||
?.style.removeProperty('transform')
|
||||
ghost.setAttribute('aria-hidden', 'true')
|
||||
ghost.querySelector('.app-icon-remove')?.remove()
|
||||
for (const element of [ghost, ...Array.from(ghost.querySelectorAll('*'))]) {
|
||||
element.removeAttribute('id')
|
||||
for (const attribute of element.getAttributeNames()) {
|
||||
if (attribute.startsWith('data-home-')) element.removeAttribute(attribute)
|
||||
}
|
||||
}
|
||||
for (const control of ghost.querySelectorAll<HTMLElement>(
|
||||
'a, button, input, select, textarea, [tabindex]',
|
||||
)) {
|
||||
control.tabIndex = -1
|
||||
}
|
||||
|
||||
const position = document.createElement('div')
|
||||
position.className = 'home-drag-position'
|
||||
position.style.width = `${sourceBounds.width}px`
|
||||
position.style.height = `${sourceBounds.height}px`
|
||||
ghost.style.width = `${source.offsetWidth}px`
|
||||
ghost.style.height = `${source.offsetHeight}px`
|
||||
ghost.style.transform = `scale(${geometry.scaleX}, ${geometry.scaleY})`
|
||||
position.appendChild(ghost)
|
||||
|
||||
homeDragGhost = position
|
||||
homeDragGrip = {
|
||||
x: event.clientX - sourceBounds.left,
|
||||
y: event.clientY - sourceBounds.top,
|
||||
}
|
||||
homeDragGeometry = geometry
|
||||
homeDragClipBounds = clipBounds
|
||||
homeDragPreviewBounds = sourceBounds
|
||||
layer.appendChild(position)
|
||||
homeDragVisualActive.value = true
|
||||
updateHomeDragGhost(event)
|
||||
}
|
||||
|
||||
function startHomeDrag(
|
||||
area: HomeArea,
|
||||
index: number,
|
||||
event: PointerEvent,
|
||||
): void {
|
||||
function startHomeDrag(area: HomeArea, index: number): void {
|
||||
clearTemporaryHomePage()
|
||||
lastHomePointer = null
|
||||
draggingHomeApp.value = { area, index }
|
||||
createHomeDragGhost(event)
|
||||
}
|
||||
|
||||
function clearFolderHover(): void {
|
||||
@@ -922,40 +754,10 @@ function queueFolderHover(event: PointerEvent): void {
|
||||
return
|
||||
}
|
||||
|
||||
const targetElement = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[data-home-area][data-home-index]'),
|
||||
)
|
||||
.filter((element) => {
|
||||
const area = element.dataset.homeArea as HomeArea | undefined
|
||||
const index = Number(element.dataset.homeIndex)
|
||||
if (
|
||||
(area !== 'grid' && area !== 'dock') ||
|
||||
!Number.isInteger(index) ||
|
||||
(area === dragged.area && index === dragged.index)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
area === 'grid' &&
|
||||
Number(
|
||||
element.closest<HTMLElement>('[data-home-page]')?.dataset.homePage,
|
||||
) !== phone.currentPage
|
||||
) {
|
||||
return false
|
||||
}
|
||||
const bounds = homeDragViewportRect(element)
|
||||
return (
|
||||
event.clientX >= bounds.left &&
|
||||
event.clientX <= bounds.right &&
|
||||
event.clientY >= bounds.top &&
|
||||
event.clientY <= bounds.bottom
|
||||
)
|
||||
})
|
||||
.sort((left, right) => {
|
||||
const leftIsDock = left.dataset.homeArea === 'dock'
|
||||
const rightIsDock = right.dataset.homeArea === 'dock'
|
||||
return Number(rightIsDock) - Number(leftIsDock)
|
||||
})[0]
|
||||
const targetElement = document
|
||||
.elementsFromPoint(event.clientX, event.clientY)
|
||||
.map((element) => element.closest<HTMLElement>('[data-home-index]'))
|
||||
.find((element) => element && !element.closest('.app-icon-item--dragging'))
|
||||
const area = targetElement?.dataset.homeArea as HomeArea | undefined
|
||||
const targetIndex = Number(targetElement?.dataset.homeIndex)
|
||||
if (
|
||||
@@ -1020,7 +822,6 @@ function queueFolderHover(event: PointerEvent): void {
|
||||
if (folderId) {
|
||||
draggingHomeApp.value = null
|
||||
lastHomePointer = null
|
||||
clearHomeDragGhost()
|
||||
clearTemporaryHomePage()
|
||||
openedFolderId.value = folderId
|
||||
}
|
||||
@@ -1031,7 +832,6 @@ function queueFolderHover(event: PointerEvent): void {
|
||||
function moveHomeDrag(event: PointerEvent): void {
|
||||
if (!draggingHomeApp.value) return
|
||||
lastHomePointer = { clientX: event.clientX, clientY: event.clientY }
|
||||
updateHomeDragGhost(event)
|
||||
if (resolveHomeEdgeTurn(event)) {
|
||||
clearFolderHover()
|
||||
queueEdgePageTurn(event, 'app')
|
||||
@@ -1045,7 +845,7 @@ async function animateHomeItemDrop(
|
||||
item: HomeItem,
|
||||
area: HomeArea,
|
||||
index: number,
|
||||
from: PhoneViewportRect,
|
||||
from: DOMRect,
|
||||
): Promise<void> {
|
||||
await nextTick()
|
||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
|
||||
@@ -1061,10 +861,7 @@ async function animateHomeItemDrop(
|
||||
)
|
||||
if (!appElement) return
|
||||
|
||||
const target = homeDragViewportRect(
|
||||
appElement,
|
||||
readPhoneViewportGeometry(appElement),
|
||||
)
|
||||
const target = appElement.getBoundingClientRect()
|
||||
const { x: offsetX, y: offsetY } = springboardViewportDeltaToLocal(
|
||||
from.left - target.left,
|
||||
from.top - target.top,
|
||||
@@ -1127,16 +924,13 @@ function nearestGridDropTarget(event: {
|
||||
`.springboard-page--apps[data-home-page="${page}"] .app-grid`,
|
||||
)
|
||||
const pageElement = grid?.closest<HTMLElement>('.springboard-page')
|
||||
const springboard = grid?.closest<HTMLElement>('.springboard')
|
||||
if (!grid || !pageElement || !springboard) return null
|
||||
const geometry = homeDragGeometry ?? readPhoneViewportGeometry(springboard)
|
||||
const pageBounds = homeDragViewportRect(pageElement, geometry)
|
||||
const springboardBounds = homeDragViewportRect(springboard, geometry)
|
||||
if (!grid || !pageElement) return null
|
||||
const pageBounds = pageElement.getBoundingClientRect()
|
||||
if (
|
||||
event.clientX < springboardBounds.left ||
|
||||
event.clientX > springboardBounds.right ||
|
||||
event.clientY < springboardBounds.top ||
|
||||
event.clientY > springboardBounds.bottom
|
||||
event.clientX < pageBounds.left ||
|
||||
event.clientX > pageBounds.right ||
|
||||
event.clientY < pageBounds.top ||
|
||||
event.clientY > pageBounds.bottom
|
||||
) {
|
||||
return null
|
||||
}
|
||||
@@ -1146,15 +940,7 @@ function nearestGridDropTarget(event: {
|
||||
const closestIndex = nearestSpringboardRectIndex(
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
slots.map((slot) => {
|
||||
const bounds = homeDragViewportRect(slot, geometry)
|
||||
return {
|
||||
height: bounds.height,
|
||||
left: springboardBounds.left + (bounds.left - pageBounds.left),
|
||||
top: springboardBounds.top + (bounds.top - pageBounds.top),
|
||||
width: bounds.width,
|
||||
}
|
||||
}),
|
||||
slots.map((slot) => slot.getBoundingClientRect()),
|
||||
)
|
||||
const closest = closestIndex === null ? null : slots[closestIndex]
|
||||
const targetOffset = Number(closest?.dataset.homeTargetOffset)
|
||||
@@ -1167,8 +953,7 @@ function nearestDockDropTarget(event: {
|
||||
}): HTMLElement | null {
|
||||
const dock = document.querySelector<HTMLElement>('.app-dock')
|
||||
if (!dock) return null
|
||||
const geometry = homeDragGeometry ?? readPhoneViewportGeometry(dock)
|
||||
const dockBounds = homeDragViewportRect(dock, geometry)
|
||||
const dockBounds = dock.getBoundingClientRect()
|
||||
if (
|
||||
event.clientX < dockBounds.left ||
|
||||
event.clientX > dockBounds.right ||
|
||||
@@ -1185,7 +970,7 @@ function nearestDockDropTarget(event: {
|
||||
const closestIndex = nearestSpringboardRectIndex(
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
slots.map((slot) => homeDragViewportRect(slot, geometry)),
|
||||
slots.map((slot) => slot.getBoundingClientRect()),
|
||||
)
|
||||
return closestIndex === null ? null : slots[closestIndex]
|
||||
}
|
||||
@@ -1194,14 +979,12 @@ function finishHomeDrag(event: PointerEvent): void {
|
||||
clearEdgePageTurn()
|
||||
clearFolderHover()
|
||||
const dragged = draggingHomeApp.value
|
||||
if (!dragged) {
|
||||
clearHomeDragGhost()
|
||||
return
|
||||
}
|
||||
updateHomeDragGhost(event)
|
||||
if (!dragged) return
|
||||
const draggedItem = appStore.homeLayout[dragged.area][dragged.index]
|
||||
const dropGhost = homeDragGhost
|
||||
const dropOrigin = homeDragPreviewBounds ? { ...homeDragPreviewBounds } : null
|
||||
const draggedElement = document.querySelector<HTMLElement>(
|
||||
`[data-home-area="${dragged.area}"][data-home-index="${dragged.index}"]`,
|
||||
)
|
||||
const dropOrigin = draggedElement?.getBoundingClientRect()
|
||||
const dockTarget = nearestDockDropTarget(event)
|
||||
const gridTarget = dockTarget ? null : nearestGridDropTarget(event)
|
||||
let dropArea = dragged.area
|
||||
@@ -1239,14 +1022,7 @@ function finishHomeDrag(event: PointerEvent): void {
|
||||
lastHomePointer = null
|
||||
clearTemporaryHomePage(moved && gridTarget !== null)
|
||||
if (moved && draggedItem && dropOrigin) {
|
||||
void animateHomeItemDrop(
|
||||
draggedItem,
|
||||
dropArea,
|
||||
dropIndex,
|
||||
dropOrigin,
|
||||
).finally(() => clearHomeDragGhost(dropGhost))
|
||||
} else {
|
||||
clearHomeDragGhost()
|
||||
void animateHomeItemDrop(draggedItem, dropArea, dropIndex, dropOrigin)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1255,7 +1031,6 @@ function stopHomeDrag(): void {
|
||||
clearFolderHover()
|
||||
draggingHomeApp.value = null
|
||||
lastHomePointer = null
|
||||
clearHomeDragGhost()
|
||||
clearTemporaryHomePage()
|
||||
}
|
||||
|
||||
@@ -1268,9 +1043,7 @@ function reorderHomeApp(
|
||||
const appElement = document.querySelector<HTMLElement>(
|
||||
`[data-home-area="${area}"][data-home-index="${sourceIndex}"]`,
|
||||
)
|
||||
const moveOrigin = appElement
|
||||
? homeDragViewportRect(appElement, readPhoneViewportGeometry(appElement))
|
||||
: null
|
||||
const moveOrigin = appElement?.getBoundingClientRect()
|
||||
const targetIndex = homeKeyboardTarget(
|
||||
appStore.homeLayout,
|
||||
area,
|
||||
@@ -1358,10 +1131,7 @@ function extractOpenedFolderApp(
|
||||
`.home-folder-panel [data-folder-app-index="${sourceIndex}"]`,
|
||||
)
|
||||
?.closest<HTMLElement>('.app-icon-item')
|
||||
const geometry = readPhoneViewportGeometry(sourceElement ?? null)
|
||||
const sourceBounds = sourceElement
|
||||
? homeDragViewportRect(sourceElement, geometry)
|
||||
: null
|
||||
const sourceBounds = sourceElement?.getBoundingClientRect()
|
||||
const candidates = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[data-home-index][data-home-area]'),
|
||||
).filter((element) => {
|
||||
@@ -1375,8 +1145,8 @@ function extractOpenedFolderApp(
|
||||
})
|
||||
const target = candidates.reduce<HTMLElement | null>((closest, candidate) => {
|
||||
if (!closest) return candidate
|
||||
const bounds = homeDragViewportRect(candidate, geometry)
|
||||
const closestBounds = homeDragViewportRect(closest, geometry)
|
||||
const bounds = candidate.getBoundingClientRect()
|
||||
const closestBounds = closest.getBoundingClientRect()
|
||||
const distance = Math.hypot(
|
||||
event.clientX - (bounds.left + bounds.width / 2),
|
||||
event.clientY - (bounds.top + bounds.height / 2),
|
||||
@@ -1442,7 +1212,6 @@ onBeforeUnmount(() => {
|
||||
pendingWidgetPointer = null
|
||||
lastWidgetPointer = null
|
||||
lastHomePointer = null
|
||||
clearHomeDragGhost()
|
||||
temporaryHomePage.value = null
|
||||
draggingHomeApp.value = null
|
||||
draggingWidgetId.value = null
|
||||
@@ -1458,6 +1227,7 @@ onBeforeUnmount(() => {
|
||||
'springboard--dragging': dragging,
|
||||
'springboard--editing': editMode,
|
||||
'springboard--folder-open': folderOverlayVisible,
|
||||
'springboard--home-dragging': draggingHomeApp !== null,
|
||||
'springboard--widget-dragging': draggingWidgetId !== null,
|
||||
},
|
||||
]"
|
||||
@@ -1533,7 +1303,6 @@ onBeforeUnmount(() => {
|
||||
:data-home-index="cell.sourceIndex"
|
||||
:data-home-target-offset="cell.targetOffset"
|
||||
:edit-mode="editMode"
|
||||
:external-drag-visual="homeDragVisualActive"
|
||||
:class="{
|
||||
'home-item--folder-hover':
|
||||
folderHoverTarget === `grid:${cell.sourceIndex}`,
|
||||
@@ -1541,7 +1310,7 @@ onBeforeUnmount(() => {
|
||||
@dragcancel="stopHomeDrag"
|
||||
@dragend="finishHomeDrag"
|
||||
@dragmove="moveHomeDrag"
|
||||
@dragstart="startHomeDrag('grid', cell.sourceIndex, $event)"
|
||||
@dragstart="startHomeDrag('grid', cell.sourceIndex)"
|
||||
@edit="enterEditMode"
|
||||
@remove="removeHomeApp(cell.app.id)"
|
||||
@reorder="reorderHomeApp('grid', cell.sourceIndex, $event)"
|
||||
@@ -1556,7 +1325,6 @@ onBeforeUnmount(() => {
|
||||
:data-home-index="cell.sourceIndex"
|
||||
:data-home-target-offset="cell.targetOffset"
|
||||
:edit-mode="editMode"
|
||||
:external-drag-visual="homeDragVisualActive"
|
||||
:folder="cell.folder"
|
||||
:class="{
|
||||
'home-item--folder-hover':
|
||||
@@ -1565,7 +1333,7 @@ onBeforeUnmount(() => {
|
||||
@dragcancel="stopHomeDrag"
|
||||
@dragend="finishHomeDrag"
|
||||
@dragmove="moveHomeDrag"
|
||||
@dragstart="startHomeDrag('grid', cell.sourceIndex, $event)"
|
||||
@dragstart="startHomeDrag('grid', cell.sourceIndex)"
|
||||
@edit="enterEditMode"
|
||||
@open="openFolder(cell.folder.id)"
|
||||
@reorder="reorderHomeApp('grid', cell.sourceIndex, $event)"
|
||||
@@ -1716,10 +1484,6 @@ onBeforeUnmount(() => {
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<Teleport defer to="#phone-home-drag-portal">
|
||||
<div ref="homeDragLayer" class="home-drag-layer" aria-hidden="true"></div>
|
||||
</Teleport>
|
||||
|
||||
<Transition name="edit-done">
|
||||
<k-glass
|
||||
v-if="editMode && isEditablePage && !folderOverlayVisible"
|
||||
@@ -1766,7 +1530,6 @@ onBeforeUnmount(() => {
|
||||
:data-home-item-key="`app-${slot.app.id}`"
|
||||
:data-home-index="slot.index"
|
||||
:edit-mode="editMode"
|
||||
:external-drag-visual="homeDragVisualActive"
|
||||
:show-label="false"
|
||||
:class="{
|
||||
'home-item--folder-hover':
|
||||
@@ -1775,7 +1538,7 @@ onBeforeUnmount(() => {
|
||||
@dragcancel="stopHomeDrag"
|
||||
@dragend="finishHomeDrag"
|
||||
@dragmove="moveHomeDrag"
|
||||
@dragstart="startHomeDrag('dock', slot.index, $event)"
|
||||
@dragstart="startHomeDrag('dock', slot.index)"
|
||||
@edit="enterEditMode"
|
||||
@remove="removeHomeApp(slot.app.id)"
|
||||
@reorder="reorderHomeApp('dock', slot.index, $event)"
|
||||
@@ -1789,7 +1552,6 @@ onBeforeUnmount(() => {
|
||||
:data-home-item-key="slot.folder.id"
|
||||
:data-home-index="slot.index"
|
||||
:edit-mode="editMode"
|
||||
:external-drag-visual="homeDragVisualActive"
|
||||
:folder="slot.folder"
|
||||
:show-label="false"
|
||||
:class="{
|
||||
@@ -1799,7 +1561,7 @@ onBeforeUnmount(() => {
|
||||
@dragcancel="stopHomeDrag"
|
||||
@dragend="finishHomeDrag"
|
||||
@dragmove="moveHomeDrag"
|
||||
@dragstart="startHomeDrag('dock', slot.index, $event)"
|
||||
@dragstart="startHomeDrag('dock', slot.index)"
|
||||
@edit="enterEditMode"
|
||||
@open="openFolder(slot.folder.id)"
|
||||
@reorder="reorderHomeApp('dock', slot.index, $event)"
|
||||
|
||||
@@ -81,7 +81,6 @@ describe('AppStoreApp Sky navigation contract', () => {
|
||||
expect(source).toContain('@click="profileOpened = true"')
|
||||
expect(source).toContain('const installedApps = computed')
|
||||
expect(source).toContain('return !appStore.isInstalled(app.id)')
|
||||
expect(source).toContain('if (!appStore.isAvailable(app.id)) return false')
|
||||
expect(source).toContain(
|
||||
'class="store-account__apps phone-effect--expensive-shadow"',
|
||||
)
|
||||
|
||||
@@ -119,10 +119,9 @@ const downloadDateDescription = computed(() =>
|
||||
)
|
||||
const catalog = computed(() =>
|
||||
PHONE_APPS.filter((app): app is LaunchablePhoneAppDefinition => {
|
||||
if (!isLaunchablePhoneApp(app) || app.id === 'app-store' || app.adminOnly) {
|
||||
if (!isLaunchablePhoneApp(app) || app.id === 'app-store') {
|
||||
return false
|
||||
}
|
||||
if (!appStore.isAvailable(app.id)) return false
|
||||
|
||||
return !appStore.isInstalled(app.id)
|
||||
}).sort((a, b) => a.gridOrder - b.gridOrder),
|
||||
@@ -130,9 +129,7 @@ const catalog = computed(() =>
|
||||
const installedApps = computed(() =>
|
||||
PHONE_APPS.filter(
|
||||
(app): app is LaunchablePhoneAppDefinition =>
|
||||
isLaunchablePhoneApp(app) &&
|
||||
appStore.isAvailable(app.id) &&
|
||||
appStore.isInstalled(app.id),
|
||||
isLaunchablePhoneApp(app) && appStore.isInstalled(app.id),
|
||||
).sort((a, b) => a.gridOrder - b.gridOrder),
|
||||
)
|
||||
const installedGameCount = computed(
|
||||
@@ -143,10 +140,8 @@ const dailyCandidates = computed(() =>
|
||||
PHONE_APPS.filter(
|
||||
(app): app is LaunchablePhoneAppDefinition =>
|
||||
isLaunchablePhoneApp(app) &&
|
||||
!app.adminOnly &&
|
||||
!isExternalPhoneApp(app) &&
|
||||
app.id !== 'app-store' &&
|
||||
appStore.isAvailable(app.id) &&
|
||||
!DEFAULT_INSTALLED_PHONE_APP_IDS.has(app.id) &&
|
||||
!appStore.isInstalled(app.id),
|
||||
),
|
||||
|
||||
@@ -48,11 +48,13 @@ describe('AppStoreDetail contract', () => {
|
||||
expect(previewSource).toContain('v-if="previewImage && screen < 3"')
|
||||
expect(previewSource).toContain(':src="previewImage"')
|
||||
expect(previewSource).toContain('store-detail-preview__screenshot')
|
||||
expect(previewSource).not.toContain('visual.scene ===')
|
||||
expect(previewSource).not.toContain("visual.scene ===")
|
||||
expect(source).toContain('getAppStorePreviewVisual')
|
||||
expect(source).toContain('Apps.appStore.previews.${props.app.id}')
|
||||
expect(previewSource).toContain(':src="iconImage"')
|
||||
expect(source).toMatch(/previewScreens\s*=\s*\[0, 1, 2, 3, 4\] as const/)
|
||||
expect(source).toMatch(
|
||||
/previewScreens\s*=\s*\[0, 1, 2, 3, 4\] as const/,
|
||||
)
|
||||
expect(previewSource).toContain('screen === 3')
|
||||
expect(previewSource).toContain('screen === 4')
|
||||
expect(previewSource).toContain('store-detail-preview__details')
|
||||
@@ -69,16 +71,6 @@ describe('AppStoreDetail contract', () => {
|
||||
expect(source).toContain('scrollToPreview(activePreviewIndex + 1)')
|
||||
expect(source).toContain('details.previousPreview')
|
||||
expect(source).toContain('details.nextPreview')
|
||||
expect(source).toContain('.store-detail__toolbar-button:hover')
|
||||
})
|
||||
|
||||
it('uses shared liquid glass for toolbar and preview controls', () => {
|
||||
expect(source).toContain("import { SkyButton } from '@/ui'")
|
||||
expect(source.match(/<SkyButton\s+glass/g)).toHaveLength(4)
|
||||
expect(source).toContain('class="store-detail__toolbar-button"')
|
||||
expect(source).toContain('class="store-detail__preview-control"')
|
||||
expect(source).toMatch(
|
||||
/\.store-detail__preview-control\s*\{[^}]*width:\s*var\(--sky-touch-target\);[^}]*height:\s*var\(--sky-touch-target\);[^}]*border-radius:\s*50%;/s,
|
||||
)
|
||||
expect(source).toContain('.store-detail__toolbar button:hover')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { ChevronLeft, ChevronRight, Share2, Star } from 'lucide-vue-next'
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Share2,
|
||||
Star,
|
||||
} from 'lucide-vue-next'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { getPhoneAppLabel, isExternalPhoneApp } from '@/config/apps'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import type { LaunchablePhoneAppDefinition } from '@/types/apps'
|
||||
import { SkyButton } from '@/ui'
|
||||
import { getAppStorePreviewImage } from '@/utils/appStorePreviewImages'
|
||||
import { getAppStorePreviewVisual } from '@/utils/appStorePreviews'
|
||||
|
||||
@@ -122,28 +126,20 @@ function updateActivePreview(): void {
|
||||
<template>
|
||||
<section class="store-detail" :style="detailStyle">
|
||||
<header class="store-detail__toolbar">
|
||||
<SkyButton
|
||||
glass
|
||||
icon-only
|
||||
rounded
|
||||
class="store-detail__toolbar-button"
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="phone.t('Common.back')"
|
||||
@click="emit('back')"
|
||||
>
|
||||
<ChevronLeft :size="26" :stroke-width="2.2" aria-hidden="true" />
|
||||
</SkyButton>
|
||||
<SkyButton
|
||||
glass
|
||||
icon-only
|
||||
rounded
|
||||
class="store-detail__toolbar-button"
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="phone.t('Apps.appStore.details.share')"
|
||||
@click="emit('share')"
|
||||
>
|
||||
<Share2 :size="21" :stroke-width="2" aria-hidden="true" />
|
||||
</SkyButton>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<section class="store-detail__hero">
|
||||
@@ -216,30 +212,22 @@ function updateActivePreview(): void {
|
||||
<h2>{{ phone.t('Apps.appStore.details.preview') }}</h2>
|
||||
<div class="store-detail__preview-navigation">
|
||||
<span>{{ activePreviewIndex + 1 }} / {{ previewCount }}</span>
|
||||
<SkyButton
|
||||
glass
|
||||
icon-only
|
||||
rounded
|
||||
class="store-detail__preview-control"
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="phone.t('Apps.appStore.details.previousPreview')"
|
||||
:disabled="activePreviewIndex === 0"
|
||||
@click="scrollToPreview(activePreviewIndex - 1)"
|
||||
>
|
||||
<ChevronLeft :size="17" :stroke-width="2.4" aria-hidden="true" />
|
||||
</SkyButton>
|
||||
<SkyButton
|
||||
glass
|
||||
icon-only
|
||||
rounded
|
||||
class="store-detail__preview-control"
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="phone.t('Apps.appStore.details.nextPreview')"
|
||||
:disabled="activePreviewIndex === previewCount - 1"
|
||||
@click="scrollToPreview(activePreviewIndex + 1)"
|
||||
>
|
||||
<ChevronRight :size="17" :stroke-width="2.4" aria-hidden="true" />
|
||||
</SkyButton>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div
|
||||
@@ -298,7 +286,7 @@ function updateActivePreview(): void {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.store-detail__toolbar-button {
|
||||
.store-detail__toolbar button {
|
||||
width: var(--sky-touch-target);
|
||||
height: var(--sky-touch-target);
|
||||
display: grid;
|
||||
@@ -306,6 +294,7 @@ function updateActivePreview(): void {
|
||||
border: 1px solid var(--sky-hairline);
|
||||
border-radius: 50%;
|
||||
color: var(--sky-text);
|
||||
background: var(--sky-surface-variant);
|
||||
transition:
|
||||
background-color 100ms ease,
|
||||
border-color 100ms ease,
|
||||
@@ -313,7 +302,7 @@ function updateActivePreview(): void {
|
||||
transform 100ms ease;
|
||||
}
|
||||
|
||||
.store-detail__toolbar-button:active {
|
||||
.store-detail__toolbar button:active {
|
||||
transform: scale(0.94);
|
||||
}
|
||||
|
||||
@@ -492,28 +481,27 @@ function updateActivePreview(): void {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.store-detail__preview-control {
|
||||
width: var(--sky-touch-target);
|
||||
height: var(--sky-touch-target);
|
||||
min-width: var(--sky-touch-target);
|
||||
min-height: var(--sky-touch-target);
|
||||
.store-detail__preview-navigation button {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid var(--sky-hairline);
|
||||
border-radius: 50%;
|
||||
padding: 0;
|
||||
color: var(--sky-text);
|
||||
background: var(--sky-surface-variant);
|
||||
transition:
|
||||
background-color 100ms ease,
|
||||
color 100ms ease,
|
||||
transform 100ms ease;
|
||||
}
|
||||
|
||||
.store-detail__preview-control:disabled {
|
||||
.store-detail__preview-navigation button:disabled {
|
||||
opacity: 0.34;
|
||||
}
|
||||
|
||||
.store-detail__preview-control:active:not(:disabled) {
|
||||
.store-detail__preview-navigation button:active:not(:disabled) {
|
||||
transform: scale(0.92);
|
||||
}
|
||||
|
||||
@@ -532,14 +520,16 @@ function updateActivePreview(): void {
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.store-detail__toolbar-button:hover {
|
||||
.store-detail__toolbar button:hover {
|
||||
border-color: rgba(255, 255, 255, 0.16);
|
||||
background: var(--sky-surface-tint);
|
||||
box-shadow: 0 7px 16px rgba(0, 0, 0, 0.18);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.store-detail__preview-control:hover:not(:disabled) {
|
||||
.store-detail__preview-navigation button:hover:not(:disabled) {
|
||||
color: var(--sky-app-accent);
|
||||
background: var(--sky-surface-tint);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ const appSource = readFileSync(
|
||||
'utf8',
|
||||
)
|
||||
const clientSource = readFileSync(
|
||||
new URL('../../../../sky_phone/source/client/nui_events.lua', import.meta.url),
|
||||
new URL('../../../../sky_phone/source/client/main.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const serverSource = readFileSync(
|
||||
@@ -69,10 +69,7 @@ describe('Banking app Sky UI migration', () => {
|
||||
expect(serverSource).toContain('kind = "transfer_in"')
|
||||
expect(serverSource).toContain('currency = Config.Banking.Currency')
|
||||
expect(clientSource).toContain(
|
||||
'["sky_phone:banking:changed"] = "banking:changed"',
|
||||
)
|
||||
expect(clientSource).toContain(
|
||||
'SendNUIMessage({ type = nui_type, data = data })',
|
||||
'SendNUIMessage({ type = "banking:changed", data = data })',
|
||||
)
|
||||
expect(appSource).toContain("data?.kind === 'transfer_in'")
|
||||
expect(appSource).toContain("appId: 'banking'")
|
||||
|
||||
@@ -791,7 +791,7 @@ onBeforeUnmount(() => {
|
||||
flex-direction: column;
|
||||
color: #f6f7f9;
|
||||
background: #07090c;
|
||||
font-family: var(--sky-font-family);
|
||||
font-family: Inter, system-ui, sans-serif;
|
||||
}
|
||||
.billing-app--light {
|
||||
--billing-border: rgb(15 23 42 / 10%);
|
||||
|
||||
@@ -52,8 +52,7 @@ describe('CalculatorApp layout contract', () => {
|
||||
)
|
||||
expect(source).toContain('calculator-history__nav-button--edit')
|
||||
expect(source).toContain('calculator-history__nav-button--close')
|
||||
expect(source.match(/<SkyButton\s+glass/g)).toHaveLength(2)
|
||||
expect(source).toContain('--sky-glass: rgb(44 44 46 / 62%);')
|
||||
expect(source).toContain('background: rgb(255 255 255 / 9%);')
|
||||
expect(source).toContain('-webkit-backdrop-filter: none;')
|
||||
expect(source).toContain('backdrop-filter: none;')
|
||||
expect(source).toContain('min-width: 106px;')
|
||||
@@ -63,7 +62,7 @@ describe('CalculatorApp layout contract', () => {
|
||||
expect(source).toContain('place-items: center;')
|
||||
expect(source).toContain('padding: 0;')
|
||||
expect(source).toMatch(
|
||||
/\.calculator-history__nav-button:hover:not\(:disabled\)[^}]*transform: none;[^}]*filter: brightness\(1\.08\);/s,
|
||||
/\.calculator-history__nav-button:hover:not\(:disabled\)[^}]*background: rgb\(255 255 255 \/ 15%\);[^}]*transform: none;[^}]*filter: none;/s,
|
||||
)
|
||||
expect(source).not.toContain('class="calculator-history__edit"')
|
||||
expect(source).not.toContain('class="calculator-history__close"')
|
||||
|
||||
@@ -378,7 +378,6 @@ watch([() => calculator.display, expression], async () => {
|
||||
>
|
||||
<template #left>
|
||||
<SkyButton
|
||||
glass
|
||||
class="calculator-history__nav-button calculator-history__nav-button--edit"
|
||||
inline
|
||||
rounded
|
||||
@@ -398,7 +397,6 @@ watch([() => calculator.display, expression], async () => {
|
||||
</template>
|
||||
<template #right>
|
||||
<SkyButton
|
||||
glass
|
||||
class="calculator-history__nav-button calculator-history__nav-button--close"
|
||||
icon-only
|
||||
rounded
|
||||
@@ -723,15 +721,16 @@ watch([() => calculator.display, expression], async () => {
|
||||
}
|
||||
|
||||
.calculator-history__navbar :deep(.calculator-history__nav-button) {
|
||||
--sky-glass: rgb(44 44 46 / 62%);
|
||||
--sky-hairline: rgb(255 255 255 / 14%);
|
||||
height: 44px;
|
||||
min-height: 44px;
|
||||
background: rgb(255 255 255 / 9%);
|
||||
color: #fff;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.calculator-history__navbar :deep(.calculator-history__nav-button:active) {
|
||||
filter: brightness(0.94);
|
||||
background: rgb(255 255 255 / 14%);
|
||||
filter: none;
|
||||
}
|
||||
|
||||
.calculator-history__navbar :deep(.calculator-history__nav-button--edit) {
|
||||
@@ -758,8 +757,9 @@ watch([() => calculator.display, expression], async () => {
|
||||
@media (hover: hover) {
|
||||
.calculator-history__navbar
|
||||
:deep(.calculator-history__nav-button:hover:not(:disabled)) {
|
||||
background: rgb(255 255 255 / 15%);
|
||||
transform: none;
|
||||
filter: brightness(1.08);
|
||||
filter: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user