Compare commits

..

32 Commits

Author SHA1 Message Date
Alec Schitzkat 8b4a32bfab BLD - Bump version to 0.2.8 2026-08-22 23:09:55 +02:00
DerEchteAlec cb6c94a7d8 FIX - harden phone stability and app controls (#28) 2026-08-22 19:48:30 +02:00
Leon.Schmidt 2059e19e1c ENH - add live admin dashboard statistics (#27) 2026-08-22 18:33:52 +02:00
Alec Schitzkat 99e943dbc5 BLD - Update GitHub release title to reflect beta status 2026-08-22 05:03:27 +02:00
Alec Schitzkat bd9f4c2695 BLD - Bump version to 0.2.7 2026-08-22 05:02:28 +02:00
Leon.Schmidt 4fa4bf3c78 ENH - centralize phone defaults and fixed permissions (#26)
* ENH - centralize phone configuration defaults

* FIX - enforce fixed phone permissions
2026-08-22 05:02:14 +02:00
DerEchteAlec a0f67fae80 FIX - synchronize Spanish locale with admin panel 2026-08-22 03:40:23 +02:00
Alec Schitzkat f7644bf973 BLD - Bump version to 0.2.6 2026-08-22 03:31:58 +02:00
Alec Schitzkat e8465e4732 Auto stash before merge of "dev" and "origin/dev" 2026-08-22 03:27:22 +02:00
DerEchteAlec a9143b0fba ADD - add in-game phone administration and configuration (#25)
* ADD - build in-game admin command center

* ADD - open admin panel by command

* ENH - rebuild admin panel as standalone editor

* ENH - compact admin workspace navigation

* ENH - expand admin device controls

* ENH - refine admin panel navigation style

* ADD - add SQL phone configurator

* FIX - expose complete phone configuration

* FIX - make company jobs freely configurable

* FIX - align configurator tables to left

* ENH - organize configurator collection controls

* ENH - organize nested configurator sections

* ENH - add configurator field descriptions

* FIX - hide fixed configurator add controls

* ENH - refine configurator editing experience

* ENH - refine admin configurator controls

* FIX - apply configurator settings instantly

* FIX - close live activity command registration

---------

Co-authored-by: Leon.Schmidt <leonmauricewill15@gmail.com>
2026-08-22 03:26:52 +02:00
Leon.Schmidt 7aa4677b81 ADD - support extended inventory metadata bridges (#23)
* ADD - support extended inventory metadata bridges

* FIX - align inventory contract tests
2026-08-21 22:04:15 +02:00
DerEchteAlec 5f8c835685 Revert "ADD - add in-game phone administration and configuration (#21)" (#24)
This reverts commit edd9677f43.
2026-08-21 21:49:05 +02:00
DerEchteAlec 21913b585c FIX - use repository-native NUI build instructions (#22)
Replace the unavailable workspace-only build_frontend.bat hint with the frontend pnpm commands shipped in the GitHub source tree. Link the pushed frontend development guide and keep the local-only script name out of the startup diagnostic through regression coverage.
2026-08-21 18:14:39 +02:00
Alec Schitzkat e056d0f05c BLD - Bump version to 0.2.5 2026-08-21 17:35:36 +02:00
Leon.Schmidt edd9677f43 ADD - add in-game phone administration and configuration (#21)
* ADD - build in-game admin command center

* ADD - open admin panel by command

* ENH - rebuild admin panel as standalone editor

* ENH - compact admin workspace navigation

* ENH - expand admin device controls

* ENH - refine admin panel navigation style

* ADD - add SQL phone configurator

* FIX - expose complete phone configuration

* FIX - make company jobs freely configurable

* FIX - align configurator tables to left

* ENH - organize configurator collection controls

* ENH - organize nested configurator sections

* ENH - add configurator field descriptions

* FIX - hide fixed configurator add controls

* ENH - refine configurator editing experience

* ENH - refine admin configurator controls

* FIX - apply configurator settings instantly

* FIX - close live activity command registration
2026-08-21 17:28:30 +02:00
DerEchteAlec 97db163cd0 FIX - harden inventory contracts and resolve phone interaction issues (#20)
* FIX - repair inventory metadata and ESX contracts

Use the stable core_inventory metadata setter and validate the complete inventory adapter contract after provider bridges load. Preserve the real ESX configuration error instead of cascading into a missing RegisterUsableItem failure.

* FIX - clarify phone opening without SIM

* FIX - harden inventory and device contracts

Validate SIM number configuration before item handling, preserve QB metadata mutation contracts, and propagate phone-open failures to callers. Extend regression coverage for inventory bridges and device bootstrap errors.

* FIX - resolve reported phone interaction issues

Stop game-input passthrough while NUI text fields are focused and add an independent hold-to-look option. Propagate SIM number formatting to the frontend, migrate configured currency symbols to utf8mb4, and clarify SkyRide, VaultX, and DarkChat behavior with focused contract coverage.

* FIX - detect missing packaged phone UI

Validate the generated NUI entrypoint, its referenced assets, and required static media when the resource starts. Print an actionable server-console warning for incomplete source archives, package phone sounds through the manifest, and cover the detection contract with Lua regressions.
2026-08-21 17:19:27 +02:00
Alec Schitzkat 698420584d ADD - Restore sky_phone fxmanifest.lua
Restore the resource manifest file to define the project structure, dependencies, and script entry points for version 0.2.1.
2026-08-21 01:33:59 +02:00
Alec Schitzkat 56901621a0 CLN - Remove sky_phone fxmanifest.lua
Remove the manifest file for the sky_phone resource, effectively disabling the resource or preparing it for structural relocation.
2026-08-21 01:33:29 +02:00
Leon.Schmidt 7b4e445394 FIX - restore FiveManage camera and memo uploads (#19)
* TRY - isolate FiveManage media upload verification

* TRY - verify unindexed FiveManage storage objects

* FIX - bind FiveManage uploads by storage key

* FIX - match FiveManage camera upload flow

* FIX - match FiveManage memo upload flow
2026-08-21 01:29:56 +02:00
Alec Schitzkat 7bbafd0026 BLD - Bump version to 0.2.0 2026-08-20 21:35:31 +02:00
DerEchteAlec 300329825f Merge pull request #4 from sky-systems/feat/inter-font
ENH - use Inter across phone UI and improve home label readability
2026-08-20 19:21:05 +02:00
DerEchteAlec 93d2263f14 FIX - resolve PR 4 merge conflict 2026-08-20 19:18:23 +02:00
DerEchteAlec dca0868753 FIX - stabilize homescreen dragging across scaling (#18)
* FIX - stabilize homescreen dragging across scaling

Render the home drag preview in an unzoomed viewport portal and normalize FiveM CEF DOM geometry before hit testing and drop settling. Cover Chrome 103, fractional scaling, 4K, ultrawide, and non-16:9 viewport behavior.

* FIX - Implement LB guest app layout and lifecycle bridge

Ensure custom LB apps are properly sized and visible by applying the required CSS contract on initialization and upon receiving the 'componentsLoaded' signal.

* FIX - match uppercase script tags in LB bridge test
2026-08-20 19:14:00 +02:00
Leon.Schmidt 36b859cabb ENH - add persistent dynamic island activities (#17)
* FIX - layer dynamic island above phone camera

The phone screen stacking context kept the Dynamic Island below the physical frame and its camera lens. Render the island at device-shell level, position it around the lens, and keep expanded notification spacing in sync.

* ADD - preview all dynamic island states

Reuse the runtime component for a development-only gallery and size expanded previews to the actual phone display so they cannot be clipped.

* FIX - match dynamic island live activity behavior

Show each activity only outside its owning app, use reference-sized activity layouts, and remove the separate expand glyph in favor of direct island taps.

* FIX - refine dynamic island live controls

Keep background activity state separate from foreground visibility so the island remains visible in the compact closed-phone peek. Match the timer, stopwatch, music, collapse, and spacing behavior to the supplied references.

* FIX - keep business call availability after closing

Stop treating the closed phone UI as the end of business-call readiness. Route background service-line calls only after revalidating the employee, owned device, and registered SIM.

* FIX - remove dock apps from home grid

The stored Home layout treated dock and grid shortcuts independently, so default dock apps appeared twice. Let the dock own those default placements and migrate saved layouts by removing matching grid shortcuts.

* FIX - keep memo island active after closing

Memo recording was cancelled as soon as the UI closed, while uploads required an active UI session and reopening a locked phone forced the home route. Keep the recorder bound to the physical device, revalidate device ownership server-side for uploads, preserve the last route behind the lock screen, and render only a compact frame, display, and live Dynamic Island while closed.

* ENH - align live activity controls with phone hardware

Report compact live-activity state to the client so Space opens Home or the enabled lock screen contextually. Apply the phone hardware volume as a shared output level for app media, games, YouTube playback, radio, and auxiliary sounds, and render the volume speaker icon in grey.

* FIX - hide paused music live activity

The music island previously depended only on the selected track, which remains set after playback pauses. Require active playback as well so the island closes when music stops while preserving the track for later resume.

---------

Co-authored-by: DerEchteAlec <bycraky@gmail.com>
2026-08-20 18:59:51 +02:00
DerEchteAlec d0317a35c0 ENH - refine phone setup (#15)
* ENH - refine phone setup experience

* FIX - refine settings header and search icon

* FIX - refine compact weather layout

* ENH - fade empty music widget content

* ENH - align gallery filter navigation

* FIX - align memo player content

* ENH - refine notes header and compose controls

* ENH - refine phone liquid glass controls

* ENH - apply liquid glass across app controls

* FIX - correct messages GIF picker layout

---------

Co-authored-by: smx.pusha <139338836+smxpusha@users.noreply.github.com>
2026-08-20 18:26:03 +02:00
DerEchteAlec 074117fe33 Merge branch 'dev' into feat/inter-font 2026-08-19 20:11:48 +02:00
DerEchteAlec a1025f4520 Merge branch 'dev' into feat/inter-font 2026-08-19 19:21:42 +02:00
DerEchteAlec 7b8bab75a8 Merge branch 'dev' into feat/inter-font 2026-08-19 15:56:56 +02:00
DerEchteAlec c0492a70cb BLD - merge dev into Inter font branch 2026-08-19 15:34:49 +02:00
DerEchteAlec 05169acbfe BLD - add GitHub contribution and release automation 2026-08-19 14:20:46 +02:00
DerEchteAlec 086b77e464 ENH - enlarge home screen labels 2026-08-19 13:52:06 +02:00
DerEchteAlec de613e2502 ENH - use Inter across phone UI 2026-08-19 13:41:45 +02:00
195 changed files with 22746 additions and 1257 deletions
+2
View File
@@ -17,6 +17,8 @@ for (const requiredFragment of [
"fx_version 'cerulean'",
"node_version '22'",
"use_experimental_fxv2_oal 'yes'",
"'source/server/nui_build_check.lua'",
"'source/html/sounds/**'",
"ui_page 'source/html/index.html'",
]) {
if (!manifest.includes(requiredFragment)) {
+1 -1
View File
@@ -119,5 +119,5 @@ jobs:
"${archive}.sha256" \
--verify-tag \
--generate-notes \
--title "Sky Phone $GITHUB_REF_NAME"
--title "$GITHUB_REF_NAME Beta"
fi
+17
View File
@@ -92,6 +92,23 @@ 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
+153 -21
View File
@@ -29,7 +29,7 @@
<p align="center">
<a href="https://www.sky-systems.net/shop/phone#live-demo"><strong>Live demo</strong></a>
&nbsp;&bull;&nbsp;
<a href="https://github.com/sky-systems/sky_phone"><strong>Download for free</strong></a>
<a href="https://github.com/sky-systems/sky_phone/releases/latest"><strong>Download for free</strong></a>
&nbsp;&bull;&nbsp;
<a href="https://discord.gg/sky-systems"><strong>Discord support</strong></a>
</p>
@@ -44,7 +44,7 @@ Sky Phone is a **free and open-source FiveM phone script** built to give serious
This is not a cut-down free alternative. Sky Phone includes the core experience server owners and players expect from a leading paid FiveM phone, plus full source access, no purchase price, no feature paywalls, and no forced ecosystem lock-in.
The production frontend is included, so a normal server installation does not require Node.js or pnpm.
The production frontend is included in the published release package, so a normal server installation does not require Node.js or pnpm. GitHub's automatically generated source archives do not contain that build.
## Why Sky Phone stands out
@@ -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** | ox_inventory, qb-inventory, lj-inventory, qs-inventory, codem-inventory, core_inventory, mf-inventory, smx-inventory, hex_4_inventory, and native ESX inventory |
| **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 |
| **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,20 +121,33 @@ 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 | Metadata support | Unique Phones | Notes |
| Inventory (configuration value) | Metadata support | Unique Phones | Notes |
| --- | --- | --- | --- |
| `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` |
| `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 |
`hex_4_inventory` and native ESX inventory cannot persist per-item metadata. Unique Phones and physical SIM cards are therefore unavailable with these adapters.
`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.
### Voice
@@ -160,8 +173,8 @@ Start the selected voice resource before Sky Phone.
## Quick installation
1. Copy the resource into your FiveM resources directory.
2. Keep the resource folder name `sky_phone`.
1. Download and extract the latest published [Sky Phone release](https://github.com/sky-systems/sky_phone/releases/latest). Do not use GitHub's automatically generated "Source code" archives for a server installation because they do not contain the built frontend.
2. Copy the included resource into your FiveM resources directory and keep its folder name `sky_phone`.
3. Start `oxmysql`, your framework, inventory, and voice resource before Sky Phone.
4. Review `sky_phone/config/config.lua` and `sky_phone/config/media.lua`.
5. Add the required inventory items.
@@ -182,6 +195,13 @@ Replace the example framework, inventory, and voice resources with the providers
Sky Phone creates and upgrades its database tables automatically. A manual SQL import is normally not required.
### How players open the phone
- Give the player the item configured in `Config.Phone.Item` (default: `phone`).
- Players can use that inventory item or press the configured keybind (default: `F1`).
- The keybind still verifies server-side that the player owns a configured phone item; it does not bypass inventory ownership.
- A SIM card is **not required to open or use the phone itself**. With `Config.Sim.Enabled = true`, only cellular features such as calls and messages require an inserted SIM.
## Configuration
Customer settings are organized in:
@@ -196,6 +216,7 @@ 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 |
@@ -211,6 +232,44 @@ 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:
@@ -315,9 +374,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: `ox`, `qb`, `lj`, `qs`, `codem`, `core`, `mf`, `smx`, `hex`, and `esx`. Resource startup fails visibly if the selected adapter cannot complete that registration.
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 `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.
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.
### QBCore-style item tables
@@ -326,6 +385,67 @@ The `hex` and `esx` adapters use ESX's count-based item API. They require both `
- 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:
@@ -344,7 +464,7 @@ With unique phones, using an inventory item selects that exact handset whenever
| SIM mode | Behavior |
| --- | --- |
| `Enabled = true` | A registered or anonymous physical SIM item is required for cellular service. |
| `Enabled = true` | The phone opens with or without a SIM. A registered or anonymous physical SIM item is required only for cellular service such as calls and messages. |
| `Enabled = false` | Sky Phone creates a persistent automatic number for devices without a SIM. Physical SIM items are not required. |
When changing these modes on an existing production server, restart the resource and test with a copy of the database first. The first phone used after switching to non-unique mode may adopt an existing valid IMEI so its local data is preserved.
@@ -534,7 +654,11 @@ 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`.
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.
### Weazel News
@@ -602,12 +726,20 @@ pnpm build
### The phone item does nothing
- A warning that the inventory returned no configured phone item means an item definition, `Config.Phone.Item`, inventory selection, or player ownership problem. It is not caused by a missing SIM card.
- Confirm the framework and inventory are supported and started first.
- Confirm the item name matches `Config.Phone.Item`.
- Confirm the item is usable.
- In unique mode, confirm the phone is non-stackable.
- Check the server console for inventory adapter warnings.
### The resource starts but the phone UI is missing
- On startup, the server console prints `SKY PHONE UI BUILD IS MISSING OR INCOMPLETE`, lists the missing or invalid packaged files, and shows repository-native build commands.
- Install the latest published release package rather than GitHub's automatically generated source archive.
- Confirm `sky_phone/source/html/index.html`, `assets`, `img`, and `sounds` exist.
- Developers working from source must run the frontend production build before starting the resource.
### Calls connect without audio
- Confirm the configured voice resource is running.
+94
View File
@@ -26,6 +26,100 @@ 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
+40 -5
View File
@@ -2,15 +2,49 @@ 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(
frontendRoot,
'..',
'sky_phone',
const targetDirectory = path.join(resourceDirectory, 'source', 'html')
const configDefaultPath = path.join(
resourceDirectory,
'source',
'html',
'shared',
'config_default.lua',
)
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.',
@@ -28,4 +62,5 @@ const normalizedIndex = fs
.replace(/\n[ \t]*\n([ \t]*<\/body>)/g, '\n$1')
fs.writeFileSync(targetIndex, normalizedIndex)
generateConfigDefault()
console.log(`Published NUI to ${targetDirectory}`)
+131 -12
View File
@@ -11,6 +11,7 @@ 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'
@@ -56,6 +57,7 @@ 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,
@@ -69,19 +71,26 @@ import type {
CompanyChangedPayload,
CompanyUnreadCounts,
} from '@/types/companies'
import type { PhoneCall } from '@/types/phone'
import type { PhoneCall, PhoneNumberFormat } from '@/types/phone'
import type { DynamicIslandActivity } from '@/types/dynamicIsland'
import type { EasyShareEvent } from '@/types/easyshare'
import type { CryptoMarketChangedData } from '@/types/crypto'
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
@@ -107,8 +116,13 @@ type AppMessage = {
| CustomAppCatalogEventData
| CustomAppEventData
| NavigationEventData
| AdminPanelOpenPayload
}
type AdminPanelOpenPayload = Required<
Pick<PhoneOpenPayload, 'fallbackLocales' | 'lang' | 'locales'>
>
type CustomAppCatalogEventData = {
apps?: unknown
}
@@ -126,6 +140,7 @@ type NavigationEventData = {
type SimPickerPayload = {
choices: SimPhoneChoice[]
number: string
phoneNumberFormat?: PhoneNumberFormat
}
type NotificationEventData = Omit<PhoneNotificationInput, 'device'> & {
@@ -313,6 +328,7 @@ const notes = useNotesStore()
const memos = useMemosStore()
const weather = useWeatherStore()
const easyShare = useEasyShareStore()
const radio = useRadioStore()
const notifications = useNotificationsStore()
const route = useRoute()
const router = useRouter()
@@ -335,13 +351,21 @@ 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',
() =>
isDevelopment &&
(route.name === 'development-sky-ui' || isDynamicIslandGalleryRoute.value),
)
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)
@@ -356,9 +380,12 @@ 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(
() =>
@@ -432,6 +459,12 @@ 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>(() => ({
@@ -449,6 +482,7 @@ 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
@@ -468,6 +502,7 @@ function getViewportScale(): number {
}
function hydratePhone(payload: PhoneOpenPayload): void {
configurePhoneNumberFormat(payload.phoneNumberFormat)
if (payload.device?.imei) {
companies.bindDeviceScope(
payload.device.imei,
@@ -495,8 +530,17 @@ 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)
appStore.hydrate(payload.device?.data.apps?.payload, payload.disabledApps)
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[] {
@@ -604,6 +648,8 @@ function loadUnlockedPhoneData(): void {
}
function completePhoneSetup(): void {
const requestedRoute = pendingUnlockRoute.value
pendingUnlockRoute.value = null
setupPreviewDismissed.value = true
setupAppearanceSelected.value = false
isLocked.value = false
@@ -611,7 +657,7 @@ function completePhoneSetup(): void {
passcodeVisible.value = false
passcodeRequired.value = false
controlCenterOpened.value = false
void router.replace('/')
void router.replace(requestedRoute ?? '/')
loadUnlockedPhoneData()
}
@@ -695,7 +741,15 @@ function openDevelopmentPayphonePreview(): void {
function onMessage(event: MessageEvent<AppMessage>): void {
if (!isTrustedRootMessageSource(event.source, window)) return
if (event.data?.type === 'custom-apps:catalog') {
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') {
appCatalog.replaceCatalog(event.data.data)
const catalogPayload = event.data.data as
| { apps?: unknown; debug?: unknown }
@@ -740,7 +794,12 @@ function onMessage(event: MessageEvent<AppMessage>): void {
isPhoneAppId(data.appId) &&
appStore.isInstalled(data.appId)
) {
void router.push(`/apps/${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.')
}
@@ -758,6 +817,7 @@ function onMessage(event: MessageEvent<AppMessage>): void {
})
}
} 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'))
} else if (event.data?.type === 'device:updated') {
@@ -1198,7 +1258,9 @@ function onMessage(event: MessageEvent<AppMessage>): void {
loadUnlockedPhoneData()
}
} else if (event.data?.type === 'sim:picker' && event.data.data) {
simPicker.value = event.data.data as unknown as SimPickerPayload
const payload = event.data.data as unknown as SimPickerPayload
configurePhoneNumberFormat(payload.phoneNumberFormat)
simPicker.value = payload
} else if (event.data?.type === 'sim:picker-close') {
simPicker.value = null
}
@@ -1473,6 +1535,7 @@ function onFocusOut(event: FocusEvent): void {
}
onMounted(() => {
removePhoneAudioController = installPhoneAudioController()
document.addEventListener('focusin', onFocusIn)
document.addEventListener('focusout', onFocusOut)
window.addEventListener('message', onMessage)
@@ -1587,6 +1650,23 @@ 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) => {
@@ -1619,7 +1699,8 @@ watch(
}
isLocked.value = setupRequired.value
? false
: !isDevelopment || developmentLockScreenPreview
: developmentLockScreenPreview ||
(!isDevelopment && phone.security.enabled)
passcodeRequired.value = isLocked.value && phone.security.enabled
unlockedServicesLoaded.value = false
controlCenterOpened.value = false
@@ -1637,8 +1718,18 @@ watch(
startPasscodeLock(passcodeRetrySeconds.value)
}
phone.setLaunchOrigin(null)
if (isLocked.value || setupRequired.value) void router.replace('/')
else loadUnlockedPhoneData()
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()
}
},
)
@@ -1650,6 +1741,7 @@ watch(
)
onBeforeUnmount(() => {
removePhoneAudioController?.()
updateTextInputFocus(false)
cancelUnlockedPhoneDataLoad()
weather.stop()
@@ -1672,6 +1764,15 @@ 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 />
@@ -1688,12 +1789,15 @@ 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"
@@ -1713,14 +1817,25 @@ onBeforeUnmount(() => {
@open="openNotificationPreview"
/>
<div
v-if="phone.isOpen || notifications.current || calls.activeCall"
v-if="
phone.isOpen ||
notifications.current ||
calls.activeCall ||
dynamicIslandActivity
"
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')"
@@ -1832,7 +1947,6 @@ onBeforeUnmount(() => {
@control-center="toggleControlCenter"
@lock="lockPhone"
/>
<PhoneDynamicIsland v-if="!setupRequired" />
<SpringboardView
v-if="!isDevelopmentRoute && !setupRequired"
@edit-mode-change="springboardEditing = $event"
@@ -1918,6 +2032,11 @@ 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')
const source = 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',
@@ -22,15 +22,32 @@ describe('browser development preview contract', () => {
it('starts unlocked while preserving an explicit lock screen preview', () => {
expect(source).toContain("developmentParameters.has('lockScreenPreview')")
expect(source).toContain(': !isDevelopment || developmentLockScreenPreview')
expect(source).toContain(
'developmentLockScreenPreview ||\n (!isDevelopment && phone.security.enabled)',
)
expect(source).toContain("developmentParameters.has('setupPreview')")
})
it('loads authenticated app data without replacing direct app routes', () => {
expect(source).toContain(
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(
"if (isLocked.value || setupRequired.value) void router.replace('/')",
)
expect(source).toContain('else loadUnlockedPhoneData()')
})
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('/')")
})
it('requires the passcode again after a full device lock', () => {
@@ -75,6 +92,10 @@ 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(
@@ -100,7 +121,9 @@ 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.
+70 -87
View File
@@ -211,9 +211,11 @@
}
}
:root {
font-family:
-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Helvetica Neue',
sans-serif;
font-family: var(--sky-font-family);
font-feature-settings:
'liga' 1,
'calt' 1;
font-optical-sizing: auto;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
@@ -234,7 +236,9 @@ body,
user-select: none;
}
button,
input {
input,
textarea,
select {
font: inherit;
}
button {
@@ -259,6 +263,20 @@ 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;
}
@@ -281,6 +299,18 @@ 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;
@@ -384,7 +414,7 @@ button {
overflow: hidden;
border: 1px solid rgb(255 255 255 / 16%);
border-radius: 15px;
color: #0a84ff;
color: #a9abb2;
background: rgb(47 47 51 / 98%);
box-shadow: 0 8px 24px rgb(0 0 0 / 28%);
pointer-events: none;
@@ -672,6 +702,9 @@ 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;
@@ -795,78 +828,6 @@ 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;
@@ -1341,8 +1302,7 @@ button {
overflow: hidden;
background: #000;
color: #f5f5f7;
font-family:
-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', sans-serif;
font-family: var(--sky-font-family);
}
.darkchat-page button,
.darkchat-page input,
@@ -2677,8 +2637,30 @@ button {
.springboard--widget-dragging .springboard-widget-page-scroll {
overflow: visible;
}
.springboard--home-dragging .springboard-page--apps {
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;
}
.app-grid {
display: grid;
@@ -2713,6 +2695,9 @@ 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;
@@ -2838,7 +2823,6 @@ button {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 11px;
}
.app-icon--calculator {
background: linear-gradient(145deg, #76767b, #1b1b1d);
@@ -7597,18 +7581,17 @@ button {
min-height: var(--springboard-grid-row);
}
.app-icon-button {
font-family:
-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', sans-serif;
font-family: var(--sky-font-family);
touch-action: none;
user-select: none;
-webkit-user-select: none;
}
.app-icon-label {
min-height: 15px;
font-size: 11.5px;
min-height: var(--sky-home-label-height);
font-size: var(--sky-home-label-font-size);
font-weight: 500;
letter-spacing: -0.15px;
line-height: 15px;
line-height: var(--sky-home-label-height);
text-align: center;
}
.springboard-edit-add {
@@ -14,6 +14,10 @@ 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',
@@ -85,3 +89,35 @@ 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
@@ -0,0 +1,524 @@
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
+10 -4
View File
@@ -30,11 +30,13 @@ const props = withDefaults(
app: PhoneAppDefinition
compact?: boolean
editMode?: boolean
externalDragVisual?: boolean
showLabel?: boolean
}>(),
{
compact: false,
editMode: false,
externalDragVisual: false,
showLabel: true,
},
)
@@ -63,14 +65,14 @@ let dragStartPage = 0
let dragPageWidth = 0
let dragPageMetrics: SpringboardDragMetrics | null = null
const dragStyle = computed(() =>
isDragging.value
isDragging.value && !props.externalDragVisual
? {
transform: `translate3d(${springboardPageDragCompensation(dragStartPage, phone.currentPage, dragPageWidth)}px, 0, 0)`,
}
: undefined,
)
const dragPointerStyle = computed(() =>
isDragging.value
isDragging.value && !props.externalDragVisual
? {
transform: `translate3d(${dragOffset.value.x}px, ${dragOffset.value.y}px, 0)`,
}
@@ -300,6 +302,7 @@ 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,
}"
@@ -325,7 +328,10 @@ onBeforeUnmount(() => {
class="app-icon"
:class="[
app.iconClass,
{ 'app-icon--image': !iconFailed && app.id !== 'calendar' },
{
'app-icon--image':
!iconFailed && Boolean(app.iconImage) && app.id !== 'calendar',
},
]"
:style="iconStyle"
>
@@ -334,7 +340,7 @@ onBeforeUnmount(() => {
<b>{{ calendarDay }}</b>
</span>
<img
v-else-if="!iconFailed"
v-else-if="!iconFailed && app.iconImage"
:src="app.iconImage"
alt=""
draggable="false"
+5 -2
View File
@@ -22,11 +22,13 @@ const props = withDefaults(
apps: PhoneAppDefinition[]
defaultName: string
editMode?: boolean
externalDragVisual?: boolean
folder: HomeFolder
showLabel?: boolean
}>(),
{
editMode: false,
externalDragVisual: false,
showLabel: true,
},
)
@@ -55,14 +57,14 @@ let stopPointerSession: (() => void) | null = null
const folderName = computed(() => props.folder.name || props.defaultName)
const dragStyle = computed(() =>
isDragging.value
isDragging.value && !props.externalDragVisual
? {
transform: `translate3d(${springboardPageDragCompensation(dragStartPage, phone.currentPage, dragPageWidth)}px, 0, 0)`,
}
: undefined,
)
const dragPointerStyle = computed(() =>
isDragging.value
isDragging.value && !props.externalDragVisual
? {
transform: `translate3d(${dragOffset.value.x}px, ${dragOffset.value.y}px, 0)`,
}
@@ -232,6 +234,7 @@ 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,8 +331,7 @@ function finishPageSwipe(event: PointerEvent): void {
z-index: 70;
inset: 0;
color: #fff;
font-family:
-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', sans-serif;
font-family: var(--sky-font-family);
}
.home-folder-backdrop {
+5 -2
View File
@@ -3,6 +3,7 @@ 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 =
@@ -70,7 +71,9 @@ const buttonSounds: HTMLAudioElement[] = []
function prepareButtonSounds(): void {
if (buttonSounds.length) return
for (let index = 0; index < 4; index += 1) {
const sound = new Audio(`${import.meta.env.BASE_URL}sounds/button.mp3`)
const sound = registerPhoneMediaElement(
new Audio(`${import.meta.env.BASE_URL}sounds/button.mp3`),
)
sound.preload = 'auto'
sound.volume = 0.55
buttonSounds.push(sound)
@@ -399,7 +402,7 @@ onBeforeUnmount(() => {
rgb(30 38 42 / 35%),
rgb(0 0 0 / 84%) 72%
);
font-family: 'Segoe UI', Arial, sans-serif;
font-family: var(--sky-font-family);
pointer-events: auto;
user-select: none;
}
@@ -5,8 +5,15 @@ 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',
@@ -19,16 +26,23 @@ 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.isOpen || notifications.current || calls.activeCall',
"'phone-device--island-expanded': dynamicIslandExpanded",
)
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)",
)
@@ -45,7 +59,44 @@ 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()"')
@@ -53,9 +104,31 @@ 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('keeps recorder state available across app changes', () => {
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', () => {
expect(recorderSource).toContain(
"message.type === 'memo:recordStateRequest'",
)
@@ -65,6 +138,15 @@ 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', () => {
@@ -75,13 +157,68 @@ 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('~ .phone-notification-provider')
expect(source).toContain("emit('expanded-change', false)")
expect(mainCss).toContain(
'.phone-device--island-expanded .phone-notification',
)
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
+89 -14
View File
@@ -14,6 +14,12 @@ import { isTrustedRootMessageSource } from '@/utils/windowMessages'
type RecordingChunk = { blob: Blob; durationMs: number }
type PendingVideo = { blob: Blob; fileName: string }
type UploadFailureDebug = {
correlationId: string
message: string
stage: string
status?: number
}
const canvasRef = ref<HTMLCanvasElement | null>(null)
const pendingVideos = new Map<string, PendingVideo>()
@@ -130,7 +136,10 @@ function cleanupRecording(): void {
try {
activeRecorder.stop()
} catch (error) {
console.error('[Camera] Could not stop the failed media recorder.', error)
console.error(
'[Camera] Could not stop the failed media recorder.',
error,
)
}
}
}
@@ -245,8 +254,7 @@ async function startRecording(data: Record<string, unknown>): Promise<void> {
const activeRecorder = recorder
removeRecorderErrorListener = bindMediaRecorderError(
activeRecorder,
() =>
generation === recordingGeneration && recorder === activeRecorder,
() => generation === recordingGeneration && recorder === activeRecorder,
(event) => {
console.error('[Camera] Media recorder failed while recording.', event)
cleanupRecording()
@@ -417,8 +425,32 @@ async function capturePhotoBlob(ready: UploadReady): Promise<Blob> {
}
}
async function failUpload(requestId: string, error: string): Promise<void> {
await nuiCall('media:failUpload', { error, requestId })
async function failUpload(
requestId: string,
error: string,
debug: UploadFailureDebug,
): Promise<void> {
console.error('[Sky Phone Media] Upload failed.', {
correlationId: debug.correlationId,
detail: debug.message,
error,
stage: debug.stage,
status: debug.status,
})
const response = await nuiCall('media:failUpload', {
correlationId: debug.correlationId,
debugMessage: debug.message,
debugStage: debug.stage,
debugStatus: debug.status,
error,
requestId,
})
if (!response.success) {
console.error('[Sky Phone Media] Could not forward upload diagnostics.', {
correlationId: debug.correlationId,
error: response.error,
})
}
}
async function uploadReady(ready: UploadReady): Promise<void> {
@@ -435,49 +467,87 @@ async function uploadReady(ready: UploadReady): Promise<void> {
blob = await capturePhotoBlob(ready)
fileName = `camera-${ready.correlationId}.${ready.photo?.Encoding ?? 'jpg'}`
}
} catch {
await failUpload(ready.requestId, 'capture_failed')
} catch (error) {
await failUpload(ready.requestId, 'capture_failed', {
correlationId: ready.correlationId,
message: error instanceof Error ? error.message : String(error),
stage: 'capture',
})
return
}
console.info('[Sky Phone Media] Capture prepared for upload.', {
bytes: blob.size,
correlationId: ready.correlationId,
mimeType: blob.type,
type: ready.mediaType,
})
const form = new FormData()
form.append('file', blob, fileName)
form.append(
'metadata',
JSON.stringify({ captureToken: ready.captureToken, source: 'sky_phone' }),
)
const controller = new AbortController()
const timeout = window.setTimeout(
() => controller.abort(),
ready.uploadTimeoutMs ?? 25000,
)
let debugStage = 'provider_request'
let debugStatus: number | undefined
try {
const response = await fetch(ready.presignedUrl, {
body: form,
method: 'POST',
signal: controller.signal,
})
debugStatus = response.status
debugStage = 'provider_response'
console.info('[Sky Phone Media] FiveManage upload responded.', {
correlationId: ready.correlationId,
status: response.status,
})
const text = await response.text()
const body = JSON.parse(text) as {
data?: { id?: string; url?: string }
error?: string
id?: string
message?: string
url?: string
}
const uploaded = body.data ?? body
if (!response.ok || !uploaded.id || !uploaded.url) {
throw new Error('upload_failed')
throw new Error(
(typeof body.error === 'string' && body.error) ||
(typeof body.message === 'string' && body.message) ||
'upload_failed',
)
}
await nuiCall('media:completeUpload', {
debugStage = 'completion_callback'
const completion = await nuiCall('media:completeUpload', {
correlationId: ready.correlationId,
remoteId: uploaded.id,
requestId: ready.requestId,
url: uploaded.url,
})
if (!completion.success) {
throw new Error(completion.error ?? 'completion_callback_failed')
}
console.info(
'[Sky Phone Media] Upload completion forwarded to the server.',
{
correlationId: ready.correlationId,
},
)
} catch (error) {
await failUpload(
ready.requestId,
error instanceof DOMException && error.name === 'AbortError'
? 'upload_timeout'
: 'upload_failed',
{
correlationId: ready.correlationId,
message: error instanceof Error ? error.message : String(error),
stage: debugStage,
status: debugStatus,
},
)
} finally {
window.clearTimeout(timeout)
@@ -523,7 +593,12 @@ function onMessage(event: MessageEvent): void {
)
}
} else if (message.type === 'media:uploadReady') {
void uploadReady(message.data as UploadReady)
const ready = message.data as UploadReady
console.info('[Sky Phone Media] Upload-ready received.', {
correlationId: ready.correlationId,
type: ready.mediaType,
})
void uploadReady(ready)
} else if (message.type === 'media:uploadResult') {
const correlationId = String(message.data?.correlationId ?? '')
if (correlationId) pendingVideos.delete(correlationId)
+11 -21
View File
@@ -54,6 +54,7 @@ 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 {
@@ -153,6 +154,7 @@ function resetRecordingData(): void {
pausedStartedAt = 0
totalPausedMs = 0
currentElapsedMs = 0
recordingDeviceImei = ''
liveLevels = Array(LIVE_LEVEL_SAMPLES).fill(0.08)
}
@@ -189,7 +191,8 @@ function failRecording(error: string, generation = recordingGeneration): void {
}
async function startRecording(data: Record<string, unknown>): Promise<void> {
if (!phone.isOpen) {
const deviceImei = phone.device?.imei
if (!phone.isOpen || !deviceImei) {
console.error('[Memos] Cannot start a recording while the phone is closed.')
return
}
@@ -217,6 +220,7 @@ 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({
@@ -340,6 +344,7 @@ 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()
@@ -355,10 +360,12 @@ 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,
}
@@ -452,14 +459,6 @@ async function uploadReady(ready: MemoUploadReady): Promise<void> {
pending.requestId = ready.requestId
const form = new FormData()
form.append('file', pending.blob, pending.fileName)
form.append(
'metadata',
JSON.stringify({
captureToken: ready.captureToken,
purpose: 'memo',
source: 'sky_phone',
}),
)
const controller = new AbortController()
pending.abortController = controller
const timeout = window.setTimeout(
@@ -569,18 +568,9 @@ function onMessage(event: MessageEvent): void {
onMounted(() => window.addEventListener('message', onMessage))
watch(
() =>
[
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()
() => phone.device?.imei ?? null,
(imei, previousImei) => {
if (previousImei && imei !== previousImei) cancelRecording()
},
)
@@ -61,16 +61,22 @@ 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'])
const selectedApps = ref<BuiltinPhoneAppId[]>(
(['banking', 'garage', 'skyride'] as const).filter((id) =>
appStore.isAvailable(id),
),
)
const setupCompleteBusy = ref(false)
const setupCompleteError = ref('')
const setupApps = (
['banking', 'garage', 'skyride', 'citymarkt', 'picstagram', 'snake'] as const
).flatMap((id) => {
const app = getPhoneApp(id)
return app ? [app] : []
})
const setupApps = computed(() =>
(
['banking', 'garage', 'skyride', 'citymarkt', 'picstagram', 'snake'] as const
).flatMap((id) => {
const app = getPhoneApp(id)
return app && appStore.isAvailable(id) ? [app] : []
}),
)
const wallpaperChoices = WALLPAPER_IDS
const progress = computed(() => `${((step.value + 1) / 10) * 100}%`)
const displayName = computed(() => {
+1 -1
View File
@@ -210,7 +210,7 @@ onBeforeUnmount(() => {
z-index: 40;
display: flex;
pointer-events: none;
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
font-family: var(--sky-font-family);
}
.radio-hud[data-horizontal='left'] {
@@ -677,9 +677,7 @@ onBeforeUnmount(() => {
backdrop-filter: blur(26px) saturate(125%);
-webkit-backdrop-filter: blur(26px) saturate(125%);
cursor: pointer;
font-family:
-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'SF Pro Text',
'Segoe UI', sans-serif;
font-family: var(--sky-font-family);
user-select: none;
-webkit-user-select: none;
touch-action: none;
+1
View File
@@ -748,6 +748,7 @@ 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) &&
@@ -0,0 +1,26 @@
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)
},
}
+6 -1
View File
@@ -1,3 +1,5 @@
import { getPhoneOutputVolume } from '@/utils/phoneAudio'
export type MemorySound = 'flip' | 'match' | 'mismatch' | 'win'
type Tone = {
@@ -46,7 +48,10 @@ 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(tone.volume, start + 0.012)
gain.gain.exponentialRampToValueAtTime(
Math.max(0.0001, tone.volume * getPhoneOutputVolume()),
start + 0.012,
)
gain.gain.exponentialRampToValueAtTime(0.0001, end)
oscillator.connect(gain)
gain.connect(audioContext.destination)
@@ -1,3 +1,5 @@
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'
@@ -28,7 +30,7 @@ function getPlayers(sound: MinesweeperSound): HTMLAudioElement[] {
if (existing) return existing
const players = Array.from({ length: 3 }, () => {
const player = new Audio(soundUrls[sound])
const player = registerPhoneMediaElement(new Audio(soundUrls[sound]))
player.preload = 'auto'
player.volume = 0.82
return player
@@ -1,3 +1,5 @@
import { getPhoneOutputVolume } from '@/utils/phoneAudio'
export type NeonDropSound =
| 'clear'
| 'drop'
@@ -48,7 +50,10 @@ function playSequence(sound: NeonDropSound): void {
oscillator.type = type
oscillator.frequency.setValueAtTime(frequency, start + delay)
gain.gain.setValueAtTime(0.0001, start + delay)
gain.gain.exponentialRampToValueAtTime(0.12, start + delay + 0.008)
gain.gain.exponentialRampToValueAtTime(
Math.max(0.0001, 0.12 * getPhoneOutputVolume()),
start + delay + 0.008,
)
gain.gain.exponentialRampToValueAtTime(0.0001, start + delay + duration)
oscillator.connect(gain)
gain.connect(context.destination)
@@ -1,3 +1,5 @@
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'
@@ -18,7 +20,7 @@ function getPlayers(sound: NumberMergeSound): HTMLAudioElement[] {
if (existing) return existing
const players = Array.from({ length: 3 }, () => {
const player = new Audio(soundUrls[sound])
const player = registerPhoneMediaElement(new Audio(soundUrls[sound]))
player.preload = 'auto'
player.volume = 0.82
return player
@@ -1,3 +1,5 @@
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'
@@ -11,7 +13,7 @@ export function playSkyFlappySound(sound: SkyFlappySound, enabled: boolean): voi
let players = pools.get(sound)
if (!players) {
players = Array.from({ length: 3 }, () => {
const player = new Audio(urls[sound])
const player = registerPhoneMediaElement(new Audio(urls[sound]))
player.preload = 'auto'
player.volume = 0.84
return player
@@ -1,3 +1,5 @@
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'
@@ -18,7 +20,7 @@ function getPlayers(sound: TowerStackSound): HTMLAudioElement[] {
if (existing) return existing
const players = Array.from({ length: 3 }, () => {
const player = new Audio(soundUrls[sound])
const player = registerPhoneMediaElement(new Audio(soundUrls[sound]))
player.preload = 'auto'
player.volume = 0.84
return player
@@ -0,0 +1,103 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const mediaConfig = readFileSync(
new URL('../../sky_phone/config/media.lua', import.meta.url),
'utf8',
)
const manifest = readFileSync(
new URL('../../sky_phone/fxmanifest.lua', import.meta.url),
'utf8',
)
const mediaProviderConfig = readFileSync(
new URL(
'../../sky_phone/source/server/media_provider_config.lua',
import.meta.url,
),
'utf8',
)
const mediaImportAdapter = readFileSync(
new URL(
'../../sky_phone/source/server/media_import/fivemanage.lua',
import.meta.url,
),
'utf8',
)
const mediaServer = readFileSync(
new URL('../../sky_phone/source/server/media.lua', import.meta.url),
'utf8',
)
const memoServer = readFileSync(
new URL('../../sky_phone/source/server/memos.lua', import.meta.url),
'utf8',
)
const mediaCapture = readFileSync(
new URL('./components/PhoneMediaCapture.vue', import.meta.url),
'utf8',
)
const memoRecorder = readFileSync(
new URL('./components/PhoneMemoRecorder.vue', import.meta.url),
'utf8',
)
describe('FiveManage server configuration contract', () => {
it('keeps the provider token in the server-only media config', () => {
expect(mediaConfig).toMatch(/FiveManage\s*=\s*{\s*ApiKey\s*=/)
expect(mediaProviderConfig).toContain(
'return trim_key(Config.Media.FiveManage.ApiKey)',
)
expect(mediaProviderConfig).not.toContain('GetConvar')
})
it('uses one resolver for Camera uploads and FiveManage imports', () => {
expect(
manifest.indexOf("'source/server/media_provider_config.lua'"),
).toBeLessThan(manifest.indexOf("'source/server/media_import.lua'"))
expect(mediaServer).toContain(
'SkyPhoneMediaProviderConfig.FiveManageApiKey()',
)
expect(mediaImportAdapter).toContain(
'SkyPhoneMediaProviderConfig.FiveManageApiKey(website.ApiKey)',
)
})
it('uses the direct FiveManage upload response flow for Camera and voice memos', () => {
expect(mediaConfig).not.toContain('VerificationRetryDelaysMs')
expect(mediaCapture).toContain("form.append('file', blob, fileName)")
expect(mediaCapture).not.toContain("form.append('path'")
expect(mediaCapture).not.toContain("form.append(\n 'metadata'")
expect(memoRecorder).toContain(
"form.append('file', pending.blob, pending.fileName)",
)
expect(memoRecorder).not.toContain("form.append('path'")
expect(memoRecorder).not.toContain("form.append(\n 'metadata'")
expect(mediaServer).toContain(
'Accepting the direct FiveManage upload response',
)
expect(mediaServer).toContain('remote_id = remote_id')
expect(mediaServer).toContain('url = uploaded_url')
expect(mediaServer).not.toContain('"HEAD"')
expect(mediaServer).not.toContain('authenticated upload-path lookup')
})
it('allowlists the FiveManage API and media hosts', () => {
expect(mediaServer).toContain('["api.fivemanage.com"] = true')
expect(mediaServer).toContain('["fmapi.net"] = true')
expect(mediaServer).toContain(
'uploaded_host:lower() ~= "r2.fivemanage.com"',
)
})
it('validates and preserves the recorded memo size before upload', () => {
expect(memoRecorder).toContain('sizeBytes: blob.size')
expect(memoServer).toContain(
'local size_bytes = tonumber(data.sizeBytes)',
)
expect(memoServer).toContain(
'size_bytes < 1 or size_bytes > Config.Memos.MaximumBytes',
)
expect(memoServer).toContain('size_bytes = memo.size_bytes')
expect(mediaServer).toContain('size = state.size_bytes')
})
})
@@ -0,0 +1,21 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const mediaCapture = readFileSync(
new URL('./components/PhoneMediaCapture.vue', import.meta.url),
'utf8',
)
const mediaServer = readFileSync(
new URL('../../sky_phone/source/server/media.lua', import.meta.url),
'utf8',
)
describe('media upload diagnostics contracts', () => {
it('forwards browser upload failure context to the server log', () => {
expect(mediaCapture).toContain("let debugStage = 'provider_request'")
expect(mediaCapture).toContain('debugStatus: debug.status')
expect(mediaServer).toContain('Client-reported upload failure')
expect(mediaServer).toContain('diagnostic_text(data.debugMessage, 240)')
})
})
+83
View File
@@ -0,0 +1,83 @@
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}")`,
)
}
})
})
+37
View File
@@ -0,0 +1,37 @@
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;/)
})
})
+129 -18
View File
@@ -3,17 +3,27 @@ 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')
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')
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'],
@@ -34,22 +44,73 @@ describe('phone inventory contracts', () => {
const phoneServer = readResourceFile('source/server/phone.lua')
expect(phoneServer).toContain(
'Bridge.Inventory.RegisterUsableItem(Config.Phone.Item, open_phone)',
'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(...)',
)
expect(phoneServer).toContain('if not usable_registered then')
})
it('auto-detects HEX and limits count-based ESX inventories to metadata-free modes', () => {
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', () => {
const inventoryBridge = readResourceFile(
'source/bridge/server/inventory.lua',
)
expect(inventoryBridge).toContain(
'GetResourceState("hex_4_inventory") == "started"',
'{ 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',
)
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', () => {
@@ -100,9 +161,15 @@ describe('phone inventory contracts', () => {
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).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')
@@ -134,10 +201,10 @@ describe('phone inventory contracts', () => {
'SkyPhoneCompatibility.RegisterExportAlias("lb-phone", "ToggleOpen"',
)
expect(phoneClient).toContain(
'local result = Bridge.Callbacks.Trigger("sky_phone:device:open-request", {})',
'local result = Bridge.Callbacks.Trigger(callback_name, {})',
)
expect(phoneClient).toMatch(
/result\.success ~= true[\s\S]*open_without_focus = false[\s\S]*return false/,
/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"',
@@ -157,7 +224,9 @@ describe('phone inventory contracts', () => {
expect(phoneClient).toContain(
'TriggerEvent("sky_phone:client:phoneToggled", false)',
)
expect(phoneBridge).toContain('TriggerEvent("lb-phone:numberChanged", phone_number)')
expect(phoneBridge).toContain(
'TriggerEvent("lb-phone:numberChanged", phone_number)',
)
expect(phoneBridge).toContain('TriggerEvent("lb-phone:phoneToggled", open)')
})
@@ -210,7 +279,9 @@ describe('phone inventory contracts', () => {
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:phoneNumberGenerated"',
)
expect(phoneBridge).toContain('TriggerEvent("lb-phone:factoryReset"')
expect(phoneBridge).toContain('TriggerEvent("lb-phone:deletedFromGallery"')
})
@@ -221,17 +292,57 @@ 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("sky_phone_toggle", locale.Controls.OpenPhone, "keyboard", Config.Phone.Keybind)',
'RegisterKeyMapping(command_name, locale.Controls.OpenPhone, "keyboard", key_name)',
)
expect(phoneClient).toContain(
'Bridge.Callbacks.Trigger("sky_phone:device:open-request", {})',
'if active_key_mapping_command == command_name then',
)
expect(phoneClient).toContain(
'request_phone_open("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')
@@ -246,7 +357,7 @@ describe('phone inventory contracts', () => {
const phoneServer = readResourceFile('source/server/phone.lua')
const migration = readResourceFile('source/server/db_migrate.lua')
expect(phoneServer).toContain('if not unique_phones then')
expect(phoneServer).toContain('if Config.Phone.Unique == false then')
expect(phoneServer).toContain('return map_character_device(source, slot)')
expect(phoneServer).toContain('FROM `sky_phone_character_devices`')
expect(phoneServer).toContain('WHERE `owner_identifier` = ?')
@@ -29,4 +29,10 @@ describe('neutral phone navigation contract', () => {
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 ?? '/')")
})
})
+6
View File
@@ -15,6 +15,12 @@ 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',
},
]
: []
+269
View File
@@ -0,0 +1,269 @@
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
},
},
})
+63 -6
View File
@@ -64,6 +64,63 @@ 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', () => {
@@ -320,7 +377,7 @@ describe('app store', () => {
const apps = useAppStoreStore()
apps.hydrate(null)
mocks.phone.saveDeviceNamespace.mockClear()
const sourceIndex = apps.homeLayout.grid.indexOf('phone')
const sourceIndex = apps.homeLayout.grid.indexOf('calculator')
expect(sourceIndex).toBeGreaterThanOrEqual(0)
expect(
@@ -329,7 +386,7 @@ describe('app store', () => {
HOME_GRID_PAGE_SIZE,
]),
).toBe(true)
expect(apps.homeLayout.grid[HOME_GRID_PAGE_SIZE]).toBe('phone')
expect(apps.homeLayout.grid[HOME_GRID_PAGE_SIZE]).toBe('calculator')
expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(1)
})
@@ -386,18 +443,18 @@ describe('app store', () => {
mocks.phone.saveDeviceNamespace.mockClear()
const notesIndex = apps.homeLayout.grid.indexOf('notes')
const clockIndex = apps.homeLayout.grid.indexOf('clock')
const settingsIndex = apps.homeLayout.grid.indexOf('settings')
const folderId = apps.createHomeFolder(
'grid',
notesIndex,
'grid',
clockIndex,
settingsIndex,
'Utilities',
)
expect(folderId).toBeTruthy()
expect(getHomeFolder(apps.homeLayout, folderId!)?.apps).toEqual([
'clock',
'settings',
'notes',
])
const mailIndex = apps.homeLayout.grid.indexOf('mail')
@@ -405,7 +462,7 @@ describe('app store', () => {
apps.moveHomeFolderApp(folderId!, 2, 0)
apps.renameHomeFolder(folderId!, 'Work')
expect(getHomeFolder(apps.homeLayout, folderId!)).toMatchObject({
apps: ['mail', 'notes', 'clock'],
apps: ['mail', 'notes', 'settings'],
name: 'Work',
})
+97 -37
View File
@@ -25,6 +25,7 @@ import {
parseHomeLayout,
reflowHomeGridForWidgetChange,
renameHomeFolder,
removeDockGridDuplicates,
removeHomeApp,
restoreHomeApp,
type HomeArea,
@@ -44,27 +45,49 @@ const pendingInstallations = new WeakMap<
Map<LaunchablePhoneAppId, PendingInstallation>
>()
function getDefaultGridIds(): LaunchablePhoneAppId[] {
return [...PHONE_APPS]
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),
)
.sort((a, b) => a.gridOrder - b.gridOrder)
.map((app) => app.id)
}
function getDefaultDockIds(): LaunchablePhoneAppId[] {
return PHONE_APPS.filter((app) => app.dockOrder !== null)
function getDefaultDockIds(
disabledApps: readonly LaunchablePhoneAppId[] = [],
): LaunchablePhoneAppId[] {
return PHONE_APPS.filter(
(app) => app.dockOrder !== null && !isAppDisabled(app.id, disabledApps),
)
.sort((a, b) => (a.dockOrder ?? 0) - (b.dockOrder ?? 0))
.map((app) => app.id)
}
function getDefaultInstalledIds(): LaunchablePhoneAppId[] {
return PHONE_APPS.filter((app) =>
isExternalPhoneApp(app)
function getDefaultInstalledIds(
disabledApps: readonly LaunchablePhoneAppId[] = [],
): LaunchablePhoneAppId[] {
return PHONE_APPS.filter((app) => {
if (app.adminOnly || isAppDisabled(app.id, disabledApps)) return false
return 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): boolean {
function isProtectedHomeApp(
appId: LaunchablePhoneAppId,
disabledApps: readonly LaunchablePhoneAppId[],
): boolean {
if (isAppDisabled(appId, disabledApps)) return false
const app = getPhoneApp(appId)
return app
? !isPhoneAppRemovable(app)
@@ -102,6 +125,7 @@ function hasUninstalledBuiltinApp(
export const useAppStoreStore = defineStore('app-store', {
state: () => ({
claimedApps: [] as LaunchablePhoneAppId[],
disabledApps: [] as LaunchablePhoneAppId[],
uninstalledApps: [] as LaunchablePhoneAppId[],
homeLayout: createDefaultHomeLayout(
getDefaultInstalledIds(),
@@ -128,6 +152,7 @@ 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,
)
@@ -148,6 +173,7 @@ 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] ||
@@ -189,6 +215,10 @@ 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(
@@ -218,8 +248,14 @@ export const useAppStoreStore = defineStore('app-store', {
installations.set(id, { deviceImei, timer, token })
pendingInstallations.set(this, installations)
},
hydrate(payload: unknown): void {
hydrate(payload: unknown, disabledApps: 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
@@ -236,13 +272,15 @@ export const useAppStoreStore = defineStore('app-store', {
layoutVersion === 5 ||
layoutVersion === 6
this.claimedApps = Array.isArray(data?.claimedApps)
? data.claimedApps.filter(
(id): id is LaunchablePhoneAppId =>
typeof id === 'string' &&
(isPhoneAppId(id) ||
(supportsPersistedExternalApps &&
isValidExternalPhoneAppId(id))),
)
? 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))
)
})
: []
this.uninstalledApps = Array.isArray(data?.uninstalledApps)
? data.uninstalledApps.filter((id): id is LaunchablePhoneAppId => {
@@ -252,25 +290,36 @@ export const useAppStoreStore = defineStore('app-store', {
})
: []
const installedIds = [
...new Set([...getDefaultInstalledIds(), ...this.claimedApps]),
].filter((id) => !this.uninstalledApps.includes(id))
...new Set([
...getDefaultInstalledIds(this.disabledApps),
...this.claimedApps,
]),
].filter(
(id) =>
!this.uninstalledApps.includes(id) && this.isAvailable(id),
)
const removedLegacyDefaults = hasUninstalledBuiltinApp(
data?.homeLayout,
installedIds,
)
const defaults = createDefaultHomeLayout(
installedIds,
getDefaultGridIds(),
getDefaultDockIds(),
getDefaultGridIds(this.disabledApps),
getDefaultDockIds(this.disabledApps),
)
this.homeLayout = parseHomeLayout(
const parsedHomeLayout = parseHomeLayout(
data?.homeLayout,
defaults,
installedIds,
false,
)
const protectedHiddenAppIds =
this.homeLayout.hidden.filter(isProtectedHomeApp)
const normalizedHomeLayout = removeDockGridDuplicates(parsedHomeLayout)
const removedDockGridDuplicates =
normalizedHomeLayout !== parsedHomeLayout
this.homeLayout = normalizedHomeLayout
const protectedHiddenAppIds = this.homeLayout.hidden.filter((appId) =>
isProtectedHomeApp(appId, this.disabledApps),
)
for (const appId of protectedHiddenAppIds) {
this.homeLayout = restoreHomeApp(this.homeLayout, appId)
}
@@ -292,6 +341,7 @@ export const useAppStoreStore = defineStore('app-store', {
this.hydrated = true
if (
protectedHiddenAppIds.length ||
removedDockGridDuplicates ||
removedLegacyDefaults ||
layoutVersion === 2 ||
layoutVersion === 3 ||
@@ -301,10 +351,15 @@ 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
@@ -312,23 +367,26 @@ export const useAppStoreStore = defineStore('app-store', {
},
reconcileCatalog(): void {
const installedIds = [
...new Set([...getDefaultInstalledIds(), ...this.claimedApps]),
].filter((id) => !this.uninstalledApps.includes(id))
...new Set([
...getDefaultInstalledIds(this.disabledApps),
...this.claimedApps,
]),
].filter(
(id) =>
!this.uninstalledApps.includes(id) && this.isAvailable(id),
)
const defaults = createDefaultHomeLayout(
installedIds,
getDefaultGridIds(),
getDefaultDockIds(),
getDefaultGridIds(this.disabledApps),
getDefaultDockIds(this.disabledApps),
)
const previous = JSON.stringify(this.homeLayout)
this.homeLayout = parseHomeLayout(
this.homeLayout,
defaults,
installedIds,
false,
this.homeLayout = removeDockGridDuplicates(
parseHomeLayout(this.homeLayout, defaults, installedIds, false),
)
for (const appId of [...this.homeLayout.hidden]) {
if (isProtectedHomeApp(appId)) {
if (isProtectedHomeApp(appId, this.disabledApps)) {
this.homeLayout = restoreHomeApp(this.homeLayout, appId)
}
}
@@ -338,6 +396,7 @@ 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()
},
@@ -473,12 +532,13 @@ export const useAppStoreStore = defineStore('app-store', {
return true
},
removeHomeApp(appId: LaunchablePhoneAppId): void {
if (isProtectedHomeApp(appId)) return
if (isProtectedHomeApp(appId, this.disabledApps)) 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()
},
+16 -4
View File
@@ -7,6 +7,11 @@ import type {
MusicTrack,
} from '@/types/music'
import { nuiCall } from '@/utils/nui'
import {
getPhoneOutputVolume,
registerPhoneMediaElement,
subscribePhoneOutputVolume,
} from '@/utils/phoneAudio'
export type YouTubePlayer = {
destroy: () => void
@@ -48,7 +53,7 @@ declare global {
}
}
const audio = new Audio()
const audio = registerPhoneMediaElement(new Audio())
audio.preload = 'auto'
const YOUTUBE_API_TIMEOUT_MS = 12000
let audioBound = false
@@ -59,6 +64,11 @@ 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 {
@@ -276,7 +286,7 @@ async function loadYouTubeTrack(videoId: string): Promise<void> {
const api = await loadYouTubeApi()
const store = useMusicStore()
if (youtubePlayer) {
youtubePlayer.setVolume(store.volume * 100)
youtubePlayer.setVolume(store.volume * getPhoneOutputVolume() * 100)
youtubePlayer.loadVideoById(videoId)
youtubePlayer.playVideo()
startYoutubeProgress()
@@ -319,7 +329,9 @@ async function loadYouTubeTrack(videoId: string): Promise<void> {
},
onReady: (event) => {
youtubePlayer = event.target
event.target.setVolume(useMusicStore().volume * 100)
event.target.setVolume(
useMusicStore().volume * getPhoneOutputVolume() * 100,
)
event.target.playVideo()
startYoutubeProgress()
resolve()
@@ -554,7 +566,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 * 100)
youtubePlayer?.setVolume(this.volume * getPhoneOutputVolume() * 100)
},
stop(): void {
stopActiveMedia()
+102 -25
View File
@@ -10,14 +10,22 @@ type LuaToken = {
}
const frontendSourceDirectory = fileURLToPath(new URL('../', import.meta.url))
const englishLocaleSource = readFileSync(
new URL('../../../sky_phone/config/locales/en.lua', import.meta.url),
'utf8',
const localeDirectory = fileURLToPath(
new URL('../../../sky_phone/config/locales/', import.meta.url),
)
const germanLocaleSource = readFileSync(
new URL('../../../sky_phone/config/locales/de.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 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',
@@ -118,6 +126,29 @@ 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
}
@@ -125,6 +156,10 @@ 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',
@@ -200,10 +235,26 @@ function collectFrontendFiles(directory: string): string[] {
}
describe('phone locale contract', () => {
const englishValues = collectLuaLocaleValues(englishLocaleSource)
const germanValues = collectLuaLocaleValues(germanLocaleSource)
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 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}`),
@@ -248,26 +299,52 @@ describe('phone locale contract', () => {
}
})
it('keeps German structurally aligned with English', () => {
const germanPaths = new Set(germanValues.keys())
it.each(translatedLocaleValues)(
'keeps %s structurally aligned with English',
(_locale, values) => {
expect([...values.keys()].sort()).toEqual([...englishPaths].sort())
},
)
expect([...germanPaths].sort()).toEqual([...englishPaths].sort())
})
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(', ')}`,
]
: []
},
)
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(', ')}`,
]
: []
})
expect(mismatches).toEqual([])
},
)
expect(mismatches).toEqual([])
})
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([])
},
)
it('keeps standard app names German and custom game names unchanged', () => {
const standardAppNames = {
+580 -39
View File
@@ -33,11 +33,13 @@ 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
@@ -638,7 +640,8 @@ const cryptoFallbackLocales = {
invalid_handle: 'Use 320 letters, numbers, dots or underscores.',
handle_taken: 'That VaultX handle is already taken.',
profile_exists: 'This character already owns a VaultX profile.',
invalid_password: 'Password must be 872 characters.',
invalid_password:
'Use 872 characters with uppercase, lowercase, a number, and a special character.',
password_mismatch: 'The passwords do not match.',
accept_terms: 'Confirm that this is a fictional in-game wallet.',
invalid_credentials: 'The password is incorrect.',
@@ -805,7 +808,384 @@ 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,
@@ -2086,7 +2466,7 @@ const defaultLocales: LocaleTree = {
to: 'To:',
connectPrivately: 'Connect privately',
newChatBody:
'Enter an exact Dark-ID or invitation code. Unknown identities require confirmation.',
"Enter another person's Dark-ID or invitation code. You can share your own ID from your profile.",
darkIdOrInvite: 'Dark-ID or invitation code',
continue: 'Continue',
contacts: 'DarkChat Contacts',
@@ -2432,46 +2812,190 @@ 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',
@@ -3036,6 +3560,8 @@ const defaultLocales: LocaleTree = {
viewRides: 'View Ride Options',
change: 'Change',
requestRide: 'Request SkyRide',
playerDriverNotice:
'SkyRide matches you with real player drivers. A driver must be online and accept your request.',
serviceMeta: '{eta} min away · {seats} seats',
distanceMeters: '{distance} m',
distanceKilometers: '{distance} km',
@@ -3106,7 +3632,8 @@ const defaultLocales: LocaleTree = {
cancelled: 'Cancelled',
},
statusBody: {
searching: 'We are matching you with a nearby driver.',
searching:
'Your request is waiting for an available player driver to accept it.',
accepted: 'Your driver is preparing to pick you up.',
driver_arriving: 'Your driver is on the way to your pickup.',
arrived: 'Your driver is waiting at the pickup point.',
@@ -4012,6 +4539,11 @@ const defaultLocales: LocaleTree = {
invalid_media_type: 'The uploaded media type is invalid.',
invalid_upload: 'The upload could not be verified.',
invalid_upload_token: 'The upload session is no longer valid.',
media_provider_failed: 'The camera upload service is unavailable.',
media_provider_rate_limited:
'The camera upload service is busy. Try again shortly.',
media_provider_unauthorized:
'The configured FiveManage API key was rejected.',
missing_config: 'Camera uploads are not configured.',
microphone_unavailable:
'Allow microphone access or mute the microphone before recording.',
@@ -5183,6 +5715,15 @@ 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
+9 -4
View File
@@ -16,10 +16,15 @@ describe('test data seeding contracts', () => {
)
})
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('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('moves an existing player SIM before attaching it to the selected phone', () => {
+187
View File
@@ -0,0 +1,187 @@
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
}
+1
View File
@@ -68,6 +68,7 @@ export type AppLaunchOrigin = {
}
type PhoneAppDefinitionBase = {
adminOnly?: boolean
category: PhoneAppCategory
dockOrder: number | null
gridOrder: number
+2 -1
View File
@@ -1,6 +1,6 @@
import type { Note } from '@/utils/notes'
import type { MemoDto } from '@/types/memos'
import type { PhoneSim } from '@/types/phone'
import type { PhoneNumberFormat, PhoneSim } from '@/types/phone'
export type DeviceDataEntry<T = unknown> = {
payload: T
@@ -50,6 +50,7 @@ export type DeviceBootstrap = {
device: PhoneDevice
memos: MemoDto[]
notes: Note[]
phoneNumberFormat: PhoneNumberFormat
player: PhonePlayerIdentity
security: DeviceSecurity
token: string
+7
View File
@@ -0,0 +1,7 @@
export type DynamicIslandActivity =
| 'call'
| 'incoming-call'
| 'music'
| 'recording'
| 'stopwatch'
| 'timer'
-1
View File
@@ -70,7 +70,6 @@ export type MediaImportResult = {
}
export type UploadReady = {
captureToken: string
correlationId: string
mediaType: MediaType
photo?: {
-1
View File
@@ -39,7 +39,6 @@ export type MemoRecordingMetadata = {
export type MemoUploadReady = {
requestId: string
correlationId: string
captureToken: string
presignedUrl: string
uploadTimeoutMs?: number
}
+5
View File
@@ -1,5 +1,10 @@
export type SimType = 'registered' | 'anonymous'
export type PhoneNumberFormat = {
groups: number[]
length: number
}
export type PhoneSim = {
id: string
number: string
@@ -0,0 +1,59 @@
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([])
})
})
+2 -3
View File
@@ -525,9 +525,8 @@
height: var(--sky-widget-label-height);
overflow: hidden;
color: var(--sky-widget-label-color, #fff);
font-family:
-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', sans-serif;
font-size: 11.5px;
font-family: var(--sky-font-family);
font-size: var(--sky-home-label-font-size);
font-weight: 500;
letter-spacing: -0.15px;
line-height: var(--sky-widget-label-height);
+20 -4
View File
@@ -1,3 +1,19 @@
@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;
@@ -26,9 +42,7 @@
--sky-font-title: 17px;
--sky-font-medium-title: 24px;
--sky-font-large-title: 34px;
--sky-font-family:
-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'SF UI Text',
'Helvetica Neue', Helvetica, Arial, sans-serif;
--sky-font-family: 'Inter', Arial, sans-serif;
--sky-transition-fast: 100ms;
--sky-transition-normal: 200ms;
--sky-ease-out: cubic-bezier(0.22, 1, 0.36, 1);
@@ -55,8 +69,10 @@
--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: 15px;
--sky-widget-label-height: var(--sky-home-label-height);
--sky-widget-radius-small: 23px;
--sky-widget-radius-medium: 25px;
--sky-widget-radius-large: 28px;
@@ -0,0 +1,60 @@
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',
)
})
})
@@ -0,0 +1,103 @@
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) },
)
}
+4 -1
View File
@@ -13,7 +13,10 @@ 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]
}),
+14 -15
View File
@@ -1,4 +1,4 @@
import { readFileSync } from 'node:fs'
import { readdirSync, readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
@@ -17,7 +17,8 @@ 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) =>
app.kind !== 'external' && app.id !== 'app-store' && !app.adminOnly,
).map((app) => app.id)
expect([...APP_STORE_PREVIEW_IMAGE_IDS].sort()).toEqual(storeAppIds.sort())
@@ -25,7 +26,8 @@ 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) =>
app.kind !== 'external' && app.id !== 'app-store' && !app.adminOnly,
).map((app) => app.id)
expect([...PREVIEWABLE_BUILTIN_APP_IDS].sort()).toEqual(
@@ -39,24 +41,21 @@ describe('App Store preview catalog', () => {
expect(visualSignatures.size).toBe(storeAppIds.length)
})
it('localizes every specialized preview in the fallback, English and German locales', () => {
it('localizes every specialized preview in the fallback and every configured locale', () => {
const localeDirectory = fileURLToPath(
new URL('../../../sky_phone/config/locales/', import.meta.url),
)
const localeSources = [
readFileSync(
fileURLToPath(new URL('../stores/phone.ts', import.meta.url)),
'utf8',
),
readFileSync(
fileURLToPath(
new URL('../../../sky_phone/config/locales/en.lua', import.meta.url),
...readdirSync(localeDirectory)
.filter((fileName) => fileName.endsWith('.lua'))
.sort()
.map((fileName) =>
readFileSync(`${localeDirectory}/${fileName}`, 'utf8'),
),
'utf8',
),
readFileSync(
fileURLToPath(
new URL('../../../sky_phone/config/locales/de.lua', import.meta.url),
),
'utf8',
),
]
for (const appId of PREVIEWABLE_BUILTIN_APP_IDS) {
+35
View File
@@ -21,6 +21,7 @@ import {
moveHomeFolderApp,
parseHomeLayout,
reflowHomeGridForWidgetChange,
removeDockGridDuplicates,
removeHomeApp,
renameHomeFolder,
restoreHomeApp,
@@ -78,6 +79,40 @@ 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(
{
+31
View File
@@ -505,6 +505,37 @@ 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,4 +1,5 @@
import { describe, expect, it } from 'vitest'
import { runInNewContext } from 'node:vm'
import type { ExternalPhoneAppDefinition } from '@/types/apps'
import {
@@ -139,6 +140,79 @@ describe('LB Phone app bridge', () => {
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 = {
+28 -1
View File
@@ -130,6 +130,25 @@ 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 ?? {};
@@ -189,6 +208,11 @@ 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') {
@@ -210,7 +234,10 @@ globalThis.addEventListener('message', (event) => {
});
applySettings(config.settings);
document.addEventListener('DOMContentLoaded', () => applySettings(globalThis.settings), { once: true });
document.addEventListener('DOMContentLoaded', () => {
prepareDocument();
applySettings(globalThis.settings);
}, { once: true });
`
function escapeAttribute(value: string): string {
+7
View File
@@ -158,6 +158,13 @@ describe('media utilities', () => {
expect(mediaErrorKey('profile_photo_required')).toBe(
'profile_photo_required',
)
expect(mediaErrorKey('media_provider_failed')).toBe('media_provider_failed')
expect(mediaErrorKey('media_provider_rate_limited')).toBe(
'media_provider_rate_limited',
)
expect(mediaErrorKey('media_provider_unauthorized')).toBe(
'media_provider_unauthorized',
)
expect(mediaErrorKey('private_provider_error')).toBe('request_failed')
})
})
+3
View File
@@ -95,6 +95,9 @@ export function mediaErrorKey(error?: string): string {
'invalid_import_url',
'invalid_upload',
'invalid_upload_token',
'media_provider_failed',
'media_provider_rate_limited',
'media_provider_unauthorized',
'missing_config',
'import_media_not_allowed',
'import_media_too_large',
+14 -1
View File
@@ -1,6 +1,10 @@
import { describe, expect, it } from 'vitest'
import { formatPhoneNumber, normalizePhoneNumber } from './phone'
import {
configurePhoneNumberFormat,
formatPhoneNumber,
normalizePhoneNumber,
} from './phone'
describe('phone numbers', () => {
it('normalizes formatted ten digit values', () => {
@@ -13,4 +17,13 @@ describe('phone numbers', () => {
expect(formatPhoneNumber('5551')).toBe('555 1')
expect(formatPhoneNumber(5551234567)).toBe('555 123 4567')
})
it('uses the server-provided number length and display groups', () => {
configurePhoneNumberFormat({ groups: [4, 3, 3], length: 10 })
expect(formatPhoneNumber('0171234567')).toBe('0171 234 567')
expect(normalizePhoneNumber('0171 234 567')).toBe('0171234567')
configurePhoneNumberFormat()
})
})
+45 -4
View File
@@ -1,12 +1,53 @@
import type { PhoneNumberFormat } from '@/types/phone'
export const PHONE_NUMBER_LENGTH = 10
const DEFAULT_PHONE_NUMBER_FORMAT: PhoneNumberFormat = {
groups: [3, 3, 4],
length: PHONE_NUMBER_LENGTH,
}
let phoneNumberFormat: PhoneNumberFormat = DEFAULT_PHONE_NUMBER_FORMAT
export function configurePhoneNumberFormat(value?: PhoneNumberFormat): void {
const length = value?.length
const groups = value?.groups
if (
typeof length !== 'number' ||
!Number.isInteger(length) ||
length < 1 ||
length > 24 ||
!Array.isArray(groups) ||
groups.length === 0 ||
groups.some((group) => !Number.isInteger(group) || group < 1)
) {
phoneNumberFormat = DEFAULT_PHONE_NUMBER_FORMAT
return
}
phoneNumberFormat = {
groups: [...groups],
length,
}
}
export function normalizePhoneNumber(value: string): string | null {
const digits = value.replace(/\D/g, '')
return digits.length === PHONE_NUMBER_LENGTH ? digits : null
return digits.length === phoneNumberFormat.length ? digits : null
}
export function formatPhoneNumber(value: string | number): string {
const digits = String(value).replace(/\D/g, '').slice(0, PHONE_NUMBER_LENGTH)
const groups = [digits.slice(0, 3), digits.slice(3, 6), digits.slice(6, 10)]
return groups.filter(Boolean).join(' ')
const digits = String(value)
.replace(/\D/g, '')
.slice(0, phoneNumberFormat.length)
const formatted: string[] = []
let offset = 0
for (const size of phoneNumberFormat.groups) {
const group = digits.slice(offset, offset + size)
if (!group) break
formatted.push(group)
offset += size
}
if (offset < digits.length) formatted.push(digits.slice(offset))
return formatted.join(' ')
}
+96
View File
@@ -0,0 +1,96 @@
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)
}
@@ -0,0 +1,174 @@
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()
})
})
@@ -0,0 +1,73 @@
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,
)
},
}
}
+5
View File
@@ -2,11 +2,16 @@ 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)
+1 -1
View File
@@ -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 = 5
export const PHONE_SCALE_STEP = 1
export const PHONE_SETUP_LAST_STEP = 9
export type AppearanceMode = (typeof APPEARANCE_MODE_IDS)[number]
@@ -140,4 +140,56 @@ 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)
})
})
+5 -1
View File
@@ -1,3 +1,5 @@
import { readPhoneViewportGeometry } from '@/utils/phoneViewportGeometry'
export type PageTurnDirection = -1 | 0 | 1
export type SpringboardEdgeTurn = {
@@ -39,7 +41,9 @@ export function readSpringboardDragMetrics(
'.springboard-page, .home-folder-panel',
)
if (!surface) return null
const bounds = surface.getBoundingClientRect()
const bounds =
readPhoneViewportGeometry(surface)?.rect(surface) ??
surface.getBoundingClientRect()
return {
layoutHeight: surface.offsetHeight,
layoutWidth: surface.offsetWidth,
+2 -1
View File
@@ -32,7 +32,7 @@ describe('phone tones', () => {
}> = []
vi.stubGlobal(
'Audio',
class {
class extends EventTarget {
currentTime = 7
loop = false
pause = pause
@@ -42,6 +42,7 @@ describe('phone tones', () => {
volume = 0
constructor(src: string) {
super()
this.src = src
players.push(this)
}
+3 -2
View File
@@ -1,3 +1,4 @@
import { registerPhoneMediaElement } from '@/utils/phoneAudio'
import type { AlarmSoundId } from '@/utils/alarms'
import type { NotificationSoundId } from '@/utils/preferences'
@@ -495,8 +496,8 @@ export function playPhoneVibration(
kind: PhoneVibrationKind,
loop: boolean,
): () => void {
const player = new Audio(
`${import.meta.env.BASE_URL}${VIBRATION_SOUND_PATHS[kind]}`,
const player = registerPhoneMediaElement(
new Audio(`${import.meta.env.BASE_URL}${VIBRATION_SOUND_PATHS[kind]}`),
)
let stopped = false
player.loop = loop
+1 -1
View File
@@ -28,7 +28,7 @@ const launchStyle = computed(() => {
<template>
<div
v-if="app"
v-if="app && !app.adminOnly"
class="app-window"
:class="{ 'app-window--citywarn': app.id === 'citywarn' }"
:style="launchStyle"
@@ -19,11 +19,31 @@ 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)
@@ -94,36 +114,99 @@ describe('Springboard page swipe contract', () => {
expect(folderIconSource).toContain(':style="dragPointerStyle"')
})
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')
it('renders the home drag visual through the unzoomed phone portal', () => {
expect(viewSource).toContain('source.cloneNode(true)')
expect(viewSource).toContain(
"'springboard--home-dragging': draggingHomeApp !== null",
'<Teleport defer to="#phone-home-drag-portal">',
)
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(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(mainCss).toMatch(
/\.springboard--home-dragging \.springboard-page--apps\s*\{[^}]*overflow:\s*visible;/s,
/\.phone-home-drag-portal\s*\{[^}]*pointer-events:\s*none;/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 < pageBounds.left')
expect(viewSource).toContain('event.clientY > pageBounds.bottom')
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('nearestSpringboardRectIndex')
expect(viewSource).toContain("queueEdgePageTurn(lastHomePointer, 'app')")
expect(viewSource).toContain("edgePageLocked = dragType === 'widget'")
+273 -35
View File
@@ -41,6 +41,11 @@ 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,
@@ -106,6 +111,8 @@ 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)
@@ -125,6 +132,11 @@ 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)),
@@ -453,7 +465,7 @@ function startWidgetDrag(id: string, event: PointerEvent): void {
).find((candidate) => candidate.dataset.widgetId === id)
if (!widget) return
pageTransitioning.value = false
const bounds = widget.getBoundingClientRect()
const bounds = homeDragViewportRect(widget)
draggingWidgetId.value = id
widgetDragGrip.value = {
x: event.clientX - bounds.left,
@@ -474,7 +486,7 @@ function updateWidgetDragPreview(event: {
)
const pageElement = grid?.closest<HTMLElement>('.springboard-page')
if (!grid || !pageElement) return
const renderedGridBounds = grid.getBoundingClientRect()
const renderedGridBounds = homeDragViewportRect(grid)
const gridStyle = getComputedStyle(grid)
const scaleX =
grid.offsetWidth > 0 ? renderedGridBounds.width / grid.offsetWidth : 1
@@ -529,7 +541,7 @@ function clearTemporaryHomePage(keepCurrentPage = false): void {
function resolveHomeEdgeTurn(event: { clientX: number }) {
const springboard = document.querySelector<HTMLElement>('.springboard')
if (!springboard) return null
const bounds = springboard.getBoundingClientRect()
const bounds = homeDragViewportRect(springboard)
const renderedLastPage = appPages.value.length
return resolveSpringboardHomeEdgeTurn(
event.clientX,
@@ -548,7 +560,7 @@ function queueEdgePageTurn(
): void {
const springboard = document.querySelector<HTMLElement>('.springboard')
if (!springboard) return
const bounds = springboard.getBoundingClientRect()
const bounds = homeDragViewportRect(springboard)
const turn =
dragType === 'app'
? resolveHomeEdgeTurn(event)
@@ -733,10 +745,166 @@ async function saveWidgetConfig(
if (configured) changePage(configured.page)
}
function startHomeDrag(area: HomeArea, index: number): void {
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 {
clearTemporaryHomePage()
lastHomePointer = null
draggingHomeApp.value = { area, index }
createHomeDragGhost(event)
}
function clearFolderHover(): void {
@@ -754,10 +922,40 @@ function queueFolderHover(event: PointerEvent): void {
return
}
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 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 area = targetElement?.dataset.homeArea as HomeArea | undefined
const targetIndex = Number(targetElement?.dataset.homeIndex)
if (
@@ -822,6 +1020,7 @@ function queueFolderHover(event: PointerEvent): void {
if (folderId) {
draggingHomeApp.value = null
lastHomePointer = null
clearHomeDragGhost()
clearTemporaryHomePage()
openedFolderId.value = folderId
}
@@ -832,6 +1031,7 @@ 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')
@@ -845,7 +1045,7 @@ async function animateHomeItemDrop(
item: HomeItem,
area: HomeArea,
index: number,
from: DOMRect,
from: PhoneViewportRect,
): Promise<void> {
await nextTick()
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
@@ -861,7 +1061,10 @@ async function animateHomeItemDrop(
)
if (!appElement) return
const target = appElement.getBoundingClientRect()
const target = homeDragViewportRect(
appElement,
readPhoneViewportGeometry(appElement),
)
const { x: offsetX, y: offsetY } = springboardViewportDeltaToLocal(
from.left - target.left,
from.top - target.top,
@@ -924,13 +1127,16 @@ function nearestGridDropTarget(event: {
`.springboard-page--apps[data-home-page="${page}"] .app-grid`,
)
const pageElement = grid?.closest<HTMLElement>('.springboard-page')
if (!grid || !pageElement) return null
const pageBounds = pageElement.getBoundingClientRect()
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 (
event.clientX < pageBounds.left ||
event.clientX > pageBounds.right ||
event.clientY < pageBounds.top ||
event.clientY > pageBounds.bottom
event.clientX < springboardBounds.left ||
event.clientX > springboardBounds.right ||
event.clientY < springboardBounds.top ||
event.clientY > springboardBounds.bottom
) {
return null
}
@@ -940,7 +1146,15 @@ function nearestGridDropTarget(event: {
const closestIndex = nearestSpringboardRectIndex(
event.clientX,
event.clientY,
slots.map((slot) => slot.getBoundingClientRect()),
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,
}
}),
)
const closest = closestIndex === null ? null : slots[closestIndex]
const targetOffset = Number(closest?.dataset.homeTargetOffset)
@@ -953,7 +1167,8 @@ function nearestDockDropTarget(event: {
}): HTMLElement | null {
const dock = document.querySelector<HTMLElement>('.app-dock')
if (!dock) return null
const dockBounds = dock.getBoundingClientRect()
const geometry = homeDragGeometry ?? readPhoneViewportGeometry(dock)
const dockBounds = homeDragViewportRect(dock, geometry)
if (
event.clientX < dockBounds.left ||
event.clientX > dockBounds.right ||
@@ -970,7 +1185,7 @@ function nearestDockDropTarget(event: {
const closestIndex = nearestSpringboardRectIndex(
event.clientX,
event.clientY,
slots.map((slot) => slot.getBoundingClientRect()),
slots.map((slot) => homeDragViewportRect(slot, geometry)),
)
return closestIndex === null ? null : slots[closestIndex]
}
@@ -979,12 +1194,14 @@ function finishHomeDrag(event: PointerEvent): void {
clearEdgePageTurn()
clearFolderHover()
const dragged = draggingHomeApp.value
if (!dragged) return
if (!dragged) {
clearHomeDragGhost()
return
}
updateHomeDragGhost(event)
const draggedItem = appStore.homeLayout[dragged.area][dragged.index]
const draggedElement = document.querySelector<HTMLElement>(
`[data-home-area="${dragged.area}"][data-home-index="${dragged.index}"]`,
)
const dropOrigin = draggedElement?.getBoundingClientRect()
const dropGhost = homeDragGhost
const dropOrigin = homeDragPreviewBounds ? { ...homeDragPreviewBounds } : null
const dockTarget = nearestDockDropTarget(event)
const gridTarget = dockTarget ? null : nearestGridDropTarget(event)
let dropArea = dragged.area
@@ -1022,7 +1239,14 @@ function finishHomeDrag(event: PointerEvent): void {
lastHomePointer = null
clearTemporaryHomePage(moved && gridTarget !== null)
if (moved && draggedItem && dropOrigin) {
void animateHomeItemDrop(draggedItem, dropArea, dropIndex, dropOrigin)
void animateHomeItemDrop(
draggedItem,
dropArea,
dropIndex,
dropOrigin,
).finally(() => clearHomeDragGhost(dropGhost))
} else {
clearHomeDragGhost()
}
}
@@ -1031,6 +1255,7 @@ function stopHomeDrag(): void {
clearFolderHover()
draggingHomeApp.value = null
lastHomePointer = null
clearHomeDragGhost()
clearTemporaryHomePage()
}
@@ -1043,7 +1268,9 @@ function reorderHomeApp(
const appElement = document.querySelector<HTMLElement>(
`[data-home-area="${area}"][data-home-index="${sourceIndex}"]`,
)
const moveOrigin = appElement?.getBoundingClientRect()
const moveOrigin = appElement
? homeDragViewportRect(appElement, readPhoneViewportGeometry(appElement))
: null
const targetIndex = homeKeyboardTarget(
appStore.homeLayout,
area,
@@ -1131,7 +1358,10 @@ function extractOpenedFolderApp(
`.home-folder-panel [data-folder-app-index="${sourceIndex}"]`,
)
?.closest<HTMLElement>('.app-icon-item')
const sourceBounds = sourceElement?.getBoundingClientRect()
const geometry = readPhoneViewportGeometry(sourceElement ?? null)
const sourceBounds = sourceElement
? homeDragViewportRect(sourceElement, geometry)
: null
const candidates = Array.from(
document.querySelectorAll<HTMLElement>('[data-home-index][data-home-area]'),
).filter((element) => {
@@ -1145,8 +1375,8 @@ function extractOpenedFolderApp(
})
const target = candidates.reduce<HTMLElement | null>((closest, candidate) => {
if (!closest) return candidate
const bounds = candidate.getBoundingClientRect()
const closestBounds = closest.getBoundingClientRect()
const bounds = homeDragViewportRect(candidate, geometry)
const closestBounds = homeDragViewportRect(closest, geometry)
const distance = Math.hypot(
event.clientX - (bounds.left + bounds.width / 2),
event.clientY - (bounds.top + bounds.height / 2),
@@ -1212,6 +1442,7 @@ onBeforeUnmount(() => {
pendingWidgetPointer = null
lastWidgetPointer = null
lastHomePointer = null
clearHomeDragGhost()
temporaryHomePage.value = null
draggingHomeApp.value = null
draggingWidgetId.value = null
@@ -1227,7 +1458,6 @@ onBeforeUnmount(() => {
'springboard--dragging': dragging,
'springboard--editing': editMode,
'springboard--folder-open': folderOverlayVisible,
'springboard--home-dragging': draggingHomeApp !== null,
'springboard--widget-dragging': draggingWidgetId !== null,
},
]"
@@ -1303,6 +1533,7 @@ 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}`,
@@ -1310,7 +1541,7 @@ onBeforeUnmount(() => {
@dragcancel="stopHomeDrag"
@dragend="finishHomeDrag"
@dragmove="moveHomeDrag"
@dragstart="startHomeDrag('grid', cell.sourceIndex)"
@dragstart="startHomeDrag('grid', cell.sourceIndex, $event)"
@edit="enterEditMode"
@remove="removeHomeApp(cell.app.id)"
@reorder="reorderHomeApp('grid', cell.sourceIndex, $event)"
@@ -1325,6 +1556,7 @@ 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':
@@ -1333,7 +1565,7 @@ onBeforeUnmount(() => {
@dragcancel="stopHomeDrag"
@dragend="finishHomeDrag"
@dragmove="moveHomeDrag"
@dragstart="startHomeDrag('grid', cell.sourceIndex)"
@dragstart="startHomeDrag('grid', cell.sourceIndex, $event)"
@edit="enterEditMode"
@open="openFolder(cell.folder.id)"
@reorder="reorderHomeApp('grid', cell.sourceIndex, $event)"
@@ -1484,6 +1716,10 @@ 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"
@@ -1530,6 +1766,7 @@ 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':
@@ -1538,7 +1775,7 @@ onBeforeUnmount(() => {
@dragcancel="stopHomeDrag"
@dragend="finishHomeDrag"
@dragmove="moveHomeDrag"
@dragstart="startHomeDrag('dock', slot.index)"
@dragstart="startHomeDrag('dock', slot.index, $event)"
@edit="enterEditMode"
@remove="removeHomeApp(slot.app.id)"
@reorder="reorderHomeApp('dock', slot.index, $event)"
@@ -1552,6 +1789,7 @@ 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="{
@@ -1561,7 +1799,7 @@ onBeforeUnmount(() => {
@dragcancel="stopHomeDrag"
@dragend="finishHomeDrag"
@dragmove="moveHomeDrag"
@dragstart="startHomeDrag('dock', slot.index)"
@dragstart="startHomeDrag('dock', slot.index, $event)"
@edit="enterEditMode"
@open="openFolder(slot.folder.id)"
@reorder="reorderHomeApp('dock', slot.index, $event)"
@@ -81,6 +81,7 @@ 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"',
)
+7 -2
View File
@@ -119,9 +119,10 @@ const downloadDateDescription = computed(() =>
)
const catalog = computed(() =>
PHONE_APPS.filter((app): app is LaunchablePhoneAppDefinition => {
if (!isLaunchablePhoneApp(app) || app.id === 'app-store') {
if (!isLaunchablePhoneApp(app) || app.id === 'app-store' || app.adminOnly) {
return false
}
if (!appStore.isAvailable(app.id)) return false
return !appStore.isInstalled(app.id)
}).sort((a, b) => a.gridOrder - b.gridOrder),
@@ -129,7 +130,9 @@ const catalog = computed(() =>
const installedApps = computed(() =>
PHONE_APPS.filter(
(app): app is LaunchablePhoneAppDefinition =>
isLaunchablePhoneApp(app) && appStore.isInstalled(app.id),
isLaunchablePhoneApp(app) &&
appStore.isAvailable(app.id) &&
appStore.isInstalled(app.id),
).sort((a, b) => a.gridOrder - b.gridOrder),
)
const installedGameCount = computed(
@@ -140,8 +143,10 @@ 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),
),
+1 -1
View File
@@ -791,7 +791,7 @@ onBeforeUnmount(() => {
flex-direction: column;
color: #f6f7f9;
background: #07090c;
font-family: Inter, system-ui, sans-serif;
font-family: var(--sky-font-family);
}
.billing-app--light {
--billing-border: rgb(15 23 42 / 10%);
+1 -7
View File
@@ -1156,13 +1156,7 @@ onMounted(async () => {
padding: 47px 0 24px;
background: var(--bg);
color: var(--label);
font-family:
Inter,
-apple-system,
BlinkMacSystemFont,
'SF Pro Display',
system-ui,
sans-serif;
font-family: var(--sky-font-family);
}
.calendar--light {
+26 -1
View File
@@ -134,10 +134,30 @@ async function requestPhoto(): Promise<void> {
window.setTimeout(() => void completeDevelopmentCapture(id, 'photo'), 700)
return
}
await nuiCall('media:requestUpload', {
console.info('[Sky Phone Media] Camera requested a photo upload.', {
correlationId: id,
})
const response = await nuiCall('media:requestUpload', {
correlationId: id,
mediaType: 'photo',
})
if (!response.success) {
console.error('[Sky Phone Media] Photo upload request was rejected.', {
correlationId: id,
error: response.error,
})
window.postMessage(
{
data: {
correlationId: id,
error: response.error ?? 'request_failed',
success: false,
},
type: 'media:uploadResult',
},
'*',
)
}
}
function startRecording(): void {
@@ -374,6 +394,11 @@ function onMessage(event: MessageEvent): void {
} else if (message.type === 'media:uploadResult') {
const result = message.data as UploadResult
if (!result?.correlationId) return
console.info('[Sky Phone Media] Camera received an upload result.', {
correlationId: result.correlationId,
error: result.error,
success: result.success,
})
savingVideo.value = false
if (result.success && result.media) {
latestMedia.value = result.media
+1 -1
View File
@@ -2195,7 +2195,7 @@ onMounted(async () => {
overflow: hidden;
background: #151613;
color: #f8f8f4;
font-family: Inter, system-ui, sans-serif;
font-family: var(--sky-font-family);
}
.citymarkt--light {
--ink: #fff;
@@ -119,6 +119,8 @@ describe('VaultX crypto app contracts', () => {
expect(source).not.toContain('class="auth-panel__heading"')
expect(source).not.toContain('class="auth-action-dock"')
expect(source).not.toContain('class="password-rules"')
expect(source).toContain('class="auth-password-hint"')
expect(source).toContain("t('auth.ruleSpecial')")
expect(source).toMatch(
/\.auth-shell\s*\{[^}]*width:\s*100%;[^}]*max-width:\s*338px;/s,
)
@@ -367,6 +369,22 @@ describe('VaultX crypto app contracts', () => {
expect(testServer).toContain('function advanceCryptoCycle(')
})
it('does not keep market and settlement workers active while disabled', () => {
expect(server).toContain('local function start_crypto_schedulers()')
expect(server).toMatch(
/start_crypto_schedulers\(\)[\s\S]*?if Config\.Crypto\.Enabled ~= true then\s+return/,
)
expect(server).toContain(
'while scheduler_generation == generation and Config.Crypto.Enabled == true do',
)
expect(server).toContain(
'AddEventHandler("sky_phone:configurator:serverUpdated", refresh_crypto_runtime)',
)
expect(server).not.toMatch(
/CreateThread\(function\(\)\s+while true do\s+Wait\(5 \* 60 \* 1000\)/,
)
})
it('stores cash in price-scale minor units throughout the ledger', () => {
expect(server).toContain(
'local ledger_amount = amount * Config.Crypto.PriceScale',
+10
View File
@@ -824,6 +824,10 @@ onUnmounted(() => {
:size="18"
/></button></template
></SkyField>
<p v-if="authMode === 'register'" class="auth-password-hint">
{{ t('auth.ruleLength') }} · {{ t('auth.ruleMixed') }} ·
{{ t('auth.ruleNumber') }} · {{ t('auth.ruleSpecial') }}
</p>
<p v-if="formError" class="error">{{ formError }}</p>
<SkyButton block large class="auth-submit" type="submit">{{
t(
@@ -2060,6 +2064,12 @@ onUnmounted(() => {
.auth-form {
gap: 12px;
}
.auth-password-hint {
margin: -3px 4px 0;
color: rgba(255, 255, 255, 0.58);
font-size: 11px;
line-height: 15px;
}
.auth-account {
display: grid;
grid-template-columns: 40px minmax(0, 1fr) 18px;
@@ -6,6 +6,14 @@ const source = readFileSync(
new URL('./DarkChatApp.vue', import.meta.url),
'utf8',
)
const server = readFileSync(
new URL('../../../../sky_phone/source/server/darkchat.lua', import.meta.url),
'utf8',
)
const config = readFileSync(
new URL('../../../../sky_phone/config/config.lua', import.meta.url),
'utf8',
)
describe('DarkChatApp Sky UI contract', () => {
it('uses first-party Sky UI without direct Konsta markup', () => {
@@ -37,10 +45,17 @@ describe('DarkChatApp Sky UI contract', () => {
expect(newChat).toContain('class="dc-recipient-field"')
expect(newChat).toContain('layout="inline"')
expect(newChat).toContain('class="dc-new-chat-contacts"')
expect(newChat).toContain("{{ t('newChatBody') }}")
expect(newChat).toContain("{{ phone.t('Common.cancel') }}")
expect(newChat).not.toContain('class="dc-hero"')
})
it('rejects the current profile identifiers before opening confirmation', () => {
expect(source).toContain('darkchat.profile?.darkId')
expect(source).toContain('darkchat.profile?.inviteCode')
expect(source).toContain("showToast(errorText('self_chat'))")
})
it('keeps the search visually compact without shrinking its wrapper', () => {
expect(source).toMatch(
/\.dc-search :deep\(\.sky-searchbar__control\),[\s\S]*?height:\s*38px;[\s\S]*?min-height:\s*38px;/,
@@ -123,4 +138,13 @@ describe('DarkChatApp Sky UI contract', () => {
/\.dc-inbox-navbar :deep\(\.sky-navbar__title-container > div\)\s*\{[^}]*translateY\(-14px\)/s,
)
})
it('cleans expired messages in bounded indexed batches', () => {
expect(config).toContain('CleanupBatchSize = 250')
expect(server).toContain('Config.DarkChat.CleanupBatchSize')
expect(server).toMatch(
/DELETE FROM `sky_phone_darkchat_messages`[\s\S]*?ORDER BY `expires_at`, `id`[\s\S]*?LIMIT \?/,
)
expect(server).toContain(']], { batch_size })')
})
})
+22
View File
@@ -427,6 +427,20 @@ function closeReport(): void {
function requestStart(value = identifier.value): void {
const clean = value.trim()
if (!clean) return
const ownIdentifiers = [
darkchat.profile?.darkId,
darkchat.profile?.inviteCode,
]
if (
ownIdentifiers.some(
(ownIdentifier) =>
ownIdentifier?.toLocaleLowerCase(phone.lang) ===
clean.toLocaleLowerCase(phone.lang),
)
) {
showToast(errorText('self_chat'))
return
}
pendingIdentifier.value = clean
safetyOpen.value = true
}
@@ -1262,6 +1276,13 @@ onBeforeUnmount(() => {
padding-top: 2px;
}
.dc-new-chat-intro {
margin: 2px 2px 12px;
color: var(--dc-muted);
font-size: 13px;
line-height: 18px;
}
.dc-recipient-field,
.dc-new-chat-contacts {
margin: 0 calc(var(--sky-page-gutter) * -1) 12px;
@@ -2071,6 +2092,7 @@ onBeforeUnmount(() => {
</template>
</SkyNavbar>
<SkyScrollArea padded class="dc-new-chat-scroll">
<p class="dc-new-chat-intro">{{ t('newChatBody') }}</p>
<SkyList class="dc-recipient-field" density="compact" flush>
<SkyField
:label="t('to')"
+1 -1
View File
@@ -3586,7 +3586,7 @@ onMounted(async () => {
overflow: hidden;
background: #12171b !important;
color: #f7f8f4;
font-family: Inter, system-ui, sans-serif;
font-family: var(--sky-font-family);
}
.feather-app--active.feather-app--light {
--feather-panel: #f0f1ec;
+18 -4
View File
@@ -77,6 +77,10 @@ import {
SkyToggle,
} from '@/ui'
import { nuiCall } from '@/utils/nui'
import {
getPhoneOutputVolume,
subscribePhoneOutputVolume,
} from '@/utils/phoneAudio'
type Tab = 'feed' | 'discover' | 'create' | 'activity' | 'profile'
type AuthMode = 'login' | 'register'
@@ -194,6 +198,8 @@ let flipTokYoutubePlayer: YouTubePlayer | null = null
let flipTokYoutubeApi: YouTubeApi | null = null
let flipTokYoutubeOwner = ''
let flipTokYoutubeVideoId = ''
let flipTokYoutubeVolume = 0
let removePhoneOutputVolumeListener: (() => void) | undefined
let observer: IntersectionObserver | null = null
let videoClickTimer: number | null = null
let likePulseTimer: number | null = null
@@ -786,6 +792,7 @@ async function playFlipTokYoutube(
seconds = 0,
): Promise<void> {
flipTokYoutubeOwner = owner
flipTokYoutubeVolume = volume
try {
const api = await loadYouTubeApi()
flipTokYoutubeApi = api
@@ -794,7 +801,7 @@ async function playFlipTokYoutube(
flipTokYoutubeVideoId = videoId
flipTokYoutubePlayer.loadVideoById(videoId)
}
flipTokYoutubePlayer.setVolume(volume)
flipTokYoutubePlayer.setVolume(volume * getPhoneOutputVolume())
flipTokYoutubePlayer.seekTo(Math.max(0, seconds), true)
flipTokYoutubePlayer.playVideo()
if (owner === 'composer') customMusicLoadFailed.value = false
@@ -835,7 +842,7 @@ async function playFlipTokYoutube(
},
onReady: (event) => {
flipTokYoutubePlayer = event.target
event.target.setVolume(volume)
event.target.setVolume(volume * getPhoneOutputVolume())
event.target.seekTo(Math.max(0, seconds), true)
event.target.playVideo()
if (owner === 'composer') customMusicLoadFailed.value = false
@@ -1627,7 +1634,10 @@ watch(originalVolume, (value) => {
watch(musicVolume, (value) => {
if (composerMusic.value) composerMusic.value.volume = value / 100
if (flipTokYoutubeOwner === 'composer') flipTokYoutubePlayer?.setVolume(value)
if (flipTokYoutubeOwner === 'composer') {
flipTokYoutubeVolume = value
flipTokYoutubePlayer?.setVolume(value * getPhoneOutputVolume())
}
})
watch(
@@ -1655,6 +1665,9 @@ watch(
)
onMounted(async () => {
removePhoneOutputVolumeListener = subscribePhoneOutputVolume((volume) => {
flipTokYoutubePlayer?.setVolume(flipTokYoutubeVolume * volume)
})
const profileSelection = messageMedia.consumeMany<ProfileMediaContext>(
'fliptok:profile-avatar',
)
@@ -1731,6 +1744,7 @@ onMounted(async () => {
})
onBeforeUnmount(() => {
removePhoneOutputVolumeListener?.()
observer?.disconnect()
if (videoClickTimer !== null) window.clearTimeout(videoClickTimer)
if (likePulseTimer !== null) window.clearTimeout(likePulseTimer)
@@ -3381,7 +3395,7 @@ onBeforeUnmount(() => {
overflow: hidden;
background: #000;
color: #fff;
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', sans-serif;
font-family: var(--sky-font-family);
}
.state,
.light-empty,
+1 -1
View File
@@ -1526,7 +1526,7 @@ onMounted(async () => {
overflow: hidden;
background: #12171b;
color: #f7f7f2;
font-family: Inter, system-ui, sans-serif;
font-family: var(--sky-font-family);
}
.pages--light {
--yellow: #8a6500;
+1 -1
View File
@@ -279,7 +279,7 @@ onBeforeUnmount(() => {
background:
radial-gradient(circle at 88% 8%, rgb(255 255 255 / 72%), transparent 28%),
linear-gradient(155deg, #f4efff 0%, #e7ddff 55%, #d9cdf7 100%);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-family: var(--sky-font-family);
user-select: none;
}
+1 -1
View File
@@ -420,7 +420,7 @@ onBeforeUnmount(() => {
background:
radial-gradient(circle at 88% 7%, rgb(108 231 218 / 32%), transparent 30%),
linear-gradient(155deg, #effcf7 0%, #cfeee5 52%, #abdcd7 100%);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-family: var(--sky-font-family);
touch-action: manipulation;
user-select: none;
}
+1 -1
View File
@@ -386,7 +386,7 @@ onBeforeUnmount(() => {
padding: 52px 14px 25px;
color: #f5fbff;
background: radial-gradient(circle at 50% 0, #253163, #090c22 58%, #050715);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-family: var(--sky-font-family);
user-select: none;
touch-action: manipulation;
}
+1 -1
View File
@@ -335,7 +335,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
background:
radial-gradient(circle at 86% 8%, rgb(255 192 94 / 28%), transparent 29%),
linear-gradient(155deg, #fff3dc 0%, #f3d8b7 55%, #e8b98d 100%);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-family: var(--sky-font-family);
touch-action: none;
user-select: none;
}
@@ -16,6 +16,12 @@ describe('PhoneApp EasyShare contract', () => {
it('uses the shared full-width Sky tab bar for phone sections', () => {
expect(source).toContain('<sky-tab-bar')
expect(source).toContain('<sky-tab-button')
expect(source).toContain(
'calc(var(--sky-tabbar-height) + var(--sky-safe-area-bottom) + 16px);',
)
expect(source).not.toMatch(
/\.phone-contacts\s*\{[^}]*padding-bottom:\s*20px;/s,
)
})
it('uses shared interactive liquid glass surfaces for phone controls', () => {

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