mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-02 03:08:54 +00:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3314fa5355 | |||
| 637168c616 | |||
| 39fe171961 | |||
| c2fe8b5e23 | |||
| a24cda989a | |||
| b9f8930f00 | |||
| 5efd806e43 | |||
| 65a0846eea | |||
| 0a0053c3af | |||
| 2f0faa8206 | |||
| 76b0378649 | |||
| 07b348a01a | |||
| 9abf53b140 |
@@ -17,8 +17,6 @@ for (const requiredFragment of [
|
||||
"fx_version 'cerulean'",
|
||||
"node_version '22'",
|
||||
"use_experimental_fxv2_oal 'yes'",
|
||||
"'source/server/nui_build_check.lua'",
|
||||
"'source/html/sounds/**'",
|
||||
"ui_page 'source/html/index.html'",
|
||||
]) {
|
||||
if (!manifest.includes(requiredFragment)) {
|
||||
|
||||
@@ -119,5 +119,5 @@ jobs:
|
||||
"${archive}.sha256" \
|
||||
--verify-tag \
|
||||
--generate-notes \
|
||||
--title "$GITHUB_REF_NAME Beta"
|
||||
--title "Sky Phone $GITHUB_REF_NAME"
|
||||
fi
|
||||
|
||||
@@ -92,23 +92,6 @@ as project instructions, not as optional documentation.
|
||||
proof.
|
||||
- Do not hand-edit generated frontend output.
|
||||
|
||||
## Phone Configurator parity (mandatory)
|
||||
|
||||
- Every new, changed, renamed, moved, or removed configurable value in
|
||||
`sky_phone/config/config.lua` or `sky_phone/config/media.lua` **must** be
|
||||
reflected in the in-game Phone Configurator in the same change. Config-only
|
||||
changes without matching Configurator support are incomplete and must not be
|
||||
committed or merged.
|
||||
- Keep the Configurator's runtime schema, defaults, value types, fixed versus
|
||||
extensible collection rules, labels, descriptions, English and German
|
||||
locales, SQL persistence, validation, and save/load roundtrip aligned with
|
||||
the Lua configuration.
|
||||
- The Configurator must remain complete when file-based configuration is
|
||||
disabled. Do not introduce a setting that can only be managed by editing Lua
|
||||
after `Config.PhoneConfigurator.Enabled` is enabled.
|
||||
- Add or update contract/fixture coverage so configuration parity regressions
|
||||
fail automated checks.
|
||||
|
||||
## Working method and verification
|
||||
|
||||
1. Trace the relevant client, server, NUI, configuration, persistence, and event
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
<p align="center">
|
||||
<a href="https://www.sky-systems.net/shop/phone#live-demo"><strong>Live demo</strong></a>
|
||||
•
|
||||
<a href="https://github.com/sky-systems/sky_phone/releases/latest"><strong>Download for free</strong></a>
|
||||
<a href="https://github.com/sky-systems/sky_phone"><strong>Download for free</strong></a>
|
||||
•
|
||||
<a href="https://discord.gg/sky-systems"><strong>Discord support</strong></a>
|
||||
</p>
|
||||
@@ -44,7 +44,7 @@ Sky Phone is a **free and open-source FiveM phone script** built to give serious
|
||||
|
||||
This is not a cut-down free alternative. Sky Phone includes the core experience server owners and players expect from a leading paid FiveM phone, plus full source access, no purchase price, no feature paywalls, and no forced ecosystem lock-in.
|
||||
|
||||
The production frontend is included in the published release package, so a normal server installation does not require Node.js or pnpm. GitHub's automatically generated source archives do not contain that build.
|
||||
The production frontend is included, so a normal server installation does not require Node.js or pnpm.
|
||||
|
||||
## Why Sky Phone stands out
|
||||
|
||||
@@ -86,10 +86,10 @@ Sky Phone is built to be the **free FiveM phone you can choose without accepting
|
||||
| Layer | Supported options |
|
||||
| --- | --- |
|
||||
| **Frameworks** | ESX Legacy, QBCore, Qbox |
|
||||
| **Inventories** | ak47_inventory, codem-inventory, core_inventory, jaksam_inventory, jpr-inventory, lj-inventory, mf-inventory, one_inventory, origen_inventory, ox_inventory, ps-inventory, qb-inventory, qs-inventory, smx-inventory, tgiann-inventory, hex_4_inventory, and native ESX inventory |
|
||||
| **Inventories** | ox_inventory, qb-inventory, lj-inventory, qs-inventory, codem-inventory, core_inventory, mf-inventory, smx-inventory, hex_4_inventory, and native ESX inventory |
|
||||
| **Calls** | YACA, PMA Voice, SaltyChat |
|
||||
| **Radio** | YACA, PMA Voice, SaltyChat |
|
||||
| **Housing** | RTX Housing, Quasar Housing, TGIANN House, VMS Housing, RX Housing, NoLag Properties, SN Properties, ESX Property, qbx_properties |
|
||||
| **Housing** | RTX Housing, Quasar Housing, VMS Housing, RX Housing, NoLag Properties, SN Properties, ESX Property, qbx_properties |
|
||||
| **Garages** | Built-in/custom data and a broad set of popular garage providers configured through the bridge |
|
||||
| **Custom app contracts** | Sky Phone, LB Phone, 17Movement, High Phone, Quasar Smartphone, YSeries |
|
||||
| **Languages** | English, German |
|
||||
@@ -121,33 +121,20 @@ Sky Phone is built to be the **free FiveM phone you can choose without accepting
|
||||
| **Framework** | ESX Legacy (`es_extended`), QBCore (`qb-core`), or Qbox (`qbx_core`) |
|
||||
| **Inventory** | Choose one supported adapter from the table below |
|
||||
|
||||
| Inventory (configuration value) | Metadata support | Unique Phones | Notes |
|
||||
| Inventory | Metadata support | Unique Phones | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| `jaksam_inventory` (`jaksam`) | Yes | Yes | Direct per-slot metadata; usable items are registered through jaksam_inventory |
|
||||
| `qs-inventory` (`qs`) | Yes | Yes | Full per-slot metadata |
|
||||
| `ps-inventory` (`ps`) | Yes | Yes | QBCore only; uses item `info` metadata |
|
||||
| `codem-inventory` (`codem`) | Yes | Yes | Full per-slot metadata |
|
||||
| `tgiann-inventory` (`tgiann`) | Yes | Yes | Per-slot metadata; item definitions must enable `hasMetadata` |
|
||||
| `core_inventory` (`core`) | Yes | Yes | Full per-slot metadata |
|
||||
| `jpr-inventory` (`jpr`) | Yes | Yes | QBCore only; uses item `info` metadata |
|
||||
| `origen_inventory` (`origen`) | Yes | Yes | Full per-slot metadata |
|
||||
| `ak47_inventory` (`ak47`) | Yes | Yes | Uses per-slot item `info` metadata |
|
||||
| `one_inventory` (`one`) | Yes | Yes | Full per-slot metadata |
|
||||
| `ox_inventory` (`ox`) | Yes | Yes | Full per-item phone and physical SIM metadata |
|
||||
| `mf-inventory` (`mf`) | Yes | Yes | ESX only |
|
||||
| `smx-inventory` (`smx`) | Yes | Yes | ESX only; one metadata record per configured item name through the player metadata bridge |
|
||||
| `lj-inventory` (`lj`) | Yes | Yes | QBCore inventory with item `info` metadata |
|
||||
| `qb-inventory` (`qb`) | Yes | Yes | Uses item `info` metadata |
|
||||
| `hex_4_inventory` (`hex`) | **No metadata support** | **No, Unique Phones are not possible** | ESX only; Sky Phone automatically disables unique phones and physical SIM cards |
|
||||
| Native ESX inventory (`esx`) | **No metadata support** | **No, Unique Phones are not possible** | Count-based items; Sky Phone automatically disables unique phones and physical SIM cards |
|
||||
| `ox_inventory` | Yes | Yes | Full per-item phone and physical SIM metadata |
|
||||
| `qb-inventory` | Yes | Yes | Uses item `info` metadata |
|
||||
| `lj-inventory` | Yes | Yes | QBCore inventory with item `info` metadata |
|
||||
| `qs-inventory` | Yes | Yes | Full per-slot metadata |
|
||||
| `codem-inventory` | Yes | Yes | Full per-slot metadata |
|
||||
| `core_inventory` | Yes | Yes | Full per-slot metadata |
|
||||
| `mf-inventory` | Yes | Yes | Supported with ESX |
|
||||
| `smx-inventory` | Yes | Yes | Supported with ESX through the player metadata bridge |
|
||||
| `hex_4_inventory` | **No metadata support** | **No, Unique Phones are not possible** | ESX only; set `Config.Phone.Unique = false` and `Config.Sim.Enabled = false` |
|
||||
| Native ESX inventory | **No metadata support** | **No, Unique Phones are not possible** | Count-based items; set `Config.Phone.Unique = false` and `Config.Sim.Enabled = false` |
|
||||
|
||||
`hex_4_inventory` and native ESX inventory cannot persist per-item metadata. Sky Phone therefore forces `Config.Phone.Unique` and `Config.Sim.Enabled` to `false` at runtime whenever either adapter is active.
|
||||
|
||||
`Config.Bridge.Inventory = "auto"` detects framework-compatible adapters in the table order. This deliberately matches the Sky inventory priority so a dedicated inventory is selected before a compatibility resource it may run beside. You may configure either the short value shown in parentheses or the exact resource name.
|
||||
|
||||
The adapters shared with `sky_base` are implemented locally inside Sky Phone. Installing or starting `sky_base` is not required; Sky Phone remains a standalone resource.
|
||||
|
||||
For configuration parity with `sky_base`, `qb-inv` is accepted as an alias for `qb`, while `qbox` selects the Qbox-native `ox_inventory` adapter.
|
||||
`hex_4_inventory` and native ESX inventory cannot persist per-item metadata. Unique Phones and physical SIM cards are therefore unavailable with these adapters.
|
||||
|
||||
### Voice
|
||||
|
||||
@@ -173,8 +160,8 @@ Start the selected voice resource before Sky Phone.
|
||||
|
||||
## Quick installation
|
||||
|
||||
1. Download and extract the latest published [Sky Phone release](https://github.com/sky-systems/sky_phone/releases/latest). Do not use GitHub's automatically generated "Source code" archives for a server installation because they do not contain the built frontend.
|
||||
2. Copy the included resource into your FiveM resources directory and keep its folder name `sky_phone`.
|
||||
1. Copy the resource into your FiveM resources directory.
|
||||
2. Keep the resource folder name `sky_phone`.
|
||||
3. Start `oxmysql`, your framework, inventory, and voice resource before Sky Phone.
|
||||
4. Review `sky_phone/config/config.lua` and `sky_phone/config/media.lua`.
|
||||
5. Add the required inventory items.
|
||||
@@ -195,13 +182,6 @@ Replace the example framework, inventory, and voice resources with the providers
|
||||
|
||||
Sky Phone creates and upgrades its database tables automatically. A manual SQL import is normally not required.
|
||||
|
||||
### How players open the phone
|
||||
|
||||
- Give the player the item configured in `Config.Phone.Item` (default: `phone`).
|
||||
- Players can use that inventory item or press the configured keybind (default: `F1`).
|
||||
- The keybind still verifies server-side that the player owns a configured phone item; it does not bypass inventory ownership.
|
||||
- A SIM card is **not required to open or use the phone itself**. With `Config.Sim.Enabled = true`, only cellular features such as calls and messages require an inserted SIM.
|
||||
|
||||
## Configuration
|
||||
|
||||
Customer settings are organized in:
|
||||
@@ -216,7 +196,6 @@ The files contain clearly separated sections for:
|
||||
| Section | Purpose |
|
||||
| --- | --- |
|
||||
| `Config.Bridge` | Framework, inventory, language, callback timeout, and debug mode |
|
||||
| `Config.CommandPermissions` | Fixed groups for the admin panel, test data, verification commands, and social moderation |
|
||||
| `Config.Phone` | Phone item, movement, unique-device mode, and development command |
|
||||
| `Config.Sim` | Physical or virtual SIM behavior and number formatting |
|
||||
| `Config.Calls` / `Config.Radio` | Voice providers, call behavior, radio limits, and permissions |
|
||||
@@ -232,44 +211,6 @@ The files contain clearly separated sections for:
|
||||
|
||||
Restart `sky_phone` after changing Lua configuration.
|
||||
|
||||
### In-game phone configurator
|
||||
|
||||
Set the switch at the beginning of `config/config.lua` to use SQL-backed configuration:
|
||||
|
||||
```lua
|
||||
Config.PhoneConfigurator = {
|
||||
Enabled = true,
|
||||
}
|
||||
```
|
||||
|
||||
When enabled, the generated `source/shared/config_default.lua` is the shipped first-run baseline.
|
||||
The frontend build recreates this file from `config.lua` and the server-only `media.lua`; do not edit
|
||||
the generated snapshot directly. Sky Phone creates the `sky_phone_configurator` table automatically,
|
||||
loads its saved values before framework and phone
|
||||
modules initialize, and exposes the editor through `/phonepanel`. Nothing autosaves: stage changes
|
||||
in the Phone Configurator tool and press the green check. Saving verifies both SQL payloads and then
|
||||
applies the new server, client, media, app, item, command, provider, animation, and UI values through
|
||||
Sky Phone's internal runtime refresh. It does not execute a resource restart command.
|
||||
|
||||
Every configurable `Config.*` value from `config.lua`, including server-only sections, and every
|
||||
value from `Config.Media` is discovered automatically. The bootstrap switch and
|
||||
`Config.CommandPermissions` intentionally remain file-owned: the switch decides whether SQL
|
||||
configuration is loaded, while permissions must remain authoritative outside the panel. The fixed
|
||||
permission table is never displayed or overwritten by the Phone Configurator, and its stable keys do
|
||||
not change when their commands are renamed in the panel. Lists, nested objects, vectors,
|
||||
and numeric-keyed Lua tables use structured editors instead of raw JSON. Shipped schema rows stay
|
||||
editable but cannot be renamed, converted, or removed. Every list and table still accepts any number
|
||||
of additional rows; administrator-added rows remain removable. Company job keys are intentionally
|
||||
fully removable because `Config.Companies.Definitions` is a freely managed job collection.
|
||||
|
||||
ESX and QBCore use the groups listed in `Config.CommandPermissions`. Qbox checks the configured ACE
|
||||
objects first and then its framework groups. The standard Qbox `permissions.cfg` grants the `admin`
|
||||
ACE to `group.admin`, so an identifier assigned to `group.admin` can open `/phonepanel` with the
|
||||
shipped `phonepanel` permission list. Restart `sky_phone` after changing fixed permissions.
|
||||
|
||||
Media API keys and server peppers are never returned in plaintext to the NUI. Existing secrets are
|
||||
shown only as configured and are replaced only when an administrator enters a new value.
|
||||
|
||||
### Language
|
||||
|
||||
Available locales:
|
||||
@@ -374,9 +315,9 @@ Default entries for unique phones with physical SIM cards:
|
||||
|
||||
Do not configure an LB Phone client event or client export. Sky Phone registers the usable items through its server-side inventory adapter.
|
||||
|
||||
The server registers `Config.Phone.Item` as usable for every supported inventory adapter: `ak47`, `codem`, `core`, `jaksam`, `jpr`, `lj`, `mf`, `one`, `origen`, `ox`, `ps`, `qb`, `qs`, `smx`, `tgiann`, `hex`, and `esx`. Resource startup fails visibly if the selected adapter or its resource is unavailable.
|
||||
The server registers `Config.Phone.Item` as usable for every supported inventory adapter: `ox`, `qb`, `lj`, `qs`, `codem`, `core`, `mf`, `smx`, `hex`, and `esx`. Resource startup fails visibly if the selected adapter cannot complete that registration.
|
||||
|
||||
The `hex` and `esx` adapters use ESX's count-based item API, which cannot persist per-item phone or physical SIM metadata. Sky Phone automatically forces `Config.Phone.Unique = false` and `Config.Sim.Enabled = false` while either adapter is active. `auto` selects `hex` when `hex_4_inventory` is started and otherwise falls back to `esx` on an ESX server when no metadata-capable inventory is detected.
|
||||
The `hex` and `esx` adapters use ESX's count-based item API. They require both `Config.Phone.Unique = false` and `Config.Sim.Enabled = false` because this API cannot persist per-item phone or physical SIM metadata. `auto` selects `hex` when `hex_4_inventory` is started and otherwise falls back to `esx` on an ESX server when no metadata-capable inventory is detected.
|
||||
|
||||
### QBCore-style item tables
|
||||
|
||||
@@ -385,67 +326,6 @@ The `hex` and `esx` adapters use ESX's count-based item API, which cannot persis
|
||||
- Physical SIM items must always be unique.
|
||||
- SIM items are not required when `Config.Sim.Enabled = false`.
|
||||
|
||||
Example for `qb-inventory`, `lj-inventory`, `ps-inventory`, and `jpr-inventory`:
|
||||
|
||||
```lua
|
||||
phone = {
|
||||
name = "phone",
|
||||
label = "iFruit Phone",
|
||||
weight = 200,
|
||||
type = "item",
|
||||
image = "phone.png",
|
||||
unique = true,
|
||||
useable = true,
|
||||
shouldClose = true,
|
||||
description = "A personal mobile phone",
|
||||
},
|
||||
|
||||
sky_phone_sim_registered = {
|
||||
name = "sky_phone_sim_registered",
|
||||
label = "Registered SIM",
|
||||
weight = 5,
|
||||
type = "item",
|
||||
image = "sky_phone_sim_registered.png",
|
||||
unique = true,
|
||||
useable = true,
|
||||
shouldClose = true,
|
||||
},
|
||||
|
||||
sky_phone_sim_anonymous = {
|
||||
name = "sky_phone_sim_anonymous",
|
||||
label = "Anonymous SIM",
|
||||
weight = 5,
|
||||
type = "item",
|
||||
image = "sky_phone_sim_anonymous.png",
|
||||
unique = true,
|
||||
useable = true,
|
||||
shouldClose = true,
|
||||
},
|
||||
```
|
||||
|
||||
For `tgiann-inventory`, set `hasMetadata = true`, `useable = true`, and `shouldClose = true` on all three item definitions. Follow the inventory's own item schema for the remaining adapters; the required behavior is always the same: a unique phone or physical SIM must occupy its own slot and its metadata table must survive moving, dropping, storing, and trading the item.
|
||||
|
||||
### Unique Phones and metadata
|
||||
|
||||
Sky Phone owns the metadata values and writes them server-side. Do not pre-generate IMEIs or phone numbers in item definitions:
|
||||
|
||||
| Item | Metadata written by Sky Phone |
|
||||
| --- | --- |
|
||||
| Phone | `imei`; when a SIM is inserted, also `sim_id`, `phone_number`, and `formatted_number` |
|
||||
| Physical SIM | `sim_metadata_version`, `sim_id`, `phone_number`, `formatted_number`, `sim_type`, and registration details where applicable |
|
||||
|
||||
When a metadata-capable phone item is used for the first time, Sky Phone reserves an IMEI and writes it back to that exact slot. Existing metadata is preserved. The adapter then reads the slot again and rejects the operation if the inventory did not persist the requested values.
|
||||
|
||||
For reliable Unique Phones:
|
||||
|
||||
- Set `Config.Phone.Unique = true`.
|
||||
- Make the phone item non-stackable/unique. Every phone slot must contain exactly one item.
|
||||
- If `Config.Sim.Enabled = true`, make both physical SIM items non-stackable/unique and metadata-capable too.
|
||||
- Do not use inventory conversion, admin, crafting, or shop scripts that strip item metadata. Copying an item with its metadata also copies its IMEI; duplicated IMEIs are reported in the server console.
|
||||
- When changing inventory systems, migrate the complete item metadata table. Without the old `imei`, the next use creates a new device identity and does not automatically attach the old handset data.
|
||||
|
||||
With `Config.Phone.Unique = false`, the handset identity is stored once per framework character instead of on each phone item. The phone item may stack. Physical SIMs still require per-item metadata, so `Config.Sim.Enabled` must be `false` on `hex` and native `esx`.
|
||||
|
||||
## Phone and SIM modes
|
||||
|
||||
The two mode switches are independent:
|
||||
@@ -464,7 +344,7 @@ With unique phones, using an inventory item selects that exact handset whenever
|
||||
|
||||
| SIM mode | Behavior |
|
||||
| --- | --- |
|
||||
| `Enabled = true` | The phone opens with or without a SIM. A registered or anonymous physical SIM item is required only for cellular service such as calls and messages. |
|
||||
| `Enabled = true` | A registered or anonymous physical SIM item is required for cellular service. |
|
||||
| `Enabled = false` | Sky Phone creates a persistent automatic number for devices without a SIM. Physical SIM items are not required. |
|
||||
|
||||
When changing these modes on an existing production server, restart the resource and test with a copy of the database first. The first phone used after switching to non-unique mode may adopt an existing valid IMEI so its local data is preserved.
|
||||
@@ -650,15 +530,11 @@ Select the provider under `Config.Garage.System`. Vehicle images use the configu
|
||||
|
||||
### Housing
|
||||
|
||||
Select `rtx`, `quasar`, `tgiann`, `vms`, `rx`, `nolag`, `sn`, `esx_property`, or `qbx_properties` under `Config.Housing.System`. Automatic mode uses `Config.Housing.AutoPriority` and keeps the existing `esx_property` and `qbx_properties` defaults ahead of newly supported providers. Select a provider explicitly when multiple housing resources are running. Each bridge exposes only the capabilities supported by the documented provider API. The TGIANN bridge expects `tgiann-core` to start before `tgiann-house`, lists server-authorized owner properties, and supports entrance waypoints; TGIANN does not publish stable external contracts for keyholders, lock controls, CCTV, or live garage status.
|
||||
Select `rtx`, `quasar`, `vms`, `rx`, `nolag`, `sn`, `esx_property`, or `qbx_properties` under `Config.Housing.System`. Automatic mode uses `Config.Housing.AutoPriority` and keeps the existing `esx_property` and `qbx_properties` defaults ahead of newly supported providers. Select a provider explicitly when multiple housing resources are running. Each bridge exposes only the capabilities supported by the documented provider API.
|
||||
|
||||
### Companies
|
||||
|
||||
Company jobs, public profiles, service numbers, services, permissions, locations, and default
|
||||
availability are configured under `Config.Companies.Definitions`. Definitions are not limited to the
|
||||
shipped jobs: add any number of company IDs in the in-game configurator and fill the freely
|
||||
configurable `Job` value in the automatically generated full company template. Existing job keys can
|
||||
also be removed; the remaining Companies settings stay available as normal individual fields.
|
||||
Company jobs, public profiles, service numbers, services, permissions, locations, and default availability are configured under `Config.Companies.Definitions`.
|
||||
|
||||
### Weazel News
|
||||
|
||||
@@ -726,20 +602,12 @@ pnpm build
|
||||
|
||||
### The phone item does nothing
|
||||
|
||||
- A warning that the inventory returned no configured phone item means an item definition, `Config.Phone.Item`, inventory selection, or player ownership problem. It is not caused by a missing SIM card.
|
||||
- Confirm the framework and inventory are supported and started first.
|
||||
- Confirm the item name matches `Config.Phone.Item`.
|
||||
- Confirm the item is usable.
|
||||
- In unique mode, confirm the phone is non-stackable.
|
||||
- Check the server console for inventory adapter warnings.
|
||||
|
||||
### The resource starts but the phone UI is missing
|
||||
|
||||
- On startup, the server console prints `SKY PHONE UI BUILD IS MISSING OR INCOMPLETE`, lists the missing or invalid packaged files, and shows repository-native build commands.
|
||||
- Install the latest published release package rather than GitHub's automatically generated source archive.
|
||||
- Confirm `sky_phone/source/html/index.html`, `assets`, `img`, and `sounds` exist.
|
||||
- Developers working from source must run the frontend production build before starting the resource.
|
||||
|
||||
### Calls connect without audio
|
||||
|
||||
- Confirm the configured voice resource is running.
|
||||
|
||||
@@ -26,100 +26,6 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
## Inter 4.1
|
||||
|
||||
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION AND CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER
|
||||
DEALINGS IN THE FONT SOFTWARE.
|
||||
|
||||
## Framework7 documentation placeholder media
|
||||
|
||||
The development-only Sky UI Kitchen Sink includes placeholder images mirrored
|
||||
|
||||
+5
-40
@@ -2,49 +2,15 @@ const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
|
||||
const frontendRoot = __dirname
|
||||
const resourceDirectory = path.join(frontendRoot, '..', 'sky_phone')
|
||||
const sourceDirectory = path.join(frontendRoot, 'dist')
|
||||
const targetDirectory = path.join(resourceDirectory, 'source', 'html')
|
||||
const configDefaultPath = path.join(
|
||||
resourceDirectory,
|
||||
const targetDirectory = path.join(
|
||||
frontendRoot,
|
||||
'..',
|
||||
'sky_phone',
|
||||
'source',
|
||||
'shared',
|
||||
'config_default.lua',
|
||||
'html',
|
||||
)
|
||||
|
||||
function readLuaSource(...segments) {
|
||||
return fs
|
||||
.readFileSync(path.join(resourceDirectory, ...segments), 'utf8')
|
||||
.replace(/\r\n?/g, '\n')
|
||||
.trimEnd()
|
||||
}
|
||||
|
||||
function generateConfigDefault() {
|
||||
const configSource = readLuaSource('config', 'config.lua').replace(
|
||||
/\n?-- CONFIG_DEFAULT_EXCLUDE_START[\s\S]*?-- CONFIG_DEFAULT_EXCLUDE_END\n?/g,
|
||||
'\n',
|
||||
)
|
||||
const mediaSource = readLuaSource('config', 'media.lua')
|
||||
const generatedSource = [
|
||||
'-- GENERATED by frontend/build.cjs from config/config.lua and config/media.lua - do not edit.',
|
||||
'-- Escrowed snapshot of the shipped defaults used by the Phone Configurator.',
|
||||
'local realConfig = Config',
|
||||
'Config = {}',
|
||||
'',
|
||||
configSource,
|
||||
'',
|
||||
mediaSource,
|
||||
'',
|
||||
'ConfigDefaults = Config',
|
||||
'Config = realConfig',
|
||||
'',
|
||||
].join('\n')
|
||||
|
||||
fs.mkdirSync(path.dirname(configDefaultPath), { recursive: true })
|
||||
fs.writeFileSync(configDefaultPath, generatedSource, 'utf8')
|
||||
console.log(`Generated Phone Configurator defaults at ${configDefaultPath}`)
|
||||
}
|
||||
|
||||
if (!fs.existsSync(sourceDirectory)) {
|
||||
throw new Error(
|
||||
'Missing frontend/dist. Run vite build before publishing the NUI.',
|
||||
@@ -62,5 +28,4 @@ const normalizedIndex = fs
|
||||
.replace(/\n[ \t]*\n([ \t]*<\/body>)/g, '\n$1')
|
||||
fs.writeFileSync(targetIndex, normalizedIndex)
|
||||
|
||||
generateConfigDefault()
|
||||
console.log(`Published NUI to ${targetDirectory}`)
|
||||
|
||||
+22
-145
@@ -11,7 +11,6 @@ import {
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { SkyProvider } from '@/ui'
|
||||
import AdminPanel from '@/components/AdminPanel.vue'
|
||||
import PhoneHomeIndicator from '@/components/PhoneHomeIndicator.vue'
|
||||
import PhoneControlCenter from '@/components/PhoneControlCenter.vue'
|
||||
import PhoneDynamicIsland from '@/components/PhoneDynamicIsland.vue'
|
||||
@@ -57,7 +56,6 @@ import { useNotesStore } from '@/stores/notes'
|
||||
import { useMemosStore } from '@/stores/memos'
|
||||
import { useWeatherStore } from '@/stores/weather'
|
||||
import { useEasyShareStore } from '@/stores/easyshare'
|
||||
import { useRadioStore } from '@/stores/radio'
|
||||
import {
|
||||
useNotificationsStore,
|
||||
type PhoneNotification,
|
||||
@@ -71,28 +69,19 @@ import type {
|
||||
CompanyChangedPayload,
|
||||
CompanyUnreadCounts,
|
||||
} from '@/types/companies'
|
||||
import type { PhoneCall, PhoneNumberFormat } from '@/types/phone'
|
||||
import type { DynamicIslandActivity } from '@/types/dynamicIsland'
|
||||
import type { PhoneCall } from '@/types/phone'
|
||||
import type { EasyShareEvent } from '@/types/easyshare'
|
||||
import type { CryptoMarketChangedData } from '@/types/crypto'
|
||||
import type { CityWarnEventData } from '@/types/citywarn'
|
||||
import { nuiCall } from '@/utils/nui'
|
||||
import {
|
||||
installPhoneAudioController,
|
||||
setPhoneOutputVolume,
|
||||
} from '@/utils/phoneAudio'
|
||||
import { formatTimer } from '@/utils/clock'
|
||||
import { parsePhonePreferences } from '@/utils/preferences'
|
||||
import { getHairlinePixelStyle } from '@/utils/rendering'
|
||||
import { isTextInputElement } from '@/utils/textInputFocus'
|
||||
import { configurePhoneNumberFormat } from '@/utils/phone'
|
||||
import { consumeEscape } from '@/utils/keyboard'
|
||||
import type { CustomPhoneToneCatalog } from '@/utils/customTones'
|
||||
import { isTrustedRootMessageSource } from '@/utils/windowMessages'
|
||||
import SpringboardView from '@/views/SpringboardView.vue'
|
||||
|
||||
type AppMessage = {
|
||||
openHome?: boolean
|
||||
type?: string
|
||||
data?:
|
||||
| CalendarReminderData
|
||||
@@ -118,14 +107,8 @@ type AppMessage = {
|
||||
| CustomAppCatalogEventData
|
||||
| CustomAppEventData
|
||||
| NavigationEventData
|
||||
| AdminPanelOpenPayload
|
||||
| CustomPhoneToneCatalog
|
||||
}
|
||||
|
||||
type AdminPanelOpenPayload = Required<
|
||||
Pick<PhoneOpenPayload, 'fallbackLocales' | 'lang' | 'locales'>
|
||||
>
|
||||
|
||||
type CustomAppCatalogEventData = {
|
||||
apps?: unknown
|
||||
}
|
||||
@@ -143,7 +126,6 @@ type NavigationEventData = {
|
||||
type SimPickerPayload = {
|
||||
choices: SimPhoneChoice[]
|
||||
number: string
|
||||
phoneNumberFormat?: PhoneNumberFormat
|
||||
}
|
||||
|
||||
type NotificationEventData = Omit<PhoneNotificationInput, 'device'> & {
|
||||
@@ -331,7 +313,6 @@ const notes = useNotesStore()
|
||||
const memos = useMemosStore()
|
||||
const weather = useWeatherStore()
|
||||
const easyShare = useEasyShareStore()
|
||||
const radio = useRadioStore()
|
||||
const notifications = useNotificationsStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -354,21 +335,13 @@ const DARK_STATUS_BAR_APP_IDS = new Set([
|
||||
'minesweeper',
|
||||
'number-merge',
|
||||
])
|
||||
const isDynamicIslandGalleryRoute = computed(
|
||||
() => isDevelopment && route.name === 'development-dynamic-islands',
|
||||
)
|
||||
const isDevelopmentRoute = computed(
|
||||
() =>
|
||||
isDevelopment &&
|
||||
(route.name === 'development-sky-ui' || isDynamicIslandGalleryRoute.value),
|
||||
() => isDevelopment && route.name === 'development-sky-ui',
|
||||
)
|
||||
const appTransitionName = computed(() =>
|
||||
route.query.transition === 'app-switch' ? 'app-switch' : 'app-window',
|
||||
)
|
||||
const isLocked = ref(false)
|
||||
const adminPanelOpen = ref(
|
||||
isDevelopment && developmentParameters.has('adminPanel'),
|
||||
)
|
||||
const springboardEditing = ref(false)
|
||||
const isUnlocking = ref(false)
|
||||
const passcodeBusy = ref(false)
|
||||
@@ -383,12 +356,9 @@ const setupPreviewDismissed = ref(false)
|
||||
const setupDevelopmentSkipped = ref(false)
|
||||
const setupAppearanceSelected = ref(false)
|
||||
const pendingUnlockRoute = ref<string | null>(null)
|
||||
const openHomeRequested = ref(false)
|
||||
const unlockedServicesLoaded = ref(false)
|
||||
const controlCenterOpened = ref(false)
|
||||
const activitySuspended = ref(false)
|
||||
const dynamicIslandExpanded = ref(false)
|
||||
const dynamicIslandActivity = ref<DynamicIslandActivity | null>(null)
|
||||
const simPicker = ref<SimPickerPayload | null>(null)
|
||||
const setupRequired = computed(
|
||||
() =>
|
||||
@@ -462,12 +432,6 @@ const phoneResolutionStyle = computed<CSSProperties>(() => ({
|
||||
}))
|
||||
const phoneStageStyle = computed<CSSProperties>(() => ({
|
||||
...phoneResolutionStyle.value,
|
||||
'--phone-live-activity-peek-height':
|
||||
dynamicIslandActivity.value === 'music'
|
||||
? '190px'
|
||||
: dynamicIslandActivity.value === 'recording'
|
||||
? '132px'
|
||||
: '112px',
|
||||
visibility: activitySuspended.value ? 'hidden' : 'visible',
|
||||
}))
|
||||
const phoneDisplayStyle = computed<CSSProperties>(() => ({
|
||||
@@ -485,7 +449,6 @@ let unlockTimer: number | undefined
|
||||
let passcodeLockTimer: number | undefined
|
||||
let hardwareVolumeHudTimer: number | undefined
|
||||
let unlockedServicesIdle: number | undefined
|
||||
let removePhoneAudioController: (() => void) | undefined
|
||||
let phoneClosePending = false
|
||||
let simPickerClosePending = false
|
||||
|
||||
@@ -505,7 +468,6 @@ function getViewportScale(): number {
|
||||
}
|
||||
|
||||
function hydratePhone(payload: PhoneOpenPayload): void {
|
||||
configurePhoneNumberFormat(payload.phoneNumberFormat)
|
||||
if (payload.device?.imei) {
|
||||
companies.bindDeviceScope(
|
||||
payload.device.imei,
|
||||
@@ -533,17 +495,8 @@ function hydratePhone(payload: PhoneOpenPayload): void {
|
||||
clock.hydrate(payload.device?.data.alarms?.payload)
|
||||
games.hydrate(payload.device?.data.games?.payload)
|
||||
media.hydrate(payload.device?.data.media?.payload)
|
||||
appStore.hydrate(payload.device?.data.apps?.payload, payload.disabledApps)
|
||||
appStore.hydrate(payload.device?.data.apps?.payload)
|
||||
widgets.hydrate(payload.device?.data.widgets?.payload)
|
||||
|
||||
const currentAppId = route.params.appId
|
||||
if (
|
||||
typeof currentAppId === 'string' &&
|
||||
isPhoneAppId(currentAppId) &&
|
||||
!appStore.isInstalled(currentAppId)
|
||||
) {
|
||||
void router.push('/')
|
||||
}
|
||||
}
|
||||
|
||||
function getInstalledNavigationAppIds(): string[] {
|
||||
@@ -651,8 +604,6 @@ function loadUnlockedPhoneData(): void {
|
||||
}
|
||||
|
||||
function completePhoneSetup(): void {
|
||||
const requestedRoute = pendingUnlockRoute.value
|
||||
pendingUnlockRoute.value = null
|
||||
setupPreviewDismissed.value = true
|
||||
setupAppearanceSelected.value = false
|
||||
isLocked.value = false
|
||||
@@ -660,7 +611,7 @@ function completePhoneSetup(): void {
|
||||
passcodeVisible.value = false
|
||||
passcodeRequired.value = false
|
||||
controlCenterOpened.value = false
|
||||
void router.replace(requestedRoute ?? '/')
|
||||
void router.replace('/')
|
||||
loadUnlockedPhoneData()
|
||||
}
|
||||
|
||||
@@ -744,17 +695,7 @@ function openDevelopmentPayphonePreview(): void {
|
||||
function onMessage(event: MessageEvent<AppMessage>): void {
|
||||
if (!isTrustedRootMessageSource(event.source, window)) return
|
||||
|
||||
if (event.data?.type === 'admin:open') {
|
||||
const data = event.data.data as AdminPanelOpenPayload | undefined
|
||||
if (data?.lang && data.locales && data.fallbackLocales) {
|
||||
phone.setLocale(data.lang, data.locales, data.fallbackLocales)
|
||||
}
|
||||
adminPanelOpen.value = true
|
||||
} else if (event.data?.type === 'admin:close') {
|
||||
adminPanelOpen.value = false
|
||||
} else if (event.data?.type === 'phone:tones') {
|
||||
phone.setCustomTones(event.data.data)
|
||||
} else if (event.data?.type === 'custom-apps:catalog') {
|
||||
if (event.data?.type === 'custom-apps:catalog') {
|
||||
appCatalog.replaceCatalog(event.data.data)
|
||||
const catalogPayload = event.data.data as
|
||||
| { apps?: unknown; debug?: unknown }
|
||||
@@ -799,12 +740,7 @@ function onMessage(event: MessageEvent<AppMessage>): void {
|
||||
isPhoneAppId(data.appId) &&
|
||||
appStore.isInstalled(data.appId)
|
||||
) {
|
||||
const requestedRoute = `/apps/${data.appId}`
|
||||
if (setupRequired.value || isLocked.value) {
|
||||
pendingUnlockRoute.value = requestedRoute
|
||||
} else {
|
||||
void router.push(requestedRoute)
|
||||
}
|
||||
void router.push(`/apps/${data.appId}`)
|
||||
} else {
|
||||
console.error('[Navigation] Ignored an unavailable app target.')
|
||||
}
|
||||
@@ -822,7 +758,6 @@ 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') {
|
||||
@@ -1263,9 +1198,7 @@ function onMessage(event: MessageEvent<AppMessage>): void {
|
||||
loadUnlockedPhoneData()
|
||||
}
|
||||
} else if (event.data?.type === 'sim:picker' && event.data.data) {
|
||||
const payload = event.data.data as unknown as SimPickerPayload
|
||||
configurePhoneNumberFormat(payload.phoneNumberFormat)
|
||||
simPicker.value = payload
|
||||
simPicker.value = event.data.data as unknown as SimPickerPayload
|
||||
} else if (event.data?.type === 'sim:picker-close') {
|
||||
simPicker.value = null
|
||||
}
|
||||
@@ -1320,17 +1253,20 @@ async function closePhone(): Promise<void> {
|
||||
function onKeydown(event: KeyboardEvent): void {
|
||||
if (event.key !== 'Escape') return
|
||||
if (simPicker.value) {
|
||||
if (!consumeEscape(event)) return
|
||||
event.preventDefault()
|
||||
void closeSimPicker()
|
||||
return
|
||||
}
|
||||
|
||||
if (!phone.isOpen || activitySuspended.value || !consumeEscape(event)) return
|
||||
if (controlCenterOpened.value) {
|
||||
controlCenterOpened.value = false
|
||||
return
|
||||
}
|
||||
void closePhone()
|
||||
queueMicrotask(() => {
|
||||
if (event.defaultPrevented || !phone.isOpen || activitySuspended.value)
|
||||
return
|
||||
if (controlCenterOpened.value) {
|
||||
controlCenterOpened.value = false
|
||||
return
|
||||
}
|
||||
void closePhone()
|
||||
})
|
||||
}
|
||||
|
||||
function onSystemColorSchemeChange(event: MediaQueryListEvent): void {
|
||||
@@ -1537,7 +1473,6 @@ function onFocusOut(event: FocusEvent): void {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
removePhoneAudioController = installPhoneAudioController()
|
||||
document.addEventListener('focusin', onFocusIn)
|
||||
document.addEventListener('focusout', onFocusOut)
|
||||
window.addEventListener('message', onMessage)
|
||||
@@ -1652,23 +1587,6 @@ watch(
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => Boolean(dynamicIslandActivity.value || calls.activeCall),
|
||||
(active) => {
|
||||
void nuiCall('ui:live-activity', { active })
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
hardwareAlertVolume,
|
||||
(volume) => {
|
||||
setPhoneOutputVolume(volume / 100)
|
||||
if (radio.data.connected) void radio.setVolume(volume)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => phone.isOpen,
|
||||
(isOpen) => {
|
||||
@@ -1701,8 +1619,7 @@ watch(
|
||||
}
|
||||
isLocked.value = setupRequired.value
|
||||
? false
|
||||
: developmentLockScreenPreview ||
|
||||
(!isDevelopment && phone.security.enabled)
|
||||
: !isDevelopment || developmentLockScreenPreview
|
||||
passcodeRequired.value = isLocked.value && phone.security.enabled
|
||||
unlockedServicesLoaded.value = false
|
||||
controlCenterOpened.value = false
|
||||
@@ -1720,18 +1637,8 @@ watch(
|
||||
startPasscodeLock(passcodeRetrySeconds.value)
|
||||
}
|
||||
phone.setLaunchOrigin(null)
|
||||
if (setupRequired.value) {
|
||||
void router.replace('/')
|
||||
} else if (openHomeRequested.value) {
|
||||
openHomeRequested.value = false
|
||||
if (isLocked.value) pendingUnlockRoute.value = '/'
|
||||
else {
|
||||
void router.replace('/')
|
||||
loadUnlockedPhoneData()
|
||||
}
|
||||
} else if (!isLocked.value) {
|
||||
loadUnlockedPhoneData()
|
||||
}
|
||||
if (isLocked.value || setupRequired.value) void router.replace('/')
|
||||
else loadUnlockedPhoneData()
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1743,7 +1650,6 @@ watch(
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
removePhoneAudioController?.()
|
||||
updateTextInputFocus(false)
|
||||
cancelUnlockedPhoneDataLoad()
|
||||
weather.stop()
|
||||
@@ -1766,15 +1672,6 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SkyProvider
|
||||
v-if="adminPanelOpen"
|
||||
dark
|
||||
:safe-areas="false"
|
||||
accent="#74d66f"
|
||||
accent-soft="rgba(116, 214, 111, 0.14)"
|
||||
>
|
||||
<AdminPanel @close="adminPanelOpen = false" />
|
||||
</SkyProvider>
|
||||
<PhoneMediaCapture />
|
||||
<PhoneMemoRecorder />
|
||||
<RadioHud />
|
||||
@@ -1791,15 +1688,12 @@ onBeforeUnmount(() => {
|
||||
phone.isOpen ||
|
||||
notifications.current ||
|
||||
calls.activeCall ||
|
||||
dynamicIslandActivity ||
|
||||
notifications.devicePreviews.length
|
||||
"
|
||||
class="phone-stage"
|
||||
:class="{
|
||||
'phone-stage--browser-preview': isBrowserPreview,
|
||||
'phone-stage--landscape': phone.cameraLandscape,
|
||||
'phone-stage--live-activity':
|
||||
!phone.isOpen && Boolean(dynamicIslandActivity || calls.activeCall),
|
||||
'phone-stage--peek': notifications.isPeeking,
|
||||
}"
|
||||
:style="phoneStageStyle"
|
||||
@@ -1819,25 +1713,14 @@ onBeforeUnmount(() => {
|
||||
@open="openNotificationPreview"
|
||||
/>
|
||||
<div
|
||||
v-if="
|
||||
phone.isOpen ||
|
||||
notifications.current ||
|
||||
calls.activeCall ||
|
||||
dynamicIslandActivity
|
||||
"
|
||||
v-if="phone.isOpen || notifications.current || calls.activeCall"
|
||||
class="phone-resolution-wrapper phone-resolution-wrapper--primary"
|
||||
>
|
||||
<div
|
||||
id="phone-home-drag-portal"
|
||||
class="phone-home-drag-portal"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
<div class="phone-resolution-canvas phone-resolution-canvas--primary">
|
||||
<section
|
||||
class="phone-device"
|
||||
:class="{
|
||||
'phone-app--light': !displayedDarkMode,
|
||||
'phone-device--island-expanded': dynamicIslandExpanded,
|
||||
[`phone-app--${phone.preferences.settings.graphicsMode}`]: true,
|
||||
}"
|
||||
:aria-label="phone.t('Common.phone')"
|
||||
@@ -1887,8 +1770,6 @@ onBeforeUnmount(() => {
|
||||
class="phone-screen"
|
||||
:class="{
|
||||
'phone-screen--app': isAppRoute || isDevelopmentRoute,
|
||||
'phone-screen--camera-landscape':
|
||||
activeAppId === 'camera' && phone.cameraLandscape,
|
||||
'phone-app--light': !displayedDarkMode,
|
||||
[`phone-app--${phone.preferences.settings.graphicsMode}`]: true,
|
||||
}"
|
||||
@@ -1951,6 +1832,7 @@ onBeforeUnmount(() => {
|
||||
@control-center="toggleControlCenter"
|
||||
@lock="lockPhone"
|
||||
/>
|
||||
<PhoneDynamicIsland v-if="!setupRequired" />
|
||||
<SpringboardView
|
||||
v-if="!isDevelopmentRoute && !setupRequired"
|
||||
@edit-mode-change="springboardEditing = $event"
|
||||
@@ -2036,11 +1918,6 @@ onBeforeUnmount(() => {
|
||||
aria-hidden="true"
|
||||
draggable="false"
|
||||
/>
|
||||
<PhoneDynamicIsland
|
||||
v-if="!setupRequired && !isDynamicIslandGalleryRoute"
|
||||
@expanded-change="dynamicIslandExpanded = $event"
|
||||
@live-activity-change="dynamicIslandActivity = $event"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const source = readFileSync(new URL('./App.vue', import.meta.url), 'utf8').replace(/\r\n/g, '\n')
|
||||
const source = readFileSync(new URL('./App.vue', import.meta.url), 'utf8')
|
||||
const mainCss = readFileSync(
|
||||
new URL('./assets/main.css', import.meta.url),
|
||||
'utf8',
|
||||
@@ -22,32 +22,15 @@ describe('browser development preview contract', () => {
|
||||
|
||||
it('starts unlocked while preserving an explicit lock screen preview', () => {
|
||||
expect(source).toContain("developmentParameters.has('lockScreenPreview')")
|
||||
expect(source).toContain(
|
||||
'developmentLockScreenPreview ||\n (!isDevelopment && phone.security.enabled)',
|
||||
)
|
||||
expect(source).toContain(': !isDevelopment || developmentLockScreenPreview')
|
||||
expect(source).toContain("developmentParameters.has('setupPreview')")
|
||||
})
|
||||
|
||||
it('restores the active route after the lock screen', () => {
|
||||
expect(source).toMatch(
|
||||
/if \(setupRequired\.value\) \{[\s\S]*?router\.replace\('\/'\)/,
|
||||
)
|
||||
expect(source).toMatch(
|
||||
/else if \(!isLocked\.value\) \{\s*loadUnlockedPhoneData\(\)/,
|
||||
)
|
||||
expect(source).not.toContain(
|
||||
it('loads authenticated app data without replacing direct app routes', () => {
|
||||
expect(source).toContain(
|
||||
"if (isLocked.value || setupRequired.value) void router.replace('/')",
|
||||
)
|
||||
})
|
||||
|
||||
it('opens Space-triggered live activities on Home or the enabled lock screen', () => {
|
||||
expect(source).toContain(
|
||||
'openHomeRequested.value = event.data.openHome === true',
|
||||
)
|
||||
expect(source).toContain(
|
||||
"if (isLocked.value) pendingUnlockRoute.value = '/'",
|
||||
)
|
||||
expect(source).toContain("void router.replace('/')")
|
||||
expect(source).toContain('else loadUnlockedPhoneData()')
|
||||
})
|
||||
|
||||
it('requires the passcode again after a full device lock', () => {
|
||||
@@ -70,43 +53,6 @@ describe('browser development preview contract', () => {
|
||||
expect(source).toContain(':device-pixel-ratio="browserDevicePixelRatio"')
|
||||
})
|
||||
|
||||
it('clips composited app and overlay layers to the curved display', () => {
|
||||
expect(mainCss).toMatch(
|
||||
/\.phone-screen\s*\{[^}]*--phone-screen-radius:\s*40px;[^}]*overflow:\s*hidden;[^}]*border-radius:\s*var\(--phone-screen-radius\);[^}]*clip-path:\s*inset\(0 round var\(--phone-screen-radius\)\);/s,
|
||||
)
|
||||
})
|
||||
|
||||
it('replaces the CEF button focus rectangle around the home indicator', () => {
|
||||
expect(mainCss).toMatch(
|
||||
/\.phone-home-indicator:focus\s*\{[^}]*outline:\s*none;/s,
|
||||
)
|
||||
expect(mainCss).toMatch(
|
||||
/\.phone-home-indicator:focus-visible span\s*\{[^}]*0 0 0 2px #0a84ff,/s,
|
||||
)
|
||||
})
|
||||
|
||||
it('replaces rectangular CEF focus outlines on the side hardware controls', () => {
|
||||
expect(mainCss).toMatch(
|
||||
/\.phone-hardware-button:focus\s*\{[^}]*outline:\s*none;/s,
|
||||
)
|
||||
expect(mainCss).toMatch(
|
||||
/\.phone-hardware-button:focus-visible::after\s*\{[^}]*width:\s*3px;[^}]*height:\s*24px;[^}]*border-radius:\s*999px;[^}]*background:\s*#0a84ff;/s,
|
||||
)
|
||||
expect(mainCss).not.toMatch(
|
||||
/\.phone-hardware-button:focus-visible\s*\{[^}]*outline:/s,
|
||||
)
|
||||
})
|
||||
|
||||
it('consumes Escape synchronously before FiveM can open the pause menu', () => {
|
||||
expect(source).toContain("import { consumeEscape } from '@/utils/keyboard'")
|
||||
expect(source).toContain(
|
||||
'if (!phone.isOpen || activitySuspended.value || !consumeEscape(event)) return',
|
||||
)
|
||||
expect(source).not.toMatch(
|
||||
/function onKeydown\(event: KeyboardEvent\): void \{[\s\S]*?queueMicrotask/,
|
||||
)
|
||||
})
|
||||
|
||||
it('maps the visible device side controls to phone actions', () => {
|
||||
expect(source).toContain('@click="toggleHardwareAlertMute"')
|
||||
expect(source).toContain('@click="changeHardwareAlertVolume(10)"')
|
||||
@@ -129,10 +75,6 @@ describe('browser development preview contract', () => {
|
||||
|
||||
it('uses layout zoom so the fixed-resolution phone stays sharply rasterized', () => {
|
||||
expect(source).toContain('phone-resolution-canvas--primary')
|
||||
expect(source).toMatch(
|
||||
/phone-resolution-wrapper--primary[\s\S]*?id="phone-home-drag-portal"[\s\S]*?phone-resolution-canvas--primary/,
|
||||
)
|
||||
expect(source.match(/id="phone-home-drag-portal"/g)).toHaveLength(1)
|
||||
expect(source).toContain("'--phone-rendered-height'")
|
||||
expect(source).toContain("'--phone-rendered-width'")
|
||||
expect(mainCss).toMatch(
|
||||
@@ -158,9 +100,7 @@ describe('browser development preview contract', () => {
|
||||
expect(source).toContain("developmentParameters.has('browserPreview')")
|
||||
expect(source).toContain('import.meta.env.DEV ||')
|
||||
expect(source).toContain("'phone-stage--browser-preview': isBrowserPreview")
|
||||
expect(source).toContain(
|
||||
'return (availableScale * 0.94) / PHONE_BASE_SCALE',
|
||||
)
|
||||
expect(source).toContain('return (availableScale * 0.94) / PHONE_BASE_SCALE')
|
||||
expect(mainCss).toMatch(
|
||||
/\.phone-stage--browser-preview\s*\{[^}]*place-items:\s*center;[^}]*padding:\s*0;/s,
|
||||
)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
+91
-112
@@ -211,11 +211,9 @@
|
||||
}
|
||||
}
|
||||
:root {
|
||||
font-family: var(--sky-font-family);
|
||||
font-feature-settings:
|
||||
'liga' 1,
|
||||
'calt' 1;
|
||||
font-optical-sizing: auto;
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
@@ -236,9 +234,7 @@ body,
|
||||
user-select: none;
|
||||
}
|
||||
button,
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
input {
|
||||
font: inherit;
|
||||
}
|
||||
button {
|
||||
@@ -263,20 +259,6 @@ button {
|
||||
transform: translateY(calc(100% - 190px));
|
||||
transform-origin: right bottom;
|
||||
}
|
||||
|
||||
.phone-stage--live-activity .phone-resolution-wrapper--primary .phone-device {
|
||||
pointer-events: none;
|
||||
transform: translateY(
|
||||
calc(100% - var(--phone-live-activity-peek-height, 145px))
|
||||
);
|
||||
transform-origin: right bottom;
|
||||
}
|
||||
.phone-stage--live-activity
|
||||
.phone-device
|
||||
> :not(.phone-screen):not(.phone-device__frame):not(.phone-dynamic-island),
|
||||
.phone-stage--live-activity .phone-screen > * {
|
||||
visibility: hidden;
|
||||
}
|
||||
.phone-lift-enter-active {
|
||||
transition: opacity 0.52s linear;
|
||||
}
|
||||
@@ -299,18 +281,6 @@ button {
|
||||
height: var(--phone-rendered-height, 844px);
|
||||
}
|
||||
|
||||
.phone-home-drag-portal {
|
||||
position: absolute;
|
||||
z-index: 70;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.phone-home-drag-portal * {
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
.phone-resolution-canvas {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
@@ -380,27 +350,9 @@ button {
|
||||
.phone-hardware-button:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
.phone-hardware-button:focus {
|
||||
outline: none;
|
||||
}
|
||||
.phone-hardware-button:focus-visible::after {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 3px;
|
||||
height: 24px;
|
||||
border-radius: 999px;
|
||||
background: #0a84ff;
|
||||
box-shadow: 0 0 0 1px rgb(255 255 255 / 90%);
|
||||
content: '';
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
.phone-hardware-button--action:focus-visible::after,
|
||||
.phone-hardware-button--volume-up:focus-visible::after,
|
||||
.phone-hardware-button--volume-down:focus-visible::after {
|
||||
right: 7px;
|
||||
}
|
||||
.phone-hardware-button--power:focus-visible::after {
|
||||
left: 7px;
|
||||
.phone-hardware-button:focus-visible {
|
||||
outline: 2px solid #fff;
|
||||
outline-offset: -6px;
|
||||
}
|
||||
.phone-hardware-button--action {
|
||||
top: 176px;
|
||||
@@ -432,7 +384,7 @@ button {
|
||||
overflow: hidden;
|
||||
border: 1px solid rgb(255 255 255 / 16%);
|
||||
border-radius: 15px;
|
||||
color: #a9abb2;
|
||||
color: #0a84ff;
|
||||
background: rgb(47 47 51 / 98%);
|
||||
box-shadow: 0 8px 24px rgb(0 0 0 / 28%);
|
||||
pointer-events: none;
|
||||
@@ -478,8 +430,6 @@ button {
|
||||
}
|
||||
}
|
||||
.phone-screen {
|
||||
--phone-screen-radius: 40px;
|
||||
--phone-screen-portrait-ratio: 2.30951;
|
||||
position: relative;
|
||||
container-type: size;
|
||||
margin: auto;
|
||||
@@ -488,14 +438,7 @@ button {
|
||||
height: 98%;
|
||||
overflow: hidden;
|
||||
background: #08080a;
|
||||
border-radius: var(--phone-screen-radius);
|
||||
clip-path: inset(0 round var(--phone-screen-radius));
|
||||
}
|
||||
.phone-screen--camera-landscape {
|
||||
background: transparent;
|
||||
}
|
||||
.phone-screen--camera-landscape .springboard {
|
||||
visibility: hidden;
|
||||
border-radius: 40px;
|
||||
}
|
||||
.phone-app {
|
||||
position: absolute !important;
|
||||
@@ -729,9 +672,6 @@ button {
|
||||
transition: transform 280ms var(--sky-ease-out, ease-out);
|
||||
will-change: transform;
|
||||
}
|
||||
.phone-device--island-expanded .phone-notification {
|
||||
top: 130px !important;
|
||||
}
|
||||
.phone-notification__icon {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
@@ -855,6 +795,78 @@ button {
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.phone-dynamic-island {
|
||||
position: absolute;
|
||||
z-index: 98;
|
||||
top: 12px;
|
||||
left: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 300px;
|
||||
min-height: 72px;
|
||||
padding: 10px 12px 10px 16px;
|
||||
border: 1px solid rgb(255 255 255 / 10%);
|
||||
border-radius: 28px;
|
||||
color: #fff;
|
||||
background: #050505;
|
||||
box-shadow: 0 8px 24px rgb(0 0 0 / 45%);
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
.phone-dynamic-island__caller {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.phone-dynamic-island__caller span,
|
||||
.phone-dynamic-island__caller strong {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.phone-dynamic-island__caller span {
|
||||
color: rgb(255 255 255 / 58%);
|
||||
font-size: 11px;
|
||||
line-height: 14px;
|
||||
}
|
||||
.phone-dynamic-island__caller strong {
|
||||
margin-top: 2px;
|
||||
font-size: 15px;
|
||||
line-height: 18px;
|
||||
}
|
||||
.phone-dynamic-island__actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.phone-dynamic-island__actions .sky-button--icon-only {
|
||||
width: 44px;
|
||||
min-width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
.phone-dynamic-island__actions svg {
|
||||
width: 19px;
|
||||
height: 19px;
|
||||
}
|
||||
.phone-dynamic-island__answer {
|
||||
--sky-app-accent: #34c759;
|
||||
}
|
||||
.phone-dynamic-island-enter-active,
|
||||
.phone-dynamic-island-leave-active {
|
||||
transition:
|
||||
opacity 180ms ease,
|
||||
transform 220ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
.phone-dynamic-island-enter-from,
|
||||
.phone-dynamic-island-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) scale(0.86);
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.phone-dynamic-island-enter-active,
|
||||
.phone-dynamic-island-leave-active {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
.phone-home-indicator {
|
||||
position: absolute;
|
||||
z-index: 90;
|
||||
@@ -876,14 +888,6 @@ button {
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 1px 4px #0008;
|
||||
}
|
||||
.phone-home-indicator:focus {
|
||||
outline: none;
|
||||
}
|
||||
.phone-home-indicator:focus-visible span {
|
||||
box-shadow:
|
||||
0 0 0 2px #0a84ff,
|
||||
0 1px 4px #0008;
|
||||
}
|
||||
.phone-home-indicator--interactive {
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -1337,7 +1341,8 @@ button {
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
color: #f5f5f7;
|
||||
font-family: var(--sky-font-family);
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', sans-serif;
|
||||
}
|
||||
.darkchat-page button,
|
||||
.darkchat-page input,
|
||||
@@ -2672,30 +2677,8 @@ button {
|
||||
.springboard--widget-dragging .springboard-widget-page-scroll {
|
||||
overflow: visible;
|
||||
}
|
||||
.home-drag-layer {
|
||||
position: absolute;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
.home-drag-layer * {
|
||||
pointer-events: none !important;
|
||||
}
|
||||
.home-drag-position {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
will-change: transform;
|
||||
}
|
||||
.home-drag-ghost {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
margin: 0;
|
||||
opacity: 1;
|
||||
transition: none !important;
|
||||
animation: none !important;
|
||||
transform-origin: 0 0;
|
||||
will-change: transform;
|
||||
.springboard--home-dragging .springboard-page--apps {
|
||||
overflow: visible;
|
||||
}
|
||||
.app-grid {
|
||||
display: grid;
|
||||
@@ -2730,9 +2713,6 @@ button {
|
||||
transition: transform var(--springboard-page-duration)
|
||||
var(--springboard-page-easing);
|
||||
}
|
||||
.app-icon-item--drag-source {
|
||||
opacity: 0;
|
||||
}
|
||||
.app-icon-remove {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
@@ -2858,6 +2838,7 @@ button {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 11px;
|
||||
}
|
||||
.app-icon--calculator {
|
||||
background: linear-gradient(145deg, #76767b, #1b1b1d);
|
||||
@@ -3274,9 +3255,6 @@ button {
|
||||
backface-visibility: hidden;
|
||||
will-change: transform, border-radius, opacity;
|
||||
}
|
||||
.app-window--camera-landscape {
|
||||
background: transparent;
|
||||
}
|
||||
.app-window--citywarn {
|
||||
backface-visibility: visible;
|
||||
filter: none;
|
||||
@@ -7619,17 +7597,18 @@ button {
|
||||
min-height: var(--springboard-grid-row);
|
||||
}
|
||||
.app-icon-button {
|
||||
font-family: var(--sky-font-family);
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', sans-serif;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
.app-icon-label {
|
||||
min-height: var(--sky-home-label-height);
|
||||
font-size: var(--sky-home-label-font-size);
|
||||
min-height: 15px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.15px;
|
||||
line-height: var(--sky-home-label-height);
|
||||
line-height: 15px;
|
||||
text-align: center;
|
||||
}
|
||||
.springboard-edit-add {
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const config = readFileSync(
|
||||
new URL('../../sky_phone/config/config.lua', import.meta.url),
|
||||
'utf8',
|
||||
).replace(/\r\n/g, '\n')
|
||||
const companiesServer = readFileSync(
|
||||
new URL('../../sky_phone/source/server/companies.lua', import.meta.url),
|
||||
'utf8',
|
||||
).replace(/\r\n/g, '\n')
|
||||
const configuratorServer = readFileSync(
|
||||
new URL(
|
||||
'../../sky_phone/source/server/phone_configurator.lua',
|
||||
import.meta.url,
|
||||
),
|
||||
'utf8',
|
||||
).replace(/\r\n/g, '\n')
|
||||
const testServer = readFileSync(
|
||||
new URL('../testserver/index.cjs', import.meta.url),
|
||||
'utf8',
|
||||
).replace(/\r\n/g, '\n')
|
||||
|
||||
function sourceBlock(source: string, startMarker: string, endMarker: string) {
|
||||
const start = source.indexOf(startMarker)
|
||||
const end = source.indexOf(endMarker, start)
|
||||
|
||||
expect(start).toBeGreaterThanOrEqual(0)
|
||||
expect(end).toBeGreaterThan(start)
|
||||
return source.slice(start, end)
|
||||
}
|
||||
|
||||
describe('Companies emergency request contract', () => {
|
||||
it('ships non-emergency police assistance as a requestable service', () => {
|
||||
const police = sourceBlock(
|
||||
config,
|
||||
' police = {',
|
||||
' ambulance = {',
|
||||
)
|
||||
const mockPolice = sourceBlock(
|
||||
testServer,
|
||||
'const companyProfiles = [',
|
||||
' {\n acceptsRequests: false,\n announcement: null,',
|
||||
)
|
||||
|
||||
expect(police).toContain('Emergency = true')
|
||||
expect(police).toContain('AcceptsRequests = true')
|
||||
expect(police).toContain('Id = "police-assistance"')
|
||||
expect(police).toContain('RequestsEnabled = true')
|
||||
expect(mockPolice).toContain('acceptsRequests: true')
|
||||
expect(mockPolice).toContain("name: 'Los Santos Police Department'")
|
||||
expect(mockPolice).toContain("id: 'police-assistance'")
|
||||
})
|
||||
|
||||
it('authorizes configured emergency companies through the normal request gates', () => {
|
||||
const validation = sourceBlock(
|
||||
companiesServer,
|
||||
'local function validate_configuration(configuration)',
|
||||
'local function seed_companies()',
|
||||
)
|
||||
const payload = sourceBlock(
|
||||
companiesServer,
|
||||
'local function company_payload(',
|
||||
'local function public_company(',
|
||||
)
|
||||
const createRequest = sourceBlock(
|
||||
companiesServer,
|
||||
'Bridge.Callbacks.Register("sky_phone:companies:create-request"',
|
||||
'Bridge.Callbacks.Register("sky_phone:companies:cancel-request"',
|
||||
)
|
||||
const updateProfile = sourceBlock(
|
||||
companiesServer,
|
||||
'Bridge.Callbacks.Register("sky_phone:companies:update-profile"',
|
||||
'Bridge.Callbacks.Register("sky_phone:companies:update-hours"',
|
||||
)
|
||||
|
||||
expect(validation).not.toContain(
|
||||
'definition.Emergency and definition.AcceptsRequests',
|
||||
)
|
||||
expect(payload).toContain(
|
||||
'acceptsRequests = tonumber(row.accepts_requests) == 1,',
|
||||
)
|
||||
expect(payload).not.toContain('not definition.Emergency')
|
||||
expect(createRequest).toContain(
|
||||
'if not definition or not definition.Public then',
|
||||
)
|
||||
expect(createRequest).not.toContain('definition.Emergency')
|
||||
expect(createRequest).toContain('SELECT `accepts_requests`')
|
||||
expect(createRequest).toContain('AND `requests_enabled` = 1')
|
||||
expect(updateProfile).not.toContain('member.definition.Emergency')
|
||||
})
|
||||
|
||||
it('migrates existing requestable emergency profiles exactly once', () => {
|
||||
const migration = sourceBlock(
|
||||
companiesServer,
|
||||
'local function migrate_requestable_emergency_companies()',
|
||||
'local function tombstone_removed_companies()',
|
||||
)
|
||||
const refresh = sourceBlock(
|
||||
companiesServer,
|
||||
'local function refresh_runtime_configuration()',
|
||||
'\n\nrefresh_runtime_configuration()',
|
||||
)
|
||||
|
||||
expect(migration).toContain('sky-phone:companies:requestable-emergency:v1')
|
||||
expect(migration).toContain(
|
||||
'if definition.Emergency and definition.AcceptsRequests then',
|
||||
)
|
||||
expect(migration).toContain('SET `accepts_requests` = 1')
|
||||
expect(migration).toContain('INSERT IGNORE INTO `sky_phone_migrations`')
|
||||
expect(migration).toContain('Bridge.Database.Transaction(statements)')
|
||||
expect(refresh.indexOf('seed_companies()')).toBeLessThan(
|
||||
refresh.indexOf('migrate_requestable_emergency_companies()'),
|
||||
)
|
||||
expect(
|
||||
refresh.indexOf('migrate_requestable_emergency_companies()'),
|
||||
).toBeLessThan(refresh.indexOf('tombstone_removed_companies()'))
|
||||
})
|
||||
|
||||
it('migrates the existing Phone Configurator police defaults', () => {
|
||||
const migration = sourceBlock(
|
||||
configuratorServer,
|
||||
'local function migrate_police_request_defaults()',
|
||||
'\n\ndefault_config = {}',
|
||||
)
|
||||
|
||||
expect(migration).toContain('sky-phone:configurator:police-requests:v1')
|
||||
expect(migration).toContain('police.AcceptsRequests == false')
|
||||
expect(migration).toContain('next(police.Services) == nil')
|
||||
expect(migration).toContain(
|
||||
'police.AcceptsRequests = defaults.AcceptsRequests',
|
||||
)
|
||||
expect(migration).toContain(
|
||||
'police.Services = copy_value(defaults.Services)',
|
||||
)
|
||||
expect(migration).toContain('SET `config_payload` = ?')
|
||||
expect(migration).toContain('`revision` = `revision` + 1')
|
||||
expect(migration).toContain('INSERT IGNORE INTO `sky_phone_migrations`')
|
||||
expect(migration).toContain('Bridge.Database.Transaction(statements)')
|
||||
expect(migration).toContain('apply_stored_row(read_stored_row())')
|
||||
expect(migration).toContain('apply_runtime_configuration()')
|
||||
expect(configuratorServer).toContain(
|
||||
'Bridge.Database.AfterMigration("sky_phone", migrate_police_request_defaults)',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -14,10 +14,6 @@ const callsServer = readFileSync(
|
||||
new URL('../../sky_phone/source/server/calls.lua', import.meta.url),
|
||||
'utf8',
|
||||
).replace(/\r\n/g, '\n')
|
||||
const phoneServer = readFileSync(
|
||||
new URL('../../sky_phone/source/server/phone.lua', import.meta.url),
|
||||
'utf8',
|
||||
).replace(/\r\n/g, '\n')
|
||||
const companiesStore = readFileSync(
|
||||
new URL('./stores/companies.ts', import.meta.url),
|
||||
'utf8',
|
||||
@@ -89,35 +85,3 @@ describe('Companies outbound service-line call contract', () => {
|
||||
expect(startCompanyCall).not.toContain('data.callerNumber')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Companies background call availability contract', () => {
|
||||
it('keeps call availability enabled after the phone UI closes', () => {
|
||||
const closeDevice = sourceBlock(
|
||||
phoneServer,
|
||||
`Bridge.Callbacks.Register(${quote}sky_phone:device:close${quote}`,
|
||||
`Bridge.Callbacks.Register(${quote}sky_phone:device:notification-open${quote}`,
|
||||
)
|
||||
|
||||
expect(closeDevice).toContain('sessions[source] = nil')
|
||||
expect(closeDevice).not.toContain(
|
||||
'SkyPhoneCompanies.ClearCallAvailability(source)',
|
||||
)
|
||||
})
|
||||
|
||||
it('routes background calls only to an owned phone with the same registered SIM', () => {
|
||||
const getCallTargets = sourceBlock(
|
||||
companiesServer,
|
||||
'function SkyPhoneCompanies.GetCallTargets(',
|
||||
'\n\nlocal function profile_row(',
|
||||
)
|
||||
|
||||
expect(getCallTargets).toContain('SkyPhone.LoadDevice(readiness.imei)')
|
||||
expect(getCallTargets).toContain(
|
||||
'SkyPhone.FindDeviceSlots(source, readiness.imei)',
|
||||
)
|
||||
expect(getCallTargets).toContain('device.sim_id == readiness.sim_id')
|
||||
expect(getCallTargets).toContain('device.sim_type == "registered"')
|
||||
expect(getCallTargets).toContain('device.registered_at ~= nil')
|
||||
expect(getCallTargets).not.toContain('current_device(source, true)')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
function source(path: string): string {
|
||||
return readFileSync(new URL(path, import.meta.url), 'utf8').replace(
|
||||
/\r\n/g,
|
||||
'\n',
|
||||
)
|
||||
}
|
||||
|
||||
function sourceBlock(
|
||||
value: string,
|
||||
startMarker: string,
|
||||
endMarker: string,
|
||||
): string {
|
||||
const start = value.indexOf(startMarker)
|
||||
const end = value.indexOf(endMarker, start)
|
||||
|
||||
expect(start).toBeGreaterThanOrEqual(0)
|
||||
expect(end).toBeGreaterThan(start)
|
||||
return value.slice(start, end)
|
||||
}
|
||||
|
||||
const companiesServer = source('../../sky_phone/source/server/companies.lua')
|
||||
const messagesServer = source('../../sky_phone/source/server/messages.lua')
|
||||
const databaseMigration = source('../../sky_phone/source/server/db_migrate.lua')
|
||||
const configDefaults = source(
|
||||
'../../sky_phone/source/shared/config_default.lua',
|
||||
)
|
||||
const messagesApp = source('./views/apps/MessagesApp.vue')
|
||||
const phoneFallbacks = source('./stores/phone.ts')
|
||||
const locales = ['en', 'de', 'es'].map((locale) =>
|
||||
source(`../../sky_phone/config/locales/${locale}.lua`),
|
||||
)
|
||||
|
||||
describe('Companies service-line message routing contract', () => {
|
||||
it('enables the police service line and persists an indexed route channel', () => {
|
||||
const police = sourceBlock(
|
||||
configDefaults,
|
||||
' police = {',
|
||||
' ambulance = {',
|
||||
)
|
||||
const schema = sourceBlock(
|
||||
databaseMigration,
|
||||
' name = "sky_phone_company_requests",',
|
||||
' name = "sky_phone_company_request_reads",',
|
||||
)
|
||||
|
||||
expect(police).toContain('Number = "911"')
|
||||
expect(police).toContain('CanMessage = true')
|
||||
expect(schema).toContain(
|
||||
"name = \"channel\", type = \"ENUM('app','service_line') NOT NULL DEFAULT 'app'\"",
|
||||
)
|
||||
expect(schema).toContain(
|
||||
'name = "idx_sky_phone_company_requests_service_line"',
|
||||
)
|
||||
})
|
||||
|
||||
it('resolves short service numbers before the normal SIM-number path', () => {
|
||||
const resolver = sourceBlock(
|
||||
messagesServer,
|
||||
'local function resolve_message_recipient(value)',
|
||||
'\n\nlocal function shared_contact',
|
||||
)
|
||||
const send = sourceBlock(
|
||||
messagesServer,
|
||||
'Bridge.Callbacks.Register("sky_phone:messages:send"',
|
||||
'\n\nend)',
|
||||
)
|
||||
|
||||
expect(resolver).toContain('SkyPhoneCompanies.GetServiceLine(value)')
|
||||
expect(resolver).toContain('return service_line.number, service_line')
|
||||
expect(messagesServer.match(/resolve_message_recipient\(/g)?.length).toBe(4)
|
||||
expect(send).toContain('service_line_text_only')
|
||||
expect(send).toContain('SkyPhoneCompanies.RouteServiceLineMessage')
|
||||
expect(send.indexOf('RouteServiceLineMessage')).toBeLessThan(
|
||||
send.indexOf('SELECT s.`id`, s.`phone_number`'),
|
||||
)
|
||||
})
|
||||
|
||||
it('atomically mirrors inbound SMS into one active company request', () => {
|
||||
const route = sourceBlock(
|
||||
companiesServer,
|
||||
'function SkyPhoneCompanies.RouteServiceLineMessage(source, data)',
|
||||
'Bridge.Callbacks.Register("sky_phone:companies:create-request"',
|
||||
)
|
||||
|
||||
expect(route).toContain('current_device(source, false)')
|
||||
expect(route).toContain('service_line.canMessage')
|
||||
expect(route).toContain('UPDATE `sky_phone_sims`')
|
||||
expect(route).toContain("`channel` = 'service_line'")
|
||||
expect(route).toContain('AND NOT EXISTS (')
|
||||
expect(route).toContain('INSERT INTO `sky_phone_company_request_messages`')
|
||||
expect(route).toContain('INSERT INTO `sky_phone_sms_messages`')
|
||||
expect(route).toContain('Bridge.Database.Transaction(statements)')
|
||||
expect(route).toContain('emit_request_change(row, true, true, source)')
|
||||
expect(route).toContain('"sky_phone:companies:notification"')
|
||||
})
|
||||
|
||||
it('mirrors authorized company replies back to the customer SMS thread', () => {
|
||||
const sendMessage = sourceBlock(
|
||||
companiesServer,
|
||||
'Bridge.Callbacks.Register("sky_phone:companies:send-message"',
|
||||
'Bridge.Callbacks.Register("sky_phone:companies:claim-request"',
|
||||
)
|
||||
|
||||
expect(sendMessage).toContain('access.row.channel == "service_line"')
|
||||
expect(
|
||||
sendMessage.match(/INSERT INTO `sky_phone_sms_messages`/g)?.length,
|
||||
).toBe(2)
|
||||
expect(sendMessage).toContain("message.`sender_type` = 'customer'")
|
||||
expect(sendMessage).toContain("message.`sender_type` = 'company'")
|
||||
expect(sendMessage).toContain('"sky_phone:messages:changed"')
|
||||
expect(sendMessage).toContain('"sky_phone:messages:new"')
|
||||
})
|
||||
|
||||
it('keeps service-line composition text-only in every shipped locale', () => {
|
||||
expect(messagesApp).toContain(
|
||||
"() => activeContact.value?.source === 'company'",
|
||||
)
|
||||
expect(messagesApp).toContain(
|
||||
'activeCanMessage && !activeServiceLine && attachmentMenuOpen',
|
||||
)
|
||||
expect(messagesApp).toContain('v-if="!activeServiceLine"')
|
||||
expect(messagesApp).toContain('v-else-if="!activeServiceLine"')
|
||||
expect(messagesApp).toContain("'service_line_text_only'")
|
||||
expect(phoneFallbacks).toContain('service_line_text_only:')
|
||||
for (const locale of locales) {
|
||||
expect(locale).toContain('service_line_text_only =')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,139 +0,0 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const companiesServer = readFileSync(
|
||||
new URL('../../sky_phone/source/server/companies.lua', import.meta.url),
|
||||
'utf8',
|
||||
).replace(/\r\n/g, '\n')
|
||||
const configuratorServer = readFileSync(
|
||||
new URL(
|
||||
'../../sky_phone/source/server/phone_configurator.lua',
|
||||
import.meta.url,
|
||||
),
|
||||
'utf8',
|
||||
).replace(/\r\n/g, '\n')
|
||||
const englishLocale = readFileSync(
|
||||
new URL('../../sky_phone/config/locales/en.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const germanLocale = readFileSync(
|
||||
new URL('../../sky_phone/config/locales/de.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const spanishLocale = readFileSync(
|
||||
new URL('../../sky_phone/config/locales/es.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
describe('company configurator creation contract', () => {
|
||||
it('publishes complete defaults for newly added company definitions', () => {
|
||||
expect(configuratorServer).toContain(
|
||||
'local function company_definition_entry_default(company_id, configuration)',
|
||||
)
|
||||
expect(configuratorServer).toContain(
|
||||
'local function next_available_company_service_number(configuration)',
|
||||
)
|
||||
expect(configuratorServer).toContain(
|
||||
'entryDefault = company_definition_entry_default()',
|
||||
)
|
||||
expect(configuratorServer).toContain('Routing = "round_robin"')
|
||||
expect(configuratorServer).toContain('RequestsEnabled = true')
|
||||
})
|
||||
|
||||
it('validates the candidate runtime config before encoding or writing SQL', () => {
|
||||
const save = configuratorServer.slice(
|
||||
configuratorServer.indexOf('function SkyPhoneConfigurator.Save('),
|
||||
)
|
||||
const validation = save.indexOf(
|
||||
'SkyPhoneCompanies.ValidateConfiguration(candidate_config)',
|
||||
)
|
||||
const encoding = save.indexOf(
|
||||
'local config_encoded = encode_payload(next_config, "config")',
|
||||
)
|
||||
const update = save.indexOf('UPDATE `%s`')
|
||||
|
||||
expect(validation).toBeGreaterThanOrEqual(0)
|
||||
expect(validation).toBeLessThan(encoding)
|
||||
expect(validation).toBeLessThan(update)
|
||||
expect(save).toContain(
|
||||
'return { success = false, error = "invalid_company_configuration" }',
|
||||
)
|
||||
})
|
||||
|
||||
it('validates into isolated registries before replacing the live company state', () => {
|
||||
expect(companiesServer).toContain(
|
||||
'function SkyPhoneCompanies.ValidateConfiguration(configuration)',
|
||||
)
|
||||
expect(companiesServer).toContain(
|
||||
'local validated, validation_error = validate_configuration(configuration)',
|
||||
)
|
||||
expect(companiesServer).toContain('definitions = validated.definitions')
|
||||
expect(companiesServer).toContain(
|
||||
'definition_ids = validated.definition_ids',
|
||||
)
|
||||
})
|
||||
|
||||
it('localizes rejected company configurations in every shipped locale', () => {
|
||||
for (const locale of [englishLocale, germanLocale, spanishLocale]) {
|
||||
expect(locale).toContain('invalid_company_configuration =')
|
||||
}
|
||||
})
|
||||
|
||||
it('repairs company rows created by the legacy blank schema before Companies starts', () => {
|
||||
const migration = configuratorServer.slice(
|
||||
configuratorServer.indexOf(
|
||||
'local function migrate_blank_company_definitions()',
|
||||
),
|
||||
configuratorServer.indexOf(
|
||||
'local function migrate_police_request_defaults()',
|
||||
),
|
||||
)
|
||||
const blankRegistration = configuratorServer.indexOf(
|
||||
'Bridge.Database.AfterMigration("sky_phone", migrate_blank_company_definitions)',
|
||||
)
|
||||
const policeRegistration = configuratorServer.indexOf(
|
||||
'Bridge.Database.AfterMigration("sky_phone", migrate_police_request_defaults)',
|
||||
)
|
||||
|
||||
expect(migration).toContain(
|
||||
'sky-phone:configurator:company-definition-defaults:v1',
|
||||
)
|
||||
expect(migration).toContain('company_definition_entry_default(')
|
||||
expect(migration).toContain('Bridge.Database.Transaction(statements)')
|
||||
expect(migration).toContain('SET `config_payload` = ?')
|
||||
expect(migration).toContain('`revision` = `revision` + 1')
|
||||
expect(blankRegistration).toBeGreaterThanOrEqual(0)
|
||||
expect(blankRegistration).toBeLessThan(policeRegistration)
|
||||
})
|
||||
|
||||
it('enables persisted police service-line messaging before Companies starts', () => {
|
||||
const migration = configuratorServer.slice(
|
||||
configuratorServer.indexOf(
|
||||
'local function migrate_police_service_line_messaging()',
|
||||
),
|
||||
configuratorServer.indexOf('\n\ndefault_config = {}'),
|
||||
)
|
||||
const policeRegistration = configuratorServer.indexOf(
|
||||
'Bridge.Database.AfterMigration("sky_phone", migrate_police_request_defaults)',
|
||||
)
|
||||
const messageRegistration = configuratorServer.indexOf(
|
||||
'Bridge.Database.AfterMigration("sky_phone", migrate_police_service_line_messaging)',
|
||||
)
|
||||
|
||||
expect(migration).toContain(
|
||||
'sky-phone:configurator:service-line-messaging:v2',
|
||||
)
|
||||
expect(migration).toContain('line.CanMessage ~= true')
|
||||
expect(migration).toContain('line.CanMessage = true')
|
||||
expect(migration).toContain('Bridge.Database.Transaction(statements)')
|
||||
expect(migration).toContain('SET `config_payload` = ?')
|
||||
expect(migration).toContain('`revision` = `revision` + 1')
|
||||
expect(migration).toContain('apply_stored_row(read_stored_row())')
|
||||
expect(migration).toContain('apply_runtime_configuration()')
|
||||
expect(messageRegistration).toBeGreaterThan(policeRegistration)
|
||||
expect(companiesServer).not.toContain(
|
||||
'enables messaging without a virtual service-line message router',
|
||||
)
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,809 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
FileAudio,
|
||||
FileCode2,
|
||||
LoaderCircle,
|
||||
LockKeyhole,
|
||||
Pause,
|
||||
Play,
|
||||
Plus,
|
||||
Trash2,
|
||||
Upload,
|
||||
} from 'lucide-vue-next'
|
||||
import { computed, onBeforeUnmount, ref } from 'vue'
|
||||
|
||||
import { useAdminStore } from '@/stores/admin'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import type { AdminCustomTone } from '@/types/admin'
|
||||
import { SkyButton } from '@/ui'
|
||||
import { MAX_CUSTOM_TONE_BYTES, playCustomPhoneTone } from '@/utils/customTones'
|
||||
|
||||
const MAX_DURATION_MS = 30_000
|
||||
const AUDIO_METADATA_TIMEOUT_MS = 8_000
|
||||
const props = defineProps<{ disabled?: boolean }>()
|
||||
const emit = defineEmits<{
|
||||
toast: [message: string, tone: 'error' | 'success']
|
||||
}>()
|
||||
const admin = useAdminStore()
|
||||
const phone = usePhoneStore()
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const fileInputResetKey = ref(0)
|
||||
const label = ref('')
|
||||
const toneType = ref<'notification' | 'ringtone'>('ringtone')
|
||||
const selectedFile = ref<File | null>(null)
|
||||
const selectedMimeType = ref('')
|
||||
const selectedDurationMs = ref(0)
|
||||
const previewUrl = ref('')
|
||||
const processingFile = ref(false)
|
||||
const playingToneId = ref('')
|
||||
const pendingDeleteId = ref('')
|
||||
let stopPreview: (() => void) | null = null
|
||||
let previewTimer: number | undefined
|
||||
|
||||
const ringtoneTones = computed(() =>
|
||||
admin.customTones.filter((tone) => tone.toneType === 'ringtone'),
|
||||
)
|
||||
const notificationTones = computed(() =>
|
||||
admin.customTones.filter((tone) => tone.toneType === 'notification'),
|
||||
)
|
||||
const saving = computed(() => admin.actionKey === 'custom-tone:create')
|
||||
const canSave = computed(
|
||||
() =>
|
||||
!props.disabled &&
|
||||
!processingFile.value &&
|
||||
!saving.value &&
|
||||
label.value.trim().length >= 1 &&
|
||||
label.value.trim().length <= 64 &&
|
||||
!!selectedFile.value &&
|
||||
!!selectedMimeType.value &&
|
||||
selectedDurationMs.value >= 250 &&
|
||||
selectedDurationMs.value <= MAX_DURATION_MS,
|
||||
)
|
||||
|
||||
function t(key: string, params?: Record<string, string>): string {
|
||||
return phone.t(`AdminPanel.configurator.customTones.${key}`, params)
|
||||
}
|
||||
|
||||
function errorText(error?: string): string {
|
||||
const key = error || admin.error || 'request_failed'
|
||||
const translated = phone.t(`AdminPanel.errors.${key}`)
|
||||
return translated === `AdminPanel.errors.${key}`
|
||||
? phone.t('AdminPanel.errors.default')
|
||||
: translated
|
||||
}
|
||||
|
||||
function normalizedMimeType(file: File): string {
|
||||
const aliases: Record<string, string> = {
|
||||
'audio/mp3': 'audio/mpeg',
|
||||
'audio/x-wav': 'audio/wav',
|
||||
}
|
||||
const browserType = file.type.toLocaleLowerCase().split(';')[0]
|
||||
if (aliases[browserType]) return aliases[browserType]
|
||||
if (
|
||||
['audio/mpeg', 'audio/ogg', 'audio/wav', 'audio/webm'].includes(browserType)
|
||||
) {
|
||||
return browserType
|
||||
}
|
||||
const extension = file.name.split('.').pop()?.toLocaleLowerCase()
|
||||
return (
|
||||
{
|
||||
mp3: 'audio/mpeg',
|
||||
ogg: 'audio/ogg',
|
||||
wav: 'audio/wav',
|
||||
webm: 'audio/webm',
|
||||
}[extension ?? ''] ?? ''
|
||||
)
|
||||
}
|
||||
|
||||
function audioDuration(url: string): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const audio = new Audio(url)
|
||||
audio.preload = 'metadata'
|
||||
let settled = false
|
||||
const finish = (duration?: number): void => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
window.clearTimeout(timeout)
|
||||
audio.removeEventListener('loadedmetadata', handleMetadata)
|
||||
audio.removeEventListener('error', handleError)
|
||||
audio.removeAttribute('src')
|
||||
audio.load()
|
||||
if (duration !== undefined && Number.isFinite(duration)) {
|
||||
resolve(Math.round(duration * 1000))
|
||||
} else {
|
||||
reject(new Error('invalid_audio'))
|
||||
}
|
||||
}
|
||||
const handleMetadata = (): void => finish(audio.duration)
|
||||
const handleError = (): void => finish()
|
||||
const timeout = window.setTimeout(finish, AUDIO_METADATA_TIMEOUT_MS)
|
||||
audio.addEventListener('loadedmetadata', handleMetadata, { once: true })
|
||||
audio.addEventListener('error', handleError, { once: true })
|
||||
})
|
||||
}
|
||||
|
||||
function resetFileInput(target = fileInput.value): void {
|
||||
if (target) target.value = ''
|
||||
fileInputResetKey.value += 1
|
||||
}
|
||||
|
||||
function releaseSelectedFile(resetInput = true): void {
|
||||
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
|
||||
previewUrl.value = ''
|
||||
selectedFile.value = null
|
||||
selectedMimeType.value = ''
|
||||
selectedDurationMs.value = 0
|
||||
if (resetInput) resetFileInput()
|
||||
}
|
||||
|
||||
async function chooseFile(event: Event): Promise<void> {
|
||||
const target = event.target
|
||||
if (!(target instanceof HTMLInputElement)) return
|
||||
const file = target.files?.[0]
|
||||
releaseSelectedFile(false)
|
||||
resetFileInput(target)
|
||||
if (!file) return
|
||||
const mimeType = normalizedMimeType(file)
|
||||
if (!mimeType) {
|
||||
emit('toast', t('errors.type'), 'error')
|
||||
return
|
||||
}
|
||||
if (file.size < 1 || file.size > MAX_CUSTOM_TONE_BYTES) {
|
||||
emit('toast', t('errors.size'), 'error')
|
||||
return
|
||||
}
|
||||
|
||||
processingFile.value = true
|
||||
const url = URL.createObjectURL(file)
|
||||
try {
|
||||
const durationMs = await audioDuration(url)
|
||||
if (durationMs < 250 || durationMs > MAX_DURATION_MS) {
|
||||
URL.revokeObjectURL(url)
|
||||
emit('toast', t('errors.duration'), 'error')
|
||||
return
|
||||
}
|
||||
selectedFile.value = file
|
||||
selectedMimeType.value = mimeType
|
||||
selectedDurationMs.value = durationMs
|
||||
previewUrl.value = url
|
||||
if (!label.value.trim()) {
|
||||
label.value = file.name.replace(/\.[^.]+$/, '').slice(0, 64)
|
||||
}
|
||||
} catch (error) {
|
||||
URL.revokeObjectURL(url)
|
||||
console.error('[Phone admin] Could not read the selected tone.', error)
|
||||
emit('toast', t('errors.invalid'), 'error')
|
||||
} finally {
|
||||
processingFile.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function fileBase64(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.addEventListener('load', () => {
|
||||
if (typeof reader.result !== 'string') {
|
||||
reject(new Error('invalid_audio'))
|
||||
return
|
||||
}
|
||||
const delimiter = reader.result.indexOf(',')
|
||||
if (delimiter < 0) {
|
||||
reject(new Error('invalid_audio'))
|
||||
return
|
||||
}
|
||||
resolve(reader.result.slice(delimiter + 1))
|
||||
})
|
||||
reader.addEventListener('error', () => reject(reader.error))
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
}
|
||||
|
||||
function stopActivePreview(): void {
|
||||
stopPreview?.()
|
||||
stopPreview = null
|
||||
playingToneId.value = ''
|
||||
if (previewTimer !== undefined) window.clearTimeout(previewTimer)
|
||||
previewTimer = undefined
|
||||
}
|
||||
|
||||
function previewSelected(): void {
|
||||
if (!previewUrl.value) return
|
||||
if (playingToneId.value === 'selected') {
|
||||
stopActivePreview()
|
||||
return
|
||||
}
|
||||
stopActivePreview()
|
||||
const player = new Audio(previewUrl.value)
|
||||
player.volume = 0.75
|
||||
player.addEventListener('ended', stopActivePreview, { once: true })
|
||||
void player.play().catch((error: unknown) => {
|
||||
console.error('[Phone admin] Could not preview selected tone.', error)
|
||||
emit('toast', t('errors.playback'), 'error')
|
||||
})
|
||||
playingToneId.value = 'selected'
|
||||
stopPreview = () => {
|
||||
player.pause()
|
||||
player.currentTime = 0
|
||||
}
|
||||
}
|
||||
|
||||
function previewStored(tone: AdminCustomTone): void {
|
||||
if (playingToneId.value === tone.id) {
|
||||
stopActivePreview()
|
||||
return
|
||||
}
|
||||
stopActivePreview()
|
||||
playingToneId.value = tone.id
|
||||
stopPreview = playCustomPhoneTone(
|
||||
{
|
||||
...tone,
|
||||
id: `custom:${tone.id}`,
|
||||
},
|
||||
75,
|
||||
false,
|
||||
{
|
||||
onError: () => {
|
||||
if (playingToneId.value !== tone.id) return
|
||||
stopActivePreview()
|
||||
emit('toast', t('errors.playback'), 'error')
|
||||
},
|
||||
onStarted: () => {
|
||||
if (playingToneId.value !== tone.id) return
|
||||
if (previewTimer !== undefined) window.clearTimeout(previewTimer)
|
||||
previewTimer = window.setTimeout(
|
||||
stopActivePreview,
|
||||
Math.min(MAX_DURATION_MS, tone.durationMs) + 750,
|
||||
)
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
async function saveTone(): Promise<void> {
|
||||
const file = selectedFile.value
|
||||
const name = label.value.trim()
|
||||
if (!canSave.value || !file) return
|
||||
try {
|
||||
const payload = await fileBase64(file)
|
||||
const response = await admin.createCustomTone({
|
||||
durationMs: selectedDurationMs.value,
|
||||
label: name,
|
||||
mimeType: selectedMimeType.value,
|
||||
payload,
|
||||
toneType: toneType.value,
|
||||
})
|
||||
if (!response.success) {
|
||||
emit('toast', errorText(response.error), 'error')
|
||||
return
|
||||
}
|
||||
label.value = ''
|
||||
releaseSelectedFile()
|
||||
emit('toast', t('saved'), 'success')
|
||||
} catch (error) {
|
||||
console.error('[Phone admin] Could not prepare the custom tone.', error)
|
||||
emit('toast', t('errors.invalid'), 'error')
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTone(tone: AdminCustomTone): Promise<void> {
|
||||
if (pendingDeleteId.value !== tone.id) {
|
||||
pendingDeleteId.value = tone.id
|
||||
return
|
||||
}
|
||||
stopActivePreview()
|
||||
const response = await admin.deleteCustomTone(tone.id)
|
||||
pendingDeleteId.value = ''
|
||||
if (!response.success) {
|
||||
emit('toast', errorText(response.error), 'error')
|
||||
return
|
||||
}
|
||||
emit('toast', t('deleted'), 'success')
|
||||
}
|
||||
|
||||
function formatDuration(durationMs: number): string {
|
||||
return `${(durationMs / 1000).toLocaleString(phone.lang, {
|
||||
maximumFractionDigits: 1,
|
||||
})} s`
|
||||
}
|
||||
|
||||
function formatBytes(byteSize: number): string {
|
||||
return `${Math.ceil(byteSize / 1024).toLocaleString(phone.lang)} KB`
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopActivePreview()
|
||||
releaseSelectedFile(false)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="admin-custom-tones">
|
||||
<article class="admin-custom-tones__config-note">
|
||||
<FileCode2 :size="18" />
|
||||
<div>
|
||||
<strong>{{ t('configTitle') }}</strong>
|
||||
<p>{{ t('configBody') }}</p>
|
||||
<code>Config.CustomTones · config/custom_tones/</code>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div class="admin-custom-tones__form">
|
||||
<label>
|
||||
<span>{{ t('name') }}</span>
|
||||
<input
|
||||
v-model="label"
|
||||
type="text"
|
||||
maxlength="64"
|
||||
autocomplete="off"
|
||||
:disabled="disabled || saving"
|
||||
:placeholder="t('namePlaceholder')"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<fieldset :disabled="disabled || saving">
|
||||
<legend>{{ t('category') }}</legend>
|
||||
<button
|
||||
type="button"
|
||||
:class="{ 'is-active': toneType === 'ringtone' }"
|
||||
@click="toneType = 'ringtone'"
|
||||
>
|
||||
{{ t('ringtone') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="{ 'is-active': toneType === 'notification' }"
|
||||
@click="toneType = 'notification'"
|
||||
>
|
||||
{{ t('notification') }}
|
||||
</button>
|
||||
</fieldset>
|
||||
|
||||
<label
|
||||
class="admin-custom-tones__picker"
|
||||
:class="{
|
||||
'is-disabled': disabled || saving || processingFile,
|
||||
'has-file': selectedFile,
|
||||
}"
|
||||
>
|
||||
<input
|
||||
:key="fileInputResetKey"
|
||||
ref="fileInput"
|
||||
class="admin-custom-tones__file-input"
|
||||
type="file"
|
||||
accept=".mp3,.ogg,.wav,.webm,audio/mpeg,audio/ogg,audio/wav,audio/webm"
|
||||
:disabled="disabled || saving || processingFile"
|
||||
@change="chooseFile"
|
||||
/>
|
||||
<LoaderCircle v-if="processingFile" :size="20" class="is-spinning" />
|
||||
<Upload v-else :size="20" />
|
||||
<span>
|
||||
<strong>{{ selectedFile?.name ?? t('chooseFile') }}</strong>
|
||||
<small>
|
||||
{{
|
||||
selectedFile
|
||||
? `${formatDuration(selectedDurationMs)} · ${formatBytes(selectedFile.size)}`
|
||||
: t('fileHint')
|
||||
}}
|
||||
</small>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div class="admin-custom-tones__actions">
|
||||
<SkyButton
|
||||
small
|
||||
variant="secondary"
|
||||
:disabled="!previewUrl || saving"
|
||||
@click="previewSelected"
|
||||
>
|
||||
<Pause v-if="playingToneId === 'selected'" :size="17" />
|
||||
<Play v-else :size="17" />
|
||||
{{ t('preview') }}
|
||||
</SkyButton>
|
||||
<SkyButton small :disabled="!canSave" @click="saveTone">
|
||||
<LoaderCircle v-if="saving" :size="17" class="is-spinning" />
|
||||
<Plus v-else :size="17" />
|
||||
{{ t('add') }}
|
||||
</SkyButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="admin.customTonesLoading" class="admin-custom-tones__loading">
|
||||
<LoaderCircle :size="22" class="is-spinning" />
|
||||
{{ t('loading') }}
|
||||
</div>
|
||||
<div v-else class="admin-custom-tones__catalog">
|
||||
<section
|
||||
v-for="group in [
|
||||
{ key: 'ringtone', label: t('ringtones'), tones: ringtoneTones },
|
||||
{
|
||||
key: 'notification',
|
||||
label: t('notifications'),
|
||||
tones: notificationTones,
|
||||
},
|
||||
]"
|
||||
:key="group.key"
|
||||
>
|
||||
<header>
|
||||
<strong>{{ group.label }}</strong>
|
||||
<span>{{ group.tones.length }}/32</span>
|
||||
</header>
|
||||
<p v-if="!group.tones.length" class="admin-custom-tones__empty">
|
||||
{{ t('empty') }}
|
||||
</p>
|
||||
<article v-for="tone in group.tones" :key="tone.id">
|
||||
<span class="admin-custom-tones__tone-icon"
|
||||
><FileAudio :size="18"
|
||||
/></span>
|
||||
<span class="admin-custom-tones__tone-copy">
|
||||
<strong>{{ tone.label }}</strong>
|
||||
<small>
|
||||
{{ formatDuration(tone.durationMs) }} ·
|
||||
{{ formatBytes(tone.byteSize) }} ·
|
||||
{{
|
||||
tone.source === 'config' ? t('configSource') : tone.createdBy
|
||||
}}
|
||||
</small>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="t('preview')"
|
||||
@click="previewStored(tone)"
|
||||
>
|
||||
<Pause v-if="playingToneId === tone.id" :size="17" />
|
||||
<Play v-else :size="17" />
|
||||
</button>
|
||||
<button
|
||||
v-if="tone.source === 'database'"
|
||||
type="button"
|
||||
class="is-danger"
|
||||
:class="{ 'is-confirming': pendingDeleteId === tone.id }"
|
||||
:aria-label="
|
||||
pendingDeleteId === tone.id ? t('confirmDelete') : t('delete')
|
||||
"
|
||||
:disabled="admin.actionKey === `custom-tone:delete:${tone.id}`"
|
||||
@click="deleteTone(tone)"
|
||||
>
|
||||
<LoaderCircle
|
||||
v-if="admin.actionKey === `custom-tone:delete:${tone.id}`"
|
||||
:size="17"
|
||||
class="is-spinning"
|
||||
/>
|
||||
<Trash2 v-else :size="17" />
|
||||
</button>
|
||||
<span
|
||||
v-else
|
||||
class="admin-custom-tones__locked"
|
||||
:title="t('configManaged')"
|
||||
:aria-label="t('configManaged')"
|
||||
>
|
||||
<LockKeyhole :size="15" />
|
||||
</span>
|
||||
</article>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-custom-tones {
|
||||
--sky-app-accent: var(--admin-green, #5ccb70);
|
||||
--sky-button-text: #fff;
|
||||
--sky-surface-muted: #1b1e1b;
|
||||
--sky-text: #fff;
|
||||
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
color: var(--admin-text, #f0f3f0);
|
||||
}
|
||||
|
||||
.admin-custom-tones__config-note {
|
||||
display: grid;
|
||||
grid-template-columns: 26px minmax(0, 1fr);
|
||||
align-items: start;
|
||||
gap: 9px;
|
||||
padding: 10px 11px;
|
||||
border-radius: 3px;
|
||||
background: linear-gradient(90deg, rgb(0 184 228 / 11%), transparent 82%);
|
||||
}
|
||||
|
||||
.admin-custom-tones__config-note > svg {
|
||||
color: var(--admin-accent, #00b8e4);
|
||||
}
|
||||
|
||||
.admin-custom-tones__config-note div {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.admin-custom-tones__config-note strong {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.admin-custom-tones__config-note p {
|
||||
margin: 0;
|
||||
color: var(--admin-muted, #818781);
|
||||
font-size: 9px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.admin-custom-tones__config-note code {
|
||||
width: fit-content;
|
||||
margin-top: 2px;
|
||||
padding: 3px 5px;
|
||||
border-radius: 3px;
|
||||
color: #8edcf0;
|
||||
background: rgb(0 0 0 / 25%);
|
||||
font-size: 8px;
|
||||
}
|
||||
|
||||
.admin-custom-tones__tone-icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: 0 0 40px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
background: rgb(0 194 255 / 10%);
|
||||
color: #14c9ff;
|
||||
}
|
||||
|
||||
.admin-custom-tones__empty {
|
||||
margin: 4px 0 0;
|
||||
color: #8f9994;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.admin-custom-tones__form {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 1fr) minmax(230px, 0.8fr);
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--admin-border, rgb(255 255 255 / 5%));
|
||||
border-radius: 3px;
|
||||
background: var(--admin-panel-raised, #131514);
|
||||
}
|
||||
|
||||
.admin-custom-tones__form label,
|
||||
.admin-custom-tones__form fieldset {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.admin-custom-tones__form label > span,
|
||||
.admin-custom-tones__form legend {
|
||||
color: #aab3af;
|
||||
font-size: 8px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.admin-custom-tones__form input[type='text'] {
|
||||
min-height: 36px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--admin-border-strong, rgb(255 255 255 / 9%));
|
||||
border-radius: 4px;
|
||||
outline: none;
|
||||
background: #1b1e1b;
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.admin-custom-tones__form input[type='text']:focus {
|
||||
border-color: #14c9ff;
|
||||
box-shadow: 0 0 0 2px rgb(20 201 255 / 14%);
|
||||
}
|
||||
|
||||
.admin-custom-tones__form fieldset {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.admin-custom-tones__form legend {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.admin-custom-tones__form fieldset button {
|
||||
min-height: 36px;
|
||||
border: 1px solid var(--admin-border-strong, rgb(255 255 255 / 9%));
|
||||
border-radius: 4px;
|
||||
background: #1b1e1b;
|
||||
color: #b7c0bc;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.admin-custom-tones__form fieldset button.is-active {
|
||||
border-color: #14c9ff;
|
||||
background: rgb(20 201 255 / 12%);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.admin-custom-tones__file-input {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.admin-custom-tones__form .admin-custom-tones__picker {
|
||||
position: relative;
|
||||
display: flex;
|
||||
grid-column: 1 / -1;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
min-height: 52px;
|
||||
padding: 8px 11px;
|
||||
border: 1px dashed var(--admin-border-strong, rgb(255 255 255 / 9%));
|
||||
border-radius: 4px;
|
||||
background: #111311;
|
||||
color: #dfe7e3;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.admin-custom-tones__picker:focus-within,
|
||||
.admin-custom-tones__picker:hover:not(.is-disabled) {
|
||||
border-color: var(--admin-accent, #00b8e4);
|
||||
background: rgb(0 184 228 / 6%);
|
||||
}
|
||||
|
||||
.admin-custom-tones__picker.is-disabled {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.admin-custom-tones__picker > span,
|
||||
.admin-custom-tones__tone-copy {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-custom-tones__picker strong,
|
||||
.admin-custom-tones__tone-copy strong {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.admin-custom-tones__picker small,
|
||||
.admin-custom-tones__tone-copy small {
|
||||
color: #89938e;
|
||||
font-size: 8px;
|
||||
}
|
||||
|
||||
.admin-custom-tones__actions {
|
||||
display: flex;
|
||||
grid-column: 1 / -1;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.admin-custom-tones__actions :deep(.sky-button) {
|
||||
flex: 1 1 0;
|
||||
border-color: var(--admin-border-strong, rgb(255 255 255 / 9%));
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.admin-custom-tones__catalog {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.admin-custom-tones__catalog > section {
|
||||
overflow: hidden;
|
||||
border: 1px solid rgb(255 255 255 / 7%);
|
||||
border-radius: 3px;
|
||||
background: #111311;
|
||||
}
|
||||
|
||||
.admin-custom-tones__catalog > section > header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 44px;
|
||||
padding: 0 10px;
|
||||
border-bottom: 1px solid rgb(255 255 255 / 7%);
|
||||
}
|
||||
|
||||
.admin-custom-tones__catalog > section > header span {
|
||||
color: #82908a;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.admin-custom-tones__catalog > section > header strong {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.admin-custom-tones__catalog article {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
min-height: 54px;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid rgb(255 255 255 / 5%);
|
||||
}
|
||||
|
||||
.admin-custom-tones__catalog article:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.admin-custom-tones__tone-icon {
|
||||
flex-basis: 30px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.admin-custom-tones__tone-copy {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.admin-custom-tones__catalog article > button {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: 0 0 32px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid var(--admin-border-strong, rgb(255 255 255 / 9%));
|
||||
border-radius: 4px;
|
||||
background: #151a18;
|
||||
color: #dfe7e3;
|
||||
}
|
||||
|
||||
.admin-custom-tones__locked {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: 0 0 32px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
color: var(--admin-dim, #555b55);
|
||||
}
|
||||
|
||||
.admin-custom-tones__catalog article > button.is-danger {
|
||||
color: #ff6875;
|
||||
}
|
||||
|
||||
.admin-custom-tones__catalog article > button.is-confirming {
|
||||
border-color: #ff5263;
|
||||
background: rgb(255 82 99 / 14%);
|
||||
}
|
||||
|
||||
.admin-custom-tones__empty,
|
||||
.admin-custom-tones__loading {
|
||||
padding: 18px 13px;
|
||||
}
|
||||
|
||||
.admin-custom-tones__loading {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
color: #98a39e;
|
||||
}
|
||||
|
||||
.is-spinning {
|
||||
animation: admin-custom-tone-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes admin-custom-tone-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.admin-custom-tones__form,
|
||||
.admin-custom-tones__catalog {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,527 +0,0 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const source = readFileSync(
|
||||
new URL('./AdminPanel.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const agentInstructions = readFileSync(
|
||||
new URL('../../../AGENTS.md', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const app = readFileSync(new URL('../App.vue', import.meta.url), 'utf8')
|
||||
const apps = readFileSync(new URL('../config/apps.ts', import.meta.url), 'utf8')
|
||||
const store = readFileSync(
|
||||
new URL('../stores/admin.ts', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const server = readFileSync(
|
||||
new URL('../../../sky_phone/source/server/admin.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const phoneServer = readFileSync(
|
||||
new URL('../../../sky_phone/source/server/phone.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const persistenceServer = readFileSync(
|
||||
new URL(
|
||||
'../../../sky_phone/source/server/phone_persistence.lua',
|
||||
import.meta.url,
|
||||
),
|
||||
'utf8',
|
||||
)
|
||||
const simServer = readFileSync(
|
||||
new URL('../../../sky_phone/source/server/sim.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const phoneClient = readFileSync(
|
||||
new URL('../../../sky_phone/source/client/main.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const focusClient = readFileSync(
|
||||
new URL('../../../sky_phone/source/client/focus.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const bridge = readFileSync(
|
||||
new URL(
|
||||
'../../../sky_phone/source/client/nui_server_bridge.lua',
|
||||
import.meta.url,
|
||||
),
|
||||
'utf8',
|
||||
)
|
||||
const config = readFileSync(
|
||||
new URL('../../../sky_phone/config/config.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const configDefaultsSource = config.replace(
|
||||
/\r?\n?-- CONFIG_DEFAULT_EXCLUDE_START[\s\S]*?-- CONFIG_DEFAULT_EXCLUDE_END\r?\n?/g,
|
||||
'\n',
|
||||
)
|
||||
const mediaConfig = readFileSync(
|
||||
new URL('../../../sky_phone/config/media.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const schema = readFileSync(
|
||||
new URL('../../../sky_phone/sql/install.sql', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const manifest = readFileSync(
|
||||
new URL('../../../sky_phone/fxmanifest.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const configuratorServer = readFileSync(
|
||||
new URL(
|
||||
'../../../sky_phone/source/server/phone_configurator.lua',
|
||||
import.meta.url,
|
||||
),
|
||||
'utf8',
|
||||
)
|
||||
const configDefault = readFileSync(
|
||||
new URL(
|
||||
'../../../sky_phone/source/shared/config_default.lua',
|
||||
import.meta.url,
|
||||
),
|
||||
'utf8',
|
||||
)
|
||||
const frontendBuild = readFileSync(
|
||||
new URL('../../build.cjs', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const configuratorClient = readFileSync(
|
||||
new URL(
|
||||
'../../../sky_phone/source/client/phone_configurator.lua',
|
||||
import.meta.url,
|
||||
),
|
||||
'utf8',
|
||||
)
|
||||
const mediaImportServer = readFileSync(
|
||||
new URL('../../../sky_phone/source/server/media_import.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const configuratorValueEditor = readFileSync(
|
||||
new URL('./AdminConfigValueEditor.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const configInputWidth = readFileSync(
|
||||
new URL('../directives/configInputWidth.ts', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
describe('standalone admin panel contracts', () => {
|
||||
it('renders as a dedicated full-screen editor outside the phone shell', () => {
|
||||
expect(apps).not.toContain("id: 'admin'")
|
||||
expect(app).toContain("event.data?.type === 'admin:open'")
|
||||
expect(app).toContain('v-if="adminPanelOpen"')
|
||||
expect(app).toContain('<AdminPanel')
|
||||
expect(source).toContain("import { SkyButton } from '@/ui'")
|
||||
expect(source).toContain('class="admin-panel-overlay"')
|
||||
expect(source).toContain('class="admin-panel-rail"')
|
||||
expect(source).toContain('class="admin-panel-directory"')
|
||||
expect(source).toContain('class="admin-panel-editor"')
|
||||
expect(source).toContain('pointer-events: auto')
|
||||
expect(source).toContain('@media (prefers-reduced-motion: reduce)')
|
||||
})
|
||||
|
||||
it('uses a compact transparent shell with dedicated admin workspaces', () => {
|
||||
expect(source).toContain('background: transparent')
|
||||
expect(source).toContain('width: min(76vw, 1220px)')
|
||||
expect(source).toContain('height: min(74vh, 700px)')
|
||||
expect(source).toContain('--admin-row-hover: linear-gradient')
|
||||
expect(source).toContain('--admin-row-active: linear-gradient')
|
||||
expect(source).toContain('background: var(--admin-nav-active)')
|
||||
expect(source).not.toContain('admin-panel-brand__mark')
|
||||
expect(source).not.toContain('admin-panel-profile-heading__status')
|
||||
expect(source).not.toContain('backdrop-filter: blur(2px)')
|
||||
expect(source).not.toContain("t('overview.recent')")
|
||||
|
||||
for (const tab of [
|
||||
'overview',
|
||||
'players',
|
||||
'devices',
|
||||
'apps',
|
||||
'accounts',
|
||||
'messages',
|
||||
'calls',
|
||||
'moderation',
|
||||
'tones',
|
||||
'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).toMatch(
|
||||
/const value = listTemplate\.value\s+\? blankFromConfiguratorStructure\(listTemplate\.value\)\s+: rows\.length\s+\? blankLike\(rows\[0\]\)/,
|
||||
)
|
||||
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('blankFromConfiguratorStructure')
|
||||
expect(configuratorValueEditor).toContain('createMutableTableEntry(')
|
||||
expect(source).toContain("'is-structured': field.type === 'json'")
|
||||
expect(configuratorValueEditor).toContain('function isStructuredValue(')
|
||||
expect(configuratorValueEditor).toContain(
|
||||
'class="config-structured-editor__add-field is-list"',
|
||||
)
|
||||
expect(configuratorValueEditor).toContain(
|
||||
'class="config-structured-editor__actions"',
|
||||
)
|
||||
expect(configuratorValueEditor).toContain('function toggleStructuredEntry(')
|
||||
expect(configuratorValueEditor).toContain(
|
||||
'class="config-structured-editor__section-toggle"',
|
||||
)
|
||||
expect(configuratorValueEditor).toContain(':aria-expanded=')
|
||||
expect(configuratorValueEditor).toContain(
|
||||
'@media (prefers-reduced-motion: reduce)',
|
||||
)
|
||||
expect(configuratorValueEditor).toContain(
|
||||
'.config-structured-editor__property.has-structured-value',
|
||||
)
|
||||
expect(configuratorValueEditor).not.toContain('<textarea')
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -30,13 +30,11 @@ const props = withDefaults(
|
||||
app: PhoneAppDefinition
|
||||
compact?: boolean
|
||||
editMode?: boolean
|
||||
externalDragVisual?: boolean
|
||||
showLabel?: boolean
|
||||
}>(),
|
||||
{
|
||||
compact: false,
|
||||
editMode: false,
|
||||
externalDragVisual: false,
|
||||
showLabel: true,
|
||||
},
|
||||
)
|
||||
@@ -65,14 +63,14 @@ let dragStartPage = 0
|
||||
let dragPageWidth = 0
|
||||
let dragPageMetrics: SpringboardDragMetrics | null = null
|
||||
const dragStyle = computed(() =>
|
||||
isDragging.value && !props.externalDragVisual
|
||||
isDragging.value
|
||||
? {
|
||||
transform: `translate3d(${springboardPageDragCompensation(dragStartPage, phone.currentPage, dragPageWidth)}px, 0, 0)`,
|
||||
}
|
||||
: undefined,
|
||||
)
|
||||
const dragPointerStyle = computed(() =>
|
||||
isDragging.value && !props.externalDragVisual
|
||||
isDragging.value
|
||||
? {
|
||||
transform: `translate3d(${dragOffset.value.x}px, ${dragOffset.value.y}px, 0)`,
|
||||
}
|
||||
@@ -302,7 +300,6 @@ onBeforeUnmount(() => {
|
||||
class="app-icon-item"
|
||||
:class="{
|
||||
'app-icon-item--compact': compact,
|
||||
'app-icon-item--drag-source': isDragging && externalDragVisual,
|
||||
'app-icon-item--dragging': isDragging,
|
||||
'app-icon-item--editing': editMode,
|
||||
}"
|
||||
@@ -328,10 +325,7 @@ onBeforeUnmount(() => {
|
||||
class="app-icon"
|
||||
:class="[
|
||||
app.iconClass,
|
||||
{
|
||||
'app-icon--image':
|
||||
!iconFailed && Boolean(app.iconImage) && app.id !== 'calendar',
|
||||
},
|
||||
{ 'app-icon--image': !iconFailed && app.id !== 'calendar' },
|
||||
]"
|
||||
:style="iconStyle"
|
||||
>
|
||||
@@ -340,7 +334,7 @@ onBeforeUnmount(() => {
|
||||
<b>{{ calendarDay }}</b>
|
||||
</span>
|
||||
<img
|
||||
v-else-if="!iconFailed && app.iconImage"
|
||||
v-else-if="!iconFailed"
|
||||
:src="app.iconImage"
|
||||
alt=""
|
||||
draggable="false"
|
||||
|
||||
@@ -24,13 +24,4 @@ describe('EasyShareSheet Sky UI contract', () => {
|
||||
/\.easyshare-history\s*\{[^}]*overflow:\s*hidden[^}]*background:\s*var\(--easyshare-list-surface\)/s,
|
||||
)
|
||||
})
|
||||
|
||||
it('only exposes and opens installed share destinations', () => {
|
||||
expect(source).toContain("if (appStore.isInstalled('flare'))")
|
||||
expect(source).toContain("if (appStore.isInstalled('darkchat'))")
|
||||
expect(source).toContain('.filter((id) => appStore.isInstalled(id))')
|
||||
expect(source).toContain('if (!appStore.isInstalled(kind)) return')
|
||||
expect(source).toContain('if (!appStore.isInstalled(appId)) return')
|
||||
expect(source).not.toContain('appStore.homeLayout.hidden.includes')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -67,7 +67,7 @@ const sharePeople = computed(() => {
|
||||
}> = []
|
||||
const phoneNumbers = new Set<string>()
|
||||
|
||||
if (appStore.isInstalled('flare')) {
|
||||
if (!appStore.homeLayout.hidden.includes('flare')) {
|
||||
for (const match of flare.matches) {
|
||||
people.push({
|
||||
avatar: match.profile.photoUrls[0],
|
||||
@@ -102,7 +102,7 @@ const sharePeople = computed(() => {
|
||||
phoneNumbers.add(contact.phone_number)
|
||||
}
|
||||
|
||||
if (appStore.isInstalled('darkchat')) {
|
||||
if (!appStore.homeLayout.hidden.includes('darkchat')) {
|
||||
for (const conversation of darkChat.conversations.slice(0, 8)) {
|
||||
people.push({
|
||||
kind: 'darkchat',
|
||||
@@ -116,7 +116,7 @@ const sharePeople = computed(() => {
|
||||
})
|
||||
const shareApps = computed(() =>
|
||||
(easyShare.payload ? easyShareDestinationAppIds(easyShare.payload) : [])
|
||||
.filter((id) => appStore.isInstalled(id))
|
||||
.filter((id) => !appStore.homeLayout.hidden.includes(id))
|
||||
.flatMap((id) => {
|
||||
const app = getPhoneApp(id)
|
||||
return app ? [{ app, id }] : []
|
||||
@@ -195,21 +195,18 @@ function endDrag(event: PointerEvent): void {
|
||||
}
|
||||
|
||||
function shareToChat(kind: EasyShareChatApp, targetId: string): void {
|
||||
if (!appStore.isInstalled(kind)) return
|
||||
if (!easyShare.prepareChatDraft(kind, targetId)) return
|
||||
close()
|
||||
void router.push(`/apps/${kind}`)
|
||||
}
|
||||
|
||||
function openChatApp(kind: EasyShareChatApp): void {
|
||||
if (!appStore.isInstalled(kind)) return
|
||||
if (!easyShare.prepareChatDraft(kind)) return
|
||||
close()
|
||||
void router.push(`/apps/${kind}`)
|
||||
}
|
||||
|
||||
function openShareApp(appId: EasyShareDestinationApp): void {
|
||||
if (!appStore.isInstalled(appId)) return
|
||||
if (appId === 'messages' || appId === 'darkchat' || appId === 'flare') {
|
||||
openChatApp(appId)
|
||||
return
|
||||
|
||||
@@ -22,13 +22,11 @@ const props = withDefaults(
|
||||
apps: PhoneAppDefinition[]
|
||||
defaultName: string
|
||||
editMode?: boolean
|
||||
externalDragVisual?: boolean
|
||||
folder: HomeFolder
|
||||
showLabel?: boolean
|
||||
}>(),
|
||||
{
|
||||
editMode: false,
|
||||
externalDragVisual: false,
|
||||
showLabel: true,
|
||||
},
|
||||
)
|
||||
@@ -57,14 +55,14 @@ let stopPointerSession: (() => void) | null = null
|
||||
|
||||
const folderName = computed(() => props.folder.name || props.defaultName)
|
||||
const dragStyle = computed(() =>
|
||||
isDragging.value && !props.externalDragVisual
|
||||
isDragging.value
|
||||
? {
|
||||
transform: `translate3d(${springboardPageDragCompensation(dragStartPage, phone.currentPage, dragPageWidth)}px, 0, 0)`,
|
||||
}
|
||||
: undefined,
|
||||
)
|
||||
const dragPointerStyle = computed(() =>
|
||||
isDragging.value && !props.externalDragVisual
|
||||
isDragging.value
|
||||
? {
|
||||
transform: `translate3d(${dragOffset.value.x}px, ${dragOffset.value.y}px, 0)`,
|
||||
}
|
||||
@@ -234,7 +232,6 @@ onBeforeUnmount(() => {
|
||||
<div
|
||||
class="home-folder-item app-icon-item"
|
||||
:class="{
|
||||
'app-icon-item--drag-source': isDragging && externalDragVisual,
|
||||
'app-icon-item--dragging': isDragging,
|
||||
'app-icon-item--editing': editMode,
|
||||
'home-folder-item--dragging': isDragging,
|
||||
|
||||
@@ -26,10 +26,6 @@ const mainCss = readFileSync(
|
||||
new URL('../assets/main.css', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const openedFolderAppBlocks =
|
||||
overlaySource.match(/<AppIcon\b[\s\S]*?\/>/g) ?? []
|
||||
const homeFolderIconBlocks =
|
||||
springboardSource.match(/<HomeFolderIcon\b[\s\S]*?\/>/g) ?? []
|
||||
|
||||
describe('Home folder interaction contract', () => {
|
||||
it('opens folders from edit mode and starts dragging only after movement', () => {
|
||||
@@ -40,7 +36,7 @@ describe('Home folder interaction contract', () => {
|
||||
expect(iconSource).not.toContain('props.editMode || suppressClick.value')
|
||||
})
|
||||
|
||||
it('keeps whole-folder dragging aligned at every phone scale', () => {
|
||||
it('keeps folder app dragging aligned at every phone scale', () => {
|
||||
expect(dragSource).toContain("'.springboard-page, .home-folder-panel'")
|
||||
expect(dragSource).toContain('viewportWidth: bounds.width')
|
||||
expect(dragSource).not.toContain("getPropertyValue('zoom')")
|
||||
@@ -51,60 +47,6 @@ describe('Home folder interaction contract', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('routes whole folders from both the grid and dock through the shared drag portal', () => {
|
||||
expect(homeFolderIconBlocks).toHaveLength(2)
|
||||
for (const area of ['grid', 'dock'] as const) {
|
||||
const block = homeFolderIconBlocks.find((candidate) =>
|
||||
candidate.includes(`data-home-area="${area}"`),
|
||||
)
|
||||
|
||||
expect(block).toBeDefined()
|
||||
expect(block).toContain(':external-drag-visual="homeDragVisualActive"')
|
||||
expect(block).toContain('@dragcancel="stopHomeDrag"')
|
||||
expect(block).toContain('@dragend="finishHomeDrag"')
|
||||
expect(block).toContain('@dragmove="moveHomeDrag"')
|
||||
expect(block).toContain(`@dragstart="startHomeDrag('${area}',`)
|
||||
}
|
||||
})
|
||||
|
||||
it('uses the external viewport visual for apps dragged from an opened folder', () => {
|
||||
expect(openedFolderAppBlocks).toHaveLength(1)
|
||||
expect(overlaySource).toMatch(/externalDragVisual\??:\s*boolean/)
|
||||
expect(openedFolderAppBlocks[0]).toContain(
|
||||
':external-drag-visual="externalDragVisual"',
|
||||
)
|
||||
expect(appIconSource).toContain('externalDragVisual?: boolean')
|
||||
expect(appIconSource).toContain('app-icon-item--drag-source')
|
||||
})
|
||||
|
||||
it('forwards opened-folder pointer start, move, and cancellation to the parent session', () => {
|
||||
expect(overlaySource).toMatch(
|
||||
/function startFolderAppDrag\(index: number, event: PointerEvent\)/,
|
||||
)
|
||||
expect(overlaySource).toContain("emit('dragstart', index, event)")
|
||||
expect(overlaySource).toContain("emit('dragmove', event)")
|
||||
expect(overlaySource).toContain("emit('dragcancel')")
|
||||
expect(openedFolderAppBlocks[0]).toContain(
|
||||
'@dragstart="startFolderAppDrag(entry.index, $event)"',
|
||||
)
|
||||
expect(openedFolderAppBlocks[0]).toContain('@dragmove="moveFolderAppDrag"')
|
||||
expect(openedFolderAppBlocks[0]).toContain(
|
||||
'@dragcancel="stopFolderAppDrag"',
|
||||
)
|
||||
})
|
||||
|
||||
it('hit-tests the opened folder panel and app targets in normalized viewport coordinates', () => {
|
||||
expect(overlaySource).toContain('readPhoneViewportGeometry')
|
||||
expect(overlaySource).toContain('phoneViewportRectContainsPoint')
|
||||
expect(overlaySource).toContain('folderDragViewportRect(panel)')
|
||||
expect(overlaySource).toContain('folderDragViewportRect(element)')
|
||||
expect(overlaySource).toMatch(/geometry\?\.rect\(element\)/)
|
||||
expect(overlaySource).not.toContain(
|
||||
'panelElement.value?.getBoundingClientRect()',
|
||||
)
|
||||
expect(overlaySource).not.toContain('document.elementsFromPoint')
|
||||
})
|
||||
|
||||
it('edits the folder name inline with Sky UI controls', () => {
|
||||
expect(overlaySource).toContain('<SkyField')
|
||||
expect(overlaySource).toContain('home-folder-heading--editing')
|
||||
@@ -121,9 +63,8 @@ describe('Home folder interaction contract', () => {
|
||||
expect(springboardSource).toContain(
|
||||
'@drag-outside-change="folderDraggingOutside = $event"',
|
||||
)
|
||||
expect(overlaySource).toContain('if (draggingIndex.value === null) return')
|
||||
expect(overlaySource).toContain(
|
||||
'if (!draggingOutside.value && !isPointerInsidePanel(event))',
|
||||
'if (draggingIndex.value === null || draggingOutside.value) return',
|
||||
)
|
||||
expect(overlaySource).toContain(
|
||||
'draggingOutside.value || !isPointerInsidePanel(event)',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { Check, Pencil, X } from 'lucide-vue-next'
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
@@ -11,35 +11,20 @@ import {
|
||||
HOME_FOLDER_PAGE_SIZE,
|
||||
type HomeFolder,
|
||||
} from '@/utils/homeLayout'
|
||||
import {
|
||||
phoneViewportRectContainsPoint,
|
||||
readPhoneViewportGeometry,
|
||||
type PhoneViewportGeometry,
|
||||
type PhoneViewportRect,
|
||||
} from '@/utils/phoneViewportGeometry'
|
||||
|
||||
type FolderAppEntry = {
|
||||
app: PhoneAppDefinition
|
||||
index: number
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
apps: FolderAppEntry[]
|
||||
editMode: boolean
|
||||
externalDragVisual?: boolean
|
||||
folder: HomeFolder
|
||||
renameOnOpen: boolean
|
||||
}>(),
|
||||
{
|
||||
externalDragVisual: false,
|
||||
},
|
||||
)
|
||||
const props = defineProps<{
|
||||
apps: FolderAppEntry[]
|
||||
editMode: boolean
|
||||
folder: HomeFolder
|
||||
renameOnOpen: boolean
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
dragcancel: []
|
||||
dragmove: [event: PointerEvent]
|
||||
dragstart: [index: number, event: PointerEvent]
|
||||
'drag-outside-change': [outside: boolean]
|
||||
edit: []
|
||||
extract: [index: number, event: PointerEvent]
|
||||
@@ -58,7 +43,6 @@ const renameOpened = ref(false)
|
||||
const renameDraft = ref('')
|
||||
let pagePointerStart = 0
|
||||
let pagePointerId: number | null = null
|
||||
let dragGeometry: PhoneViewportGeometry | null = null
|
||||
|
||||
const folderName = computed(
|
||||
() => props.folder.name || phone.t('Home.folders.defaultName'),
|
||||
@@ -129,48 +113,25 @@ function saveRename(): void {
|
||||
renameOpened.value = false
|
||||
}
|
||||
|
||||
function startFolderAppDrag(index: number, event: PointerEvent): void {
|
||||
function startFolderAppDrag(index: number): void {
|
||||
draggingIndex.value = index
|
||||
dragGeometry = readPhoneViewportGeometry(panelElement.value)
|
||||
setDraggingOutside(false)
|
||||
emit('dragstart', index, event)
|
||||
}
|
||||
|
||||
function fallbackViewportRect(element: Element): PhoneViewportRect {
|
||||
const bounds = element.getBoundingClientRect()
|
||||
return {
|
||||
bottom: bounds.bottom,
|
||||
height: bounds.height,
|
||||
left: bounds.left,
|
||||
right: bounds.right,
|
||||
top: bounds.top,
|
||||
width: bounds.width,
|
||||
}
|
||||
}
|
||||
|
||||
function folderDragViewportRect(element: Element): PhoneViewportRect {
|
||||
const geometry = dragGeometry ?? readPhoneViewportGeometry(element)
|
||||
return geometry?.rect(element) ?? fallbackViewportRect(element)
|
||||
}
|
||||
|
||||
function isPointerInsidePanel(event: PointerEvent): boolean {
|
||||
const panel = panelElement.value
|
||||
const panelBounds = panelElement.value?.getBoundingClientRect()
|
||||
if (!panelBounds) return false
|
||||
return (
|
||||
panel !== null &&
|
||||
phoneViewportRectContainsPoint(
|
||||
folderDragViewportRect(panel),
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
)
|
||||
event.clientX >= panelBounds.left &&
|
||||
event.clientX <= panelBounds.right &&
|
||||
event.clientY >= panelBounds.top &&
|
||||
event.clientY <= panelBounds.bottom
|
||||
)
|
||||
}
|
||||
|
||||
function moveFolderAppDrag(event: PointerEvent): void {
|
||||
if (draggingIndex.value === null) return
|
||||
if (!draggingOutside.value && !isPointerInsidePanel(event)) {
|
||||
setDraggingOutside(true)
|
||||
}
|
||||
emit('dragmove', event)
|
||||
if (draggingIndex.value === null || draggingOutside.value) return
|
||||
if (!isPointerInsidePanel(event)) setDraggingOutside(true)
|
||||
}
|
||||
|
||||
function setDraggingOutside(outside: boolean): void {
|
||||
@@ -184,52 +145,31 @@ function finishFolderAppDrag(event: PointerEvent): void {
|
||||
const shouldExtract = draggingOutside.value || !isPointerInsidePanel(event)
|
||||
draggingIndex.value = null
|
||||
if (sourceIndex === null) {
|
||||
dragGeometry = null
|
||||
setDraggingOutside(false)
|
||||
emit('dragcancel')
|
||||
return
|
||||
}
|
||||
if (shouldExtract) {
|
||||
emit('extract', sourceIndex, event)
|
||||
dragGeometry = null
|
||||
setDraggingOutside(false)
|
||||
return
|
||||
}
|
||||
setDraggingOutside(false)
|
||||
|
||||
const target = Array.from(
|
||||
panelElement.value?.querySelectorAll<HTMLElement>(
|
||||
'[data-folder-app-index]',
|
||||
) ?? [],
|
||||
).find(
|
||||
(element) =>
|
||||
Number(element.dataset.folderAppIndex) !== sourceIndex &&
|
||||
phoneViewportRectContainsPoint(
|
||||
folderDragViewportRect(element),
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
),
|
||||
)
|
||||
if (!target) {
|
||||
dragGeometry = null
|
||||
emit('dragcancel')
|
||||
return
|
||||
}
|
||||
const target = document
|
||||
.elementsFromPoint(event.clientX, event.clientY)
|
||||
.map((element) => element.closest<HTMLElement>('[data-folder-app-index]'))
|
||||
.find(
|
||||
(element) =>
|
||||
element && Number(element.dataset.folderAppIndex) !== sourceIndex,
|
||||
)
|
||||
if (!target) return
|
||||
const targetIndex = Number(target.dataset.folderAppIndex)
|
||||
dragGeometry = null
|
||||
if (!Number.isInteger(targetIndex)) {
|
||||
emit('dragcancel')
|
||||
return
|
||||
}
|
||||
emit('move', sourceIndex, targetIndex)
|
||||
if (Number.isInteger(targetIndex)) emit('move', sourceIndex, targetIndex)
|
||||
}
|
||||
|
||||
function stopFolderAppDrag(): void {
|
||||
const wasDragging = draggingIndex.value !== null
|
||||
draggingIndex.value = null
|
||||
dragGeometry = null
|
||||
setDraggingOutside(false)
|
||||
if (wasDragging) emit('dragcancel')
|
||||
}
|
||||
|
||||
function goToPage(page: number): void {
|
||||
@@ -255,11 +195,6 @@ function finishPageSwipe(event: PointerEvent): void {
|
||||
}
|
||||
pagePointerId = null
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopFolderAppDrag()
|
||||
pagePointerId = null
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -355,11 +290,10 @@ onBeforeUnmount(() => {
|
||||
:app="entry.app"
|
||||
:data-folder-app-index="entry.index"
|
||||
:edit-mode="editMode"
|
||||
:external-drag-visual="externalDragVisual"
|
||||
@dragcancel="stopFolderAppDrag"
|
||||
@dragend="finishFolderAppDrag"
|
||||
@dragmove="moveFolderAppDrag"
|
||||
@dragstart="startFolderAppDrag(entry.index, $event)"
|
||||
@dragstart="startFolderAppDrag(entry.index)"
|
||||
@edit="emit('edit')"
|
||||
/>
|
||||
</div>
|
||||
@@ -397,7 +331,8 @@ onBeforeUnmount(() => {
|
||||
z-index: 70;
|
||||
inset: 0;
|
||||
color: #fff;
|
||||
font-family: var(--sky-font-family);
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', sans-serif;
|
||||
}
|
||||
|
||||
.home-folder-backdrop {
|
||||
|
||||
@@ -3,7 +3,6 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
import payphoneFrame from '@/assets/img/payphone/american-payphone-frame.png'
|
||||
import { nuiCall } from '@/utils/nui'
|
||||
import { registerPhoneMediaElement } from '@/utils/phoneAudio'
|
||||
import { isTrustedRootMessageSource } from '@/utils/windowMessages'
|
||||
|
||||
type PayphoneState =
|
||||
@@ -71,9 +70,7 @@ const buttonSounds: HTMLAudioElement[] = []
|
||||
function prepareButtonSounds(): void {
|
||||
if (buttonSounds.length) return
|
||||
for (let index = 0; index < 4; index += 1) {
|
||||
const sound = registerPhoneMediaElement(
|
||||
new Audio(`${import.meta.env.BASE_URL}sounds/button.mp3`),
|
||||
)
|
||||
const sound = new Audio(`${import.meta.env.BASE_URL}sounds/button.mp3`)
|
||||
sound.preload = 'auto'
|
||||
sound.volume = 0.55
|
||||
buttonSounds.push(sound)
|
||||
@@ -402,7 +399,7 @@ onBeforeUnmount(() => {
|
||||
rgb(30 38 42 / 35%),
|
||||
rgb(0 0 0 / 84%) 72%
|
||||
);
|
||||
font-family: var(--sky-font-family);
|
||||
font-family: 'Segoe UI', Arial, sans-serif;
|
||||
pointer-events: auto;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
@@ -5,15 +5,8 @@ import { describe, expect, it } from 'vitest'
|
||||
const source = readFileSync(
|
||||
new URL('./PhoneDynamicIsland.vue', import.meta.url),
|
||||
'utf8',
|
||||
).replace(/\r\n/g, '\n')
|
||||
const appSource = readFileSync(
|
||||
new URL('../App.vue', import.meta.url),
|
||||
'utf8',
|
||||
).replace(/\r\n/g, '\n')
|
||||
const mainCss = readFileSync(
|
||||
new URL('../assets/main.css', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const appSource = readFileSync(new URL('../App.vue', import.meta.url), 'utf8')
|
||||
const recorderSource = readFileSync(
|
||||
new URL('./PhoneMemoRecorder.vue', import.meta.url),
|
||||
'utf8',
|
||||
@@ -26,23 +19,16 @@ const clockSource = readFileSync(
|
||||
new URL('../views/apps/ClockApp.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const serverMemosSource = readFileSync(
|
||||
new URL('../../../sky_phone/source/server/memos.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
describe('Phone Dynamic Island contract', () => {
|
||||
it('renders once at phone shell level instead of forcing calls into Phone', () => {
|
||||
expect(appSource).toContain(
|
||||
"import PhoneDynamicIsland from '@/components/PhoneDynamicIsland.vue'",
|
||||
)
|
||||
expect(appSource).toContain('<PhoneDynamicIsland v-if="!setupRequired" />')
|
||||
expect(appSource).toContain(
|
||||
"'phone-device--island-expanded': dynamicIslandExpanded",
|
||||
'phone.isOpen || notifications.current || calls.activeCall',
|
||||
)
|
||||
expect(appSource).toMatch(
|
||||
/class="phone-device__frame"[\s\S]*?<PhoneDynamicIsland[\s\S]*?@expanded-change="dynamicIslandExpanded = \$event"[\s\S]*?@live-activity-change="dynamicIslandActivity = \$event"/,
|
||||
)
|
||||
expect(appSource).toContain('dynamicIslandActivity ||')
|
||||
expect(appSource).not.toContain(
|
||||
"window.setTimeout(() => void router.push('/apps/phone'), 0)",
|
||||
)
|
||||
@@ -59,44 +45,7 @@ describe('Phone Dynamic Island contract', () => {
|
||||
expect(source).toContain('call.answeredAt ?? call.startedAt')
|
||||
})
|
||||
|
||||
it('hides an activity in its owning foreground app but restores it after closing', () => {
|
||||
expect(source).toContain(
|
||||
'if (!currentActivity || !phone.isOpen) return currentActivity',
|
||||
)
|
||||
expect(source).toContain("activeAppId.value === 'phone'")
|
||||
expect(source).toContain(
|
||||
"currentActivity === 'recording' && activeAppId.value === 'memos'",
|
||||
)
|
||||
expect(source).toContain("activeAppId.value === 'clock'")
|
||||
expect(source).toContain(
|
||||
"currentActivity === 'music' && activeAppId.value === 'music'",
|
||||
)
|
||||
})
|
||||
|
||||
it('shows only a compact frame and island for background live activities', () => {
|
||||
expect(source).toContain("'live-activity-change': [activity:")
|
||||
expect(source).toContain("emit('live-activity-change', nextActivity)")
|
||||
expect(appSource).toContain(
|
||||
"'phone-stage--live-activity':\n !phone.isOpen && Boolean(dynamicIslandActivity || calls.activeCall)",
|
||||
)
|
||||
expect(mainCss).toMatch(
|
||||
/\.phone-stage--live-activity[\s\S]*?pointer-events:\s*none;[\s\S]*?--phone-live-activity-peek-height/,
|
||||
)
|
||||
expect(mainCss).toMatch(
|
||||
/\.phone-stage--live-activity[\s\S]*?> :not\(\.phone-screen\):not\(\.phone-device__frame\):not\(\.phone-dynamic-island\)[\s\S]*?\.phone-screen > \*[\s\S]*?visibility:\s*hidden;/,
|
||||
)
|
||||
expect(appSource).toContain("? '190px'")
|
||||
expect(appSource).toContain("? '132px'")
|
||||
expect(appSource).toContain(": '112px'")
|
||||
expect(source).toContain(
|
||||
"!phone.isOpen ||\n activity.value === 'incoming-call' ||",
|
||||
)
|
||||
})
|
||||
|
||||
it('connects music, recorder, timer, and stopwatch controls to their stores', () => {
|
||||
expect(source).toContain(
|
||||
"if (music.isPlaying && music.currentTrack) return 'music'",
|
||||
)
|
||||
expect(source).toContain('@click.stop="music.previous()"')
|
||||
expect(source).toContain('@click.stop="music.toggle()"')
|
||||
expect(source).toContain('@click.stop="music.next()"')
|
||||
@@ -104,31 +53,9 @@ describe('Phone Dynamic Island contract', () => {
|
||||
expect(source).toContain('clock.pauseTimer(Date.now())')
|
||||
expect(source).toContain('clock.pauseStopwatch(Date.now())')
|
||||
expect(source).toContain('clock.addLap(Date.now())')
|
||||
expect(source).not.toContain('phone-dynamic-island__lap')
|
||||
expect(source).toContain('phone-dynamic-island__stopwatch-meta')
|
||||
expect(source).toContain('{{ stopwatchLapLabel }}')
|
||||
expect(source).toContain('{{ stopwatchLapValue }}')
|
||||
expect(source).toContain('{{ stopwatchTotalDisplay }}')
|
||||
})
|
||||
|
||||
it('matches the reference music player and timer control layouts', () => {
|
||||
expect(source).toContain('phone-dynamic-island__music-equalizer')
|
||||
expect(source).toContain('phone-dynamic-island__progress-track')
|
||||
expect(source).toContain('{{ musicElapsedLabel }}')
|
||||
expect(source).toContain('{{ musicRemainingLabel }}')
|
||||
expect(source).not.toContain('Airplay')
|
||||
expect(source).toContain('<X aria-hidden="true" />')
|
||||
expect(source).toMatch(
|
||||
/\.phone-dynamic-island--timer\.phone-dynamic-island__copy|\.phone-dynamic-island--timer \.phone-dynamic-island__copy/,
|
||||
)
|
||||
expect(source).toContain(
|
||||
'.phone-dynamic-island--music .phone-dynamic-island__actions--media',
|
||||
)
|
||||
expect(source).toContain('justify-content: center')
|
||||
expect(source).toContain('gap: 34px')
|
||||
})
|
||||
|
||||
it('keeps recorder state available across app changes and phone closes', () => {
|
||||
it('keeps recorder state available across app changes', () => {
|
||||
expect(recorderSource).toContain(
|
||||
"message.type === 'memo:recordStateRequest'",
|
||||
)
|
||||
@@ -138,15 +65,6 @@ describe('Phone Dynamic Island contract', () => {
|
||||
expect(memosSource).not.toContain(
|
||||
"if (recordingActive.value) postRecorderCommand('memo:recordCancel')",
|
||||
)
|
||||
expect(recorderSource).toContain('() => phone.device?.imei ?? null')
|
||||
expect(recorderSource).not.toContain(
|
||||
'if (!isOpen || deviceSessionChanged) cancelRecording()',
|
||||
)
|
||||
expect(recorderSource).toContain('deviceImei: finalDeviceImei')
|
||||
expect(serverMemosSource).toContain('device_owner(src, data.deviceImei)')
|
||||
expect(serverMemosSource).toContain(
|
||||
'SkyPhone.FindDeviceSlots(source, imei)[1]',
|
||||
)
|
||||
})
|
||||
|
||||
it('opens both clock live activities on the correct clock tab', () => {
|
||||
@@ -157,68 +75,13 @@ describe('Phone Dynamic Island contract', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('opens activities by tapping the island without a separate expand icon', () => {
|
||||
expect(source).toContain('@click.stop="toggleExpanded"')
|
||||
expect(source).toContain('@click.stop="openActivity"')
|
||||
expect(source).not.toContain('Maximize2')
|
||||
expect(source).not.toContain('phone-dynamic-island__open-icon')
|
||||
})
|
||||
|
||||
it('collapses expanded activities on taps, swipes, and scrolling outside', () => {
|
||||
expect(source).toContain('ref="islandElement"')
|
||||
expect(source).toContain(
|
||||
"document.addEventListener('pointerdown', onOutsidePointerDown, true)",
|
||||
)
|
||||
expect(source).toContain(
|
||||
"document.addEventListener('scroll', collapseExpanded, true)",
|
||||
)
|
||||
expect(source).toContain('islandElement.value?.contains(event.target)')
|
||||
expect(source).toContain('expanded.value = false')
|
||||
expect(source).toContain(
|
||||
"document.removeEventListener('pointerdown', onOutsidePointerDown, true)",
|
||||
)
|
||||
expect(source).toContain(
|
||||
"document.removeEventListener('scroll', collapseExpanded, true)",
|
||||
)
|
||||
})
|
||||
|
||||
it('animates state changes and moves popup notifications below expanded UI', () => {
|
||||
expect(source).toContain('<Transition name="phone-dynamic-island">')
|
||||
expect(source).toContain(
|
||||
'<Transition name="phone-dynamic-island-content" mode="out-in">',
|
||||
)
|
||||
expect(source).toContain(".phone-dynamic-island[data-expanded='true']")
|
||||
expect(source).toContain("emit('expanded-change', false)")
|
||||
expect(mainCss).toContain(
|
||||
'.phone-device--island-expanded .phone-notification',
|
||||
)
|
||||
expect(source).toContain('~ .phone-notification-provider')
|
||||
expect(source).toContain('@media (prefers-reduced-motion: reduce)')
|
||||
})
|
||||
|
||||
it('renders below the top edge and above the physical camera frame', () => {
|
||||
expect(source).toMatch(
|
||||
/\.phone-dynamic-island\s*\{[^}]*z-index:\s*102;[^}]*top:\s*30px;/s,
|
||||
)
|
||||
expect(mainCss).not.toMatch(/\.phone-dynamic-island\s*\{/)
|
||||
})
|
||||
|
||||
it('keeps compact and expanded islands close to the physical camera proportions', () => {
|
||||
expect(source).toMatch(
|
||||
/\.phone-dynamic-island\s*\{[^}]*width:\s*126px;[^}]*height:\s*38px;/s,
|
||||
)
|
||||
expect(source).toMatch(
|
||||
/\.phone-dynamic-island\[data-expanded='true'\]\s*\{[^}]*width:\s*318px;[^}]*height:\s*74px;/s,
|
||||
)
|
||||
expect(source).toMatch(
|
||||
/\.phone-dynamic-island--incoming-call\[data-expanded='true'\]\s*\{[^}]*height:\s*68px;/s,
|
||||
)
|
||||
expect(source).toMatch(
|
||||
/\.phone-dynamic-island--music\[data-expanded='true'\]\s*\{[^}]*width:\s*316px;[^}]*height:\s*150px;/s,
|
||||
)
|
||||
expect(source).toMatch(
|
||||
/\.phone-dynamic-island--stopwatch\[data-expanded='true'\]\s*\{[^}]*height:\s*70px;/s,
|
||||
)
|
||||
expect(source).toContain('box-sizing: border-box')
|
||||
expect(source).toContain('padding: 8px 16px 8px 10px')
|
||||
})
|
||||
})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,12 +14,6 @@ import { isTrustedRootMessageSource } from '@/utils/windowMessages'
|
||||
|
||||
type RecordingChunk = { blob: Blob; durationMs: number }
|
||||
type PendingVideo = { blob: Blob; fileName: string }
|
||||
type UploadFailureDebug = {
|
||||
correlationId: string
|
||||
message: string
|
||||
stage: string
|
||||
status?: number
|
||||
}
|
||||
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
||||
const pendingVideos = new Map<string, PendingVideo>()
|
||||
@@ -136,10 +130,7 @@ function cleanupRecording(): void {
|
||||
try {
|
||||
activeRecorder.stop()
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[Camera] Could not stop the failed media recorder.',
|
||||
error,
|
||||
)
|
||||
console.error('[Camera] Could not stop the failed media recorder.', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -254,7 +245,8 @@ async function startRecording(data: Record<string, unknown>): Promise<void> {
|
||||
const activeRecorder = recorder
|
||||
removeRecorderErrorListener = bindMediaRecorderError(
|
||||
activeRecorder,
|
||||
() => generation === recordingGeneration && recorder === activeRecorder,
|
||||
() =>
|
||||
generation === recordingGeneration && recorder === activeRecorder,
|
||||
(event) => {
|
||||
console.error('[Camera] Media recorder failed while recording.', event)
|
||||
cleanupRecording()
|
||||
@@ -425,32 +417,8 @@ async function capturePhotoBlob(ready: UploadReady): Promise<Blob> {
|
||||
}
|
||||
}
|
||||
|
||||
async function failUpload(
|
||||
requestId: string,
|
||||
error: string,
|
||||
debug: UploadFailureDebug,
|
||||
): Promise<void> {
|
||||
console.error('[Sky Phone Media] Upload failed.', {
|
||||
correlationId: debug.correlationId,
|
||||
detail: debug.message,
|
||||
error,
|
||||
stage: debug.stage,
|
||||
status: debug.status,
|
||||
})
|
||||
const response = await nuiCall('media:failUpload', {
|
||||
correlationId: debug.correlationId,
|
||||
debugMessage: debug.message,
|
||||
debugStage: debug.stage,
|
||||
debugStatus: debug.status,
|
||||
error,
|
||||
requestId,
|
||||
})
|
||||
if (!response.success) {
|
||||
console.error('[Sky Phone Media] Could not forward upload diagnostics.', {
|
||||
correlationId: debug.correlationId,
|
||||
error: response.error,
|
||||
})
|
||||
}
|
||||
async function failUpload(requestId: string, error: string): Promise<void> {
|
||||
await nuiCall('media:failUpload', { error, requestId })
|
||||
}
|
||||
|
||||
async function uploadReady(ready: UploadReady): Promise<void> {
|
||||
@@ -467,87 +435,49 @@ async function uploadReady(ready: UploadReady): Promise<void> {
|
||||
blob = await capturePhotoBlob(ready)
|
||||
fileName = `camera-${ready.correlationId}.${ready.photo?.Encoding ?? 'jpg'}`
|
||||
}
|
||||
} catch (error) {
|
||||
await failUpload(ready.requestId, 'capture_failed', {
|
||||
correlationId: ready.correlationId,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
stage: 'capture',
|
||||
})
|
||||
} catch {
|
||||
await failUpload(ready.requestId, 'capture_failed')
|
||||
return
|
||||
}
|
||||
|
||||
console.info('[Sky Phone Media] Capture prepared for upload.', {
|
||||
bytes: blob.size,
|
||||
correlationId: ready.correlationId,
|
||||
mimeType: blob.type,
|
||||
type: ready.mediaType,
|
||||
})
|
||||
|
||||
const form = new FormData()
|
||||
form.append('file', blob, fileName)
|
||||
form.append(
|
||||
'metadata',
|
||||
JSON.stringify({ captureToken: ready.captureToken, source: 'sky_phone' }),
|
||||
)
|
||||
const controller = new AbortController()
|
||||
const timeout = window.setTimeout(
|
||||
() => controller.abort(),
|
||||
ready.uploadTimeoutMs ?? 25000,
|
||||
)
|
||||
let debugStage = 'provider_request'
|
||||
let debugStatus: number | undefined
|
||||
try {
|
||||
const response = await fetch(ready.presignedUrl, {
|
||||
body: form,
|
||||
method: 'POST',
|
||||
signal: controller.signal,
|
||||
})
|
||||
debugStatus = response.status
|
||||
debugStage = 'provider_response'
|
||||
console.info('[Sky Phone Media] FiveManage upload responded.', {
|
||||
correlationId: ready.correlationId,
|
||||
status: response.status,
|
||||
})
|
||||
const text = await response.text()
|
||||
const body = JSON.parse(text) as {
|
||||
data?: { id?: string; url?: string }
|
||||
error?: string
|
||||
id?: string
|
||||
message?: string
|
||||
url?: string
|
||||
}
|
||||
const uploaded = body.data ?? body
|
||||
if (!response.ok || !uploaded.id || !uploaded.url) {
|
||||
throw new Error(
|
||||
(typeof body.error === 'string' && body.error) ||
|
||||
(typeof body.message === 'string' && body.message) ||
|
||||
'upload_failed',
|
||||
)
|
||||
throw new Error('upload_failed')
|
||||
}
|
||||
debugStage = 'completion_callback'
|
||||
const completion = await nuiCall('media:completeUpload', {
|
||||
correlationId: ready.correlationId,
|
||||
await nuiCall('media:completeUpload', {
|
||||
remoteId: uploaded.id,
|
||||
requestId: ready.requestId,
|
||||
url: uploaded.url,
|
||||
})
|
||||
if (!completion.success) {
|
||||
throw new Error(completion.error ?? 'completion_callback_failed')
|
||||
}
|
||||
console.info(
|
||||
'[Sky Phone Media] Upload completion forwarded to the server.',
|
||||
{
|
||||
correlationId: ready.correlationId,
|
||||
},
|
||||
)
|
||||
} catch (error) {
|
||||
await failUpload(
|
||||
ready.requestId,
|
||||
error instanceof DOMException && error.name === 'AbortError'
|
||||
? 'upload_timeout'
|
||||
: 'upload_failed',
|
||||
{
|
||||
correlationId: ready.correlationId,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
stage: debugStage,
|
||||
status: debugStatus,
|
||||
},
|
||||
)
|
||||
} finally {
|
||||
window.clearTimeout(timeout)
|
||||
@@ -593,12 +523,7 @@ function onMessage(event: MessageEvent): void {
|
||||
)
|
||||
}
|
||||
} else if (message.type === 'media:uploadReady') {
|
||||
const ready = message.data as UploadReady
|
||||
console.info('[Sky Phone Media] Upload-ready received.', {
|
||||
correlationId: ready.correlationId,
|
||||
type: ready.mediaType,
|
||||
})
|
||||
void uploadReady(ready)
|
||||
void uploadReady(message.data as UploadReady)
|
||||
} else if (message.type === 'media:uploadResult') {
|
||||
const correlationId = String(message.data?.correlationId ?? '')
|
||||
if (correlationId) pendingVideos.delete(correlationId)
|
||||
|
||||
@@ -54,7 +54,6 @@ let metadata: MemoRecordingMetadata = { note: '', pinned: false, title: '' }
|
||||
let currentState: MemoRecorderStateName = 'idle'
|
||||
let currentElapsedMs = 0
|
||||
let currentCorrelationId = ''
|
||||
let recordingDeviceImei = ''
|
||||
let removeRecorderErrorListener: (() => void) | null = null
|
||||
|
||||
function postRecorderState(state: MemoRecorderStateName, error?: string): void {
|
||||
@@ -154,7 +153,6 @@ function resetRecordingData(): void {
|
||||
pausedStartedAt = 0
|
||||
totalPausedMs = 0
|
||||
currentElapsedMs = 0
|
||||
recordingDeviceImei = ''
|
||||
liveLevels = Array(LIVE_LEVEL_SAMPLES).fill(0.08)
|
||||
}
|
||||
|
||||
@@ -191,8 +189,7 @@ function failRecording(error: string, generation = recordingGeneration): void {
|
||||
}
|
||||
|
||||
async function startRecording(data: Record<string, unknown>): Promise<void> {
|
||||
const deviceImei = phone.device?.imei
|
||||
if (!phone.isOpen || !deviceImei) {
|
||||
if (!phone.isOpen) {
|
||||
console.error('[Memos] Cannot start a recording while the phone is closed.')
|
||||
return
|
||||
}
|
||||
@@ -220,7 +217,6 @@ async function startRecording(data: Record<string, unknown>): Promise<void> {
|
||||
metadata = { note: '', pinned: false, title: '' }
|
||||
updateMetadata(data)
|
||||
resetRecordingData()
|
||||
recordingDeviceImei = deviceImei
|
||||
postRecorderState('starting')
|
||||
try {
|
||||
const acquiredStream = await navigator.mediaDevices.getUserMedia({
|
||||
@@ -344,7 +340,6 @@ async function stopRecording(data: Record<string, unknown>): Promise<void> {
|
||||
if (generation !== recordingGeneration) return
|
||||
const waveform = compressedWaveform()
|
||||
const finalMetadata = { ...metadata }
|
||||
const finalDeviceImei = recordingDeviceImei
|
||||
const exceededSizeLimit = recordingTooLarge
|
||||
cleanupRecorder(false)
|
||||
resetRecordingData()
|
||||
@@ -360,12 +355,10 @@ async function stopRecording(data: Record<string, unknown>): Promise<void> {
|
||||
liveLevels = waveform.slice(-LIVE_LEVEL_SAMPLES)
|
||||
const uploadData = {
|
||||
correlationId,
|
||||
deviceImei: finalDeviceImei,
|
||||
durationMs,
|
||||
mimeType,
|
||||
note: finalMetadata.note,
|
||||
pinned: finalMetadata.pinned,
|
||||
sizeBytes: blob.size,
|
||||
title: finalMetadata.title,
|
||||
waveform,
|
||||
}
|
||||
@@ -459,6 +452,14 @@ async function uploadReady(ready: MemoUploadReady): Promise<void> {
|
||||
pending.requestId = ready.requestId
|
||||
const form = new FormData()
|
||||
form.append('file', pending.blob, pending.fileName)
|
||||
form.append(
|
||||
'metadata',
|
||||
JSON.stringify({
|
||||
captureToken: ready.captureToken,
|
||||
purpose: 'memo',
|
||||
source: 'sky_phone',
|
||||
}),
|
||||
)
|
||||
const controller = new AbortController()
|
||||
pending.abortController = controller
|
||||
const timeout = window.setTimeout(
|
||||
@@ -568,9 +569,18 @@ function onMessage(event: MessageEvent): void {
|
||||
onMounted(() => window.addEventListener('message', onMessage))
|
||||
|
||||
watch(
|
||||
() => phone.device?.imei ?? null,
|
||||
(imei, previousImei) => {
|
||||
if (previousImei && imei !== previousImei) cancelRecording()
|
||||
() =>
|
||||
[
|
||||
phone.isOpen,
|
||||
phone.device?.imei ?? null,
|
||||
phone.deviceSessionToken,
|
||||
] as const,
|
||||
([isOpen, imei, sessionToken], previous) => {
|
||||
const deviceSessionChanged =
|
||||
previous !== undefined &&
|
||||
previous[0] &&
|
||||
(previous[1] !== imei || previous[2] !== sessionToken)
|
||||
if (!isOpen || deviceSessionChanged) cancelRecording()
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -61,22 +61,16 @@ const passcodeError = ref('')
|
||||
const passcodeLength = ref<4 | 6>(phone.security.length === 4 ? 4 : 6)
|
||||
const notificationsEnabled = ref(true)
|
||||
const notificationSounds = ref(true)
|
||||
const selectedApps = ref<BuiltinPhoneAppId[]>(
|
||||
(['banking', 'garage', 'skyride'] as const).filter((id) =>
|
||||
appStore.isAvailable(id),
|
||||
),
|
||||
)
|
||||
const selectedApps = ref<BuiltinPhoneAppId[]>(['banking', 'garage', 'skyride'])
|
||||
const setupCompleteBusy = ref(false)
|
||||
const setupCompleteError = ref('')
|
||||
|
||||
const setupApps = computed(() =>
|
||||
(
|
||||
['banking', 'garage', 'skyride', 'citymarkt', 'picstagram', 'snake'] as const
|
||||
).flatMap((id) => {
|
||||
const app = getPhoneApp(id)
|
||||
return app && appStore.isAvailable(id) ? [app] : []
|
||||
}),
|
||||
)
|
||||
const setupApps = (
|
||||
['banking', 'garage', 'skyride', 'citymarkt', 'picstagram', 'snake'] as const
|
||||
).flatMap((id) => {
|
||||
const app = getPhoneApp(id)
|
||||
return app ? [app] : []
|
||||
})
|
||||
const wallpaperChoices = WALLPAPER_IDS
|
||||
const progress = computed(() => `${((step.value + 1) / 10) * 100}%`)
|
||||
const displayName = computed(() => {
|
||||
|
||||
@@ -210,7 +210,7 @@ onBeforeUnmount(() => {
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
pointer-events: none;
|
||||
font-family: var(--sky-font-family);
|
||||
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.radio-hud[data-horizontal='left'] {
|
||||
|
||||
@@ -677,7 +677,9 @@ onBeforeUnmount(() => {
|
||||
backdrop-filter: blur(26px) saturate(125%);
|
||||
-webkit-backdrop-filter: blur(26px) saturate(125%);
|
||||
cursor: pointer;
|
||||
font-family: var(--sky-font-family);
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'SF Pro Text',
|
||||
'Segoe UI', sans-serif;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
touch-action: none;
|
||||
|
||||
@@ -748,7 +748,6 @@ export function getPhoneAppLabel(
|
||||
}
|
||||
|
||||
export function isPhoneAppRemovable(app: PhoneAppDefinition): boolean {
|
||||
if (app.adminOnly) return false
|
||||
return app.kind === 'external'
|
||||
? app.removable && !app.defaultInstalled
|
||||
: !DEFAULT_INSTALLED_PHONE_APP_IDS.has(app.id) &&
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const resource = (path: string) =>
|
||||
readFileSync(new URL(`../../sky_phone/${path}`, import.meta.url), 'utf8')
|
||||
const customTonesServer = resource('source/server/custom_tones.lua')
|
||||
const databaseMigration = resource('source/server/db_migrate.lua')
|
||||
const clientMain = resource('source/client/main.lua')
|
||||
const clientTones = resource('source/client/custom_tones.lua')
|
||||
const manifest = resource('fxmanifest.lua')
|
||||
const nuiServerBridge = resource('source/client/nui_server_bridge.lua')
|
||||
const settingsSource = readFileSync(
|
||||
new URL('./views/apps/SettingsApp.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const adminManager = readFileSync(
|
||||
new URL('./components/AdminCustomToneManager.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const adminStore = readFileSync(
|
||||
new URL('./stores/admin.ts', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const adminPanel = readFileSync(
|
||||
new URL('./components/AdminPanel.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const config = resource('config/config.lua')
|
||||
const configurator = resource('source/server/phone_configurator.lua')
|
||||
|
||||
describe('custom phone tone contract', () => {
|
||||
it('stores bounded audio payloads in a phone-owned table', () => {
|
||||
expect(databaseMigration).toContain('name = "sky_phone_custom_tones"')
|
||||
expect(databaseMigration).toContain(
|
||||
'{ name = "audio_payload", type = "MEDIUMTEXT NOT NULL"',
|
||||
)
|
||||
expect(customTonesServer).toContain('local MAX_TONES_PER_TYPE = 32')
|
||||
expect(customTonesServer).toContain('local MAX_AUDIO_BYTES = 2000000')
|
||||
expect(customTonesServer).toContain('local MAX_TRANSFER_CHUNK_CHARS = 8000')
|
||||
expect(customTonesServer).toContain('local MAX_TRANSFER_CHUNKS = 334')
|
||||
expect(customTonesServer).toContain('local MAX_DURATION_MS = 30000')
|
||||
expect(customTonesServer).toContain('decoded_base64_size')
|
||||
expect(customTonesServer).toContain('valid_audio_signature')
|
||||
})
|
||||
|
||||
it('keeps creation and deletion behind server-side admin authorization', () => {
|
||||
const adminServer = resource('source/server/admin.lua')
|
||||
expect(adminServer).toMatch(
|
||||
/^Bridge\.Database\.AfterMigration\("sky_phone", function\(\)/,
|
||||
)
|
||||
expect(adminServer.trimEnd()).toMatch(/end\)\r?\nend\)$/)
|
||||
expect(adminServer).toContain('"sky_phone:admin:tone-upload-start"')
|
||||
expect(adminServer).toContain('"sky_phone:admin:tone-upload-chunk"')
|
||||
expect(adminServer).toContain(
|
||||
'math.max(400, tonumber(Config.AdminPanel.ActionRequestsPerMinute) or 0)',
|
||||
)
|
||||
expect(adminServer).toContain('"sky_phone:admin:tone-upload-finish"')
|
||||
expect(adminServer).toContain('"sky_phone:admin:delete-tone"')
|
||||
expect(adminServer).toContain('require_admin(')
|
||||
expect(adminServer).toContain('"create_custom_tone"')
|
||||
expect(adminServer).toContain('"delete_custom_tone"')
|
||||
})
|
||||
|
||||
it('loads metadata on readiness and audio only when it is played', () => {
|
||||
expect(clientMain).toContain(
|
||||
'Bridge.Callbacks.Trigger("sky_phone:tones:list"',
|
||||
)
|
||||
expect(clientMain).toContain('type = "phone:tones"')
|
||||
expect(customTonesServer).toContain('TriggerLatentClientEvent(')
|
||||
expect(customTonesServer).toContain('"sky_phone:tones:audio-request"')
|
||||
expect(customTonesServer).toContain('AUDIO_TRANSFER_BYTES_PER_SECOND')
|
||||
expect(clientTones).toContain('RegisterNUICallback("tones:audio"')
|
||||
expect(clientTones).toContain('"sky_phone:tones:audio-response"')
|
||||
expect(clientTones).toContain('AUDIO_TRANSFER_TIMEOUT_MS')
|
||||
expect(manifest).toContain("'source/client/custom_tones.lua'")
|
||||
expect(nuiServerBridge).not.toContain('audio-start')
|
||||
expect(nuiServerBridge).not.toContain('audio-chunk')
|
||||
expect(customTonesServer).toContain(
|
||||
'SELECT `id`, `mime_type`, `audio_payload`',
|
||||
)
|
||||
})
|
||||
|
||||
it('offers direct local-file management without a URL field', () => {
|
||||
expect(adminManager).toContain('type="file"')
|
||||
expect(adminManager).toContain('readAsDataURL(file)')
|
||||
expect(adminManager).not.toContain('type="url"')
|
||||
expect(adminManager).not.toContain('https://')
|
||||
expect(adminManager).not.toContain('fileInput.value?.click()')
|
||||
expect(adminManager).toContain(':key="fileInputResetKey"')
|
||||
expect(adminManager).toContain('AUDIO_METADATA_TIMEOUT_MS')
|
||||
expect(adminManager).toContain('onStarted: () =>')
|
||||
expect(adminManager).toContain("t('errors.playback')")
|
||||
expect(adminStore).toContain('finally {')
|
||||
expect(adminStore).toContain("this.actionKey = ''")
|
||||
expect(adminStore).toContain('cacheCustomPhoneTonePayload(')
|
||||
expect(adminPanel).toContain("| 'tones'")
|
||||
expect(adminPanel).toContain("selectTab('tones')")
|
||||
})
|
||||
|
||||
it('supports URL-free file configuration outside the phone panel', () => {
|
||||
expect(config).toContain('Config.CustomTones = {')
|
||||
expect(config).toContain('config/custom_tones/')
|
||||
expect(customTonesServer).toContain('LoadResourceFile(')
|
||||
expect(configurator).toContain('CustomTones = true')
|
||||
expect(configurator).toContain('and key ~= "CustomTones"')
|
||||
})
|
||||
|
||||
it('shows built-in and custom choices in both sound categories', () => {
|
||||
expect(settingsSource).toContain('phone.customTones.ringtones.map')
|
||||
expect(settingsSource).toContain('phone.customTones.notificationSounds.map')
|
||||
expect(settingsSource).toContain('v-for="ringtone in ringtoneChoices"')
|
||||
expect(settingsSource).toContain(
|
||||
'v-for="sound in notificationSoundChoices"',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,26 +0,0 @@
|
||||
import type { Directive } from 'vue'
|
||||
|
||||
const inputListeners = new WeakMap<HTMLInputElement, () => void>()
|
||||
const MINIMUM_INPUT_WIDTH = 42
|
||||
|
||||
function syncInputWidth(input: HTMLInputElement): void {
|
||||
input.style.width = '1px'
|
||||
input.style.width = `${Math.max(MINIMUM_INPUT_WIDTH, Math.ceil(input.scrollWidth) + 2)}px`
|
||||
}
|
||||
|
||||
export const vConfigInputWidth: Directive<HTMLInputElement> = {
|
||||
mounted(input) {
|
||||
const listener = () => syncInputWidth(input)
|
||||
inputListeners.set(input, listener)
|
||||
input.addEventListener('input', listener)
|
||||
syncInputWidth(input)
|
||||
},
|
||||
updated(input) {
|
||||
syncInputWidth(input)
|
||||
},
|
||||
beforeUnmount(input) {
|
||||
const listener = inputListeners.get(input)
|
||||
if (listener) input.removeEventListener('input', listener)
|
||||
inputListeners.delete(input)
|
||||
},
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
import { getPhoneOutputVolume } from '@/utils/phoneAudio'
|
||||
|
||||
export type MemorySound = 'flip' | 'match' | 'mismatch' | 'win'
|
||||
|
||||
type Tone = {
|
||||
@@ -48,10 +46,7 @@ export function playMemorySound(sound: MemorySound, enabled: boolean): void {
|
||||
oscillator.type = tone.type
|
||||
oscillator.frequency.setValueAtTime(tone.frequency, start)
|
||||
gain.gain.setValueAtTime(0.0001, start)
|
||||
gain.gain.exponentialRampToValueAtTime(
|
||||
Math.max(0.0001, tone.volume * getPhoneOutputVolume()),
|
||||
start + 0.012,
|
||||
)
|
||||
gain.gain.exponentialRampToValueAtTime(tone.volume, start + 0.012)
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, end)
|
||||
oscillator.connect(gain)
|
||||
gain.connect(audioContext.destination)
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { registerPhoneMediaElement } from '@/utils/phoneAudio'
|
||||
|
||||
import flagUrl from '@/assets/audio/minesweeper/flag.wav?url'
|
||||
import clearUrl from '@/assets/audio/minesweeper/clear.wav?url'
|
||||
import mineUrl from '@/assets/audio/minesweeper/mine.wav?url'
|
||||
@@ -30,7 +28,7 @@ function getPlayers(sound: MinesweeperSound): HTMLAudioElement[] {
|
||||
if (existing) return existing
|
||||
|
||||
const players = Array.from({ length: 3 }, () => {
|
||||
const player = registerPhoneMediaElement(new Audio(soundUrls[sound]))
|
||||
const player = new Audio(soundUrls[sound])
|
||||
player.preload = 'auto'
|
||||
player.volume = 0.82
|
||||
return player
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { getPhoneOutputVolume } from '@/utils/phoneAudio'
|
||||
|
||||
export type NeonDropSound =
|
||||
| 'clear'
|
||||
| 'drop'
|
||||
@@ -50,10 +48,7 @@ function playSequence(sound: NeonDropSound): void {
|
||||
oscillator.type = type
|
||||
oscillator.frequency.setValueAtTime(frequency, start + delay)
|
||||
gain.gain.setValueAtTime(0.0001, start + delay)
|
||||
gain.gain.exponentialRampToValueAtTime(
|
||||
Math.max(0.0001, 0.12 * getPhoneOutputVolume()),
|
||||
start + delay + 0.008,
|
||||
)
|
||||
gain.gain.exponentialRampToValueAtTime(0.12, start + delay + 0.008)
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, start + delay + duration)
|
||||
oscillator.connect(gain)
|
||||
gain.connect(context.destination)
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { registerPhoneMediaElement } from '@/utils/phoneAudio'
|
||||
|
||||
import gameOverUrl from '@/assets/audio/number-merge/game-over.wav?url'
|
||||
import mergeUrl from '@/assets/audio/number-merge/merge.wav?url'
|
||||
import moveUrl from '@/assets/audio/number-merge/move.wav?url'
|
||||
@@ -20,7 +18,7 @@ function getPlayers(sound: NumberMergeSound): HTMLAudioElement[] {
|
||||
if (existing) return existing
|
||||
|
||||
const players = Array.from({ length: 3 }, () => {
|
||||
const player = registerPhoneMediaElement(new Audio(soundUrls[sound]))
|
||||
const player = new Audio(soundUrls[sound])
|
||||
player.preload = 'auto'
|
||||
player.volume = 0.82
|
||||
return player
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { registerPhoneMediaElement } from '@/utils/phoneAudio'
|
||||
|
||||
import crashUrl from '@/assets/audio/sky-flappy/crash.wav?url'
|
||||
import flapUrl from '@/assets/audio/sky-flappy/flap.wav?url'
|
||||
import pointUrl from '@/assets/audio/sky-flappy/point.wav?url'
|
||||
@@ -13,7 +11,7 @@ export function playSkyFlappySound(sound: SkyFlappySound, enabled: boolean): voi
|
||||
let players = pools.get(sound)
|
||||
if (!players) {
|
||||
players = Array.from({ length: 3 }, () => {
|
||||
const player = registerPhoneMediaElement(new Audio(urls[sound]))
|
||||
const player = new Audio(urls[sound])
|
||||
player.preload = 'auto'
|
||||
player.volume = 0.84
|
||||
return player
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { registerPhoneMediaElement } from '@/utils/phoneAudio'
|
||||
|
||||
import fallUrl from '@/assets/audio/tower-stack/fall.wav?url'
|
||||
import hitUrl from '@/assets/audio/tower-stack/hit.wav?url'
|
||||
import perfectUrl from '@/assets/audio/tower-stack/perfect.wav?url'
|
||||
@@ -20,7 +18,7 @@ function getPlayers(sound: TowerStackSound): HTMLAudioElement[] {
|
||||
if (existing) return existing
|
||||
|
||||
const players = Array.from({ length: 3 }, () => {
|
||||
const player = registerPhoneMediaElement(new Audio(soundUrls[sound]))
|
||||
const player = new Audio(soundUrls[sound])
|
||||
player.preload = 'auto'
|
||||
player.volume = 0.84
|
||||
return player
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const readResourceFile = (path: string) =>
|
||||
readFileSync(
|
||||
new URL(`../../sky_phone/${path}`, import.meta.url),
|
||||
'utf8',
|
||||
).replace(/\r\n/g, '\n')
|
||||
|
||||
describe('housing provider contracts', () => {
|
||||
it('registers TGIANN House with server-authorized owner properties', () => {
|
||||
const adapter = readResourceFile('source/bridge/server/housing/tgiann.lua')
|
||||
|
||||
expect(adapter).toContain('local resource_name = "tgiann-house"')
|
||||
expect(adapter).toContain('FROM `tgiann_house`')
|
||||
expect(adapter).toContain('WHERE `owner` = ?')
|
||||
expect(adapter).toContain('Bridge.Framework.GetIdentifier(source)')
|
||||
expect(adapter).toContain(
|
||||
'Bridge.Housing.RegisterProvider(provider_name, {',
|
||||
)
|
||||
expect(adapter).toContain('return nil, "property_access_denied"')
|
||||
expect(adapter).not.toContain('`houseKeys`')
|
||||
expect(adapter).not.toContain('tgiann-house:getPlayerHouses')
|
||||
})
|
||||
|
||||
it('uses the published client export for entrance waypoints only', () => {
|
||||
const adapter = readResourceFile('source/bridge/client/housing/tgiann.lua')
|
||||
|
||||
expect(adapter).toContain(
|
||||
'return exports[resource_name]:getHouseData(house)',
|
||||
)
|
||||
expect(adapter).toContain(
|
||||
'Bridge.Normalize.Coordinates(house_data.doorCoord)',
|
||||
)
|
||||
expect(adapter).toContain(
|
||||
'Bridge.Housing.RegisterClientProvider(provider_name, {',
|
||||
)
|
||||
expect(adapter).toContain('SetNewWaypoint(')
|
||||
expect(adapter).not.toContain('enterHouse(')
|
||||
expect(adapter).not.toContain('forceOpenDoorHouse')
|
||||
})
|
||||
|
||||
it('advertises TGIANN in housing configuration and documentation', () => {
|
||||
const config = readResourceFile('config/config.lua')
|
||||
const readme = readFileSync(
|
||||
new URL('../../README.md', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
expect(config).toContain(
|
||||
'"rtx", "quasar", "tgiann", "vms", "rx", "nolag", "sn"',
|
||||
)
|
||||
expect(readme).toContain('Quasar Housing, TGIANN House, VMS Housing')
|
||||
expect(readme).toContain('`quasar`, `tgiann`, `vms`')
|
||||
expect(readme).toContain(
|
||||
'expects `tgiann-core` to start before `tgiann-house`',
|
||||
)
|
||||
expect(readme).toContain(
|
||||
'lists server-authorized owner properties, and supports entrance waypoints',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,103 +0,0 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const mediaConfig = readFileSync(
|
||||
new URL('../../sky_phone/config/media.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const manifest = readFileSync(
|
||||
new URL('../../sky_phone/fxmanifest.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const mediaProviderConfig = readFileSync(
|
||||
new URL(
|
||||
'../../sky_phone/source/server/media_provider_config.lua',
|
||||
import.meta.url,
|
||||
),
|
||||
'utf8',
|
||||
)
|
||||
const mediaImportAdapter = readFileSync(
|
||||
new URL(
|
||||
'../../sky_phone/source/server/media_import/fivemanage.lua',
|
||||
import.meta.url,
|
||||
),
|
||||
'utf8',
|
||||
)
|
||||
const mediaServer = readFileSync(
|
||||
new URL('../../sky_phone/source/server/media.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const memoServer = readFileSync(
|
||||
new URL('../../sky_phone/source/server/memos.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const mediaCapture = readFileSync(
|
||||
new URL('./components/PhoneMediaCapture.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const memoRecorder = readFileSync(
|
||||
new URL('./components/PhoneMemoRecorder.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
describe('FiveManage server configuration contract', () => {
|
||||
it('keeps the provider token in the server-only media config', () => {
|
||||
expect(mediaConfig).toMatch(/FiveManage\s*=\s*{\s*ApiKey\s*=/)
|
||||
expect(mediaProviderConfig).toContain(
|
||||
'return trim_key(Config.Media.FiveManage.ApiKey)',
|
||||
)
|
||||
expect(mediaProviderConfig).not.toContain('GetConvar')
|
||||
})
|
||||
|
||||
it('uses one resolver for Camera uploads and FiveManage imports', () => {
|
||||
expect(
|
||||
manifest.indexOf("'source/server/media_provider_config.lua'"),
|
||||
).toBeLessThan(manifest.indexOf("'source/server/media_import.lua'"))
|
||||
expect(mediaServer).toContain(
|
||||
'SkyPhoneMediaProviderConfig.FiveManageApiKey()',
|
||||
)
|
||||
expect(mediaImportAdapter).toContain(
|
||||
'SkyPhoneMediaProviderConfig.FiveManageApiKey(website.ApiKey)',
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the direct FiveManage upload response flow for Camera and voice memos', () => {
|
||||
expect(mediaConfig).not.toContain('VerificationRetryDelaysMs')
|
||||
expect(mediaCapture).toContain("form.append('file', blob, fileName)")
|
||||
expect(mediaCapture).not.toContain("form.append('path'")
|
||||
expect(mediaCapture).not.toContain("form.append(\n 'metadata'")
|
||||
expect(memoRecorder).toContain(
|
||||
"form.append('file', pending.blob, pending.fileName)",
|
||||
)
|
||||
expect(memoRecorder).not.toContain("form.append('path'")
|
||||
expect(memoRecorder).not.toContain("form.append(\n 'metadata'")
|
||||
expect(mediaServer).toContain(
|
||||
'Accepting the direct FiveManage upload response',
|
||||
)
|
||||
expect(mediaServer).toContain('remote_id = remote_id')
|
||||
expect(mediaServer).toContain('url = uploaded_url')
|
||||
expect(mediaServer).not.toContain('"HEAD"')
|
||||
expect(mediaServer).not.toContain('authenticated upload-path lookup')
|
||||
})
|
||||
|
||||
it('allowlists the FiveManage API and media hosts', () => {
|
||||
expect(mediaServer).toContain('["api.fivemanage.com"] = true')
|
||||
expect(mediaServer).toContain('["fmapi.net"] = true')
|
||||
expect(mediaServer).toContain(
|
||||
'uploaded_host:lower() ~= "r2.fivemanage.com"',
|
||||
)
|
||||
})
|
||||
|
||||
it('validates and preserves the recorded memo size before upload', () => {
|
||||
expect(memoRecorder).toContain('sizeBytes: blob.size')
|
||||
expect(memoServer).toContain(
|
||||
'local size_bytes = tonumber(data.sizeBytes)',
|
||||
)
|
||||
expect(memoServer).toContain(
|
||||
'size_bytes < 1 or size_bytes > Config.Memos.MaximumBytes',
|
||||
)
|
||||
expect(memoServer).toContain('size_bytes = memo.size_bytes')
|
||||
expect(mediaServer).toContain('size = state.size_bytes')
|
||||
})
|
||||
})
|
||||
@@ -1,21 +0,0 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const mediaCapture = readFileSync(
|
||||
new URL('./components/PhoneMediaCapture.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const mediaServer = readFileSync(
|
||||
new URL('../../sky_phone/source/server/media.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
describe('media upload diagnostics contracts', () => {
|
||||
it('forwards browser upload failure context to the server log', () => {
|
||||
expect(mediaCapture).toContain("let debugStage = 'provider_request'")
|
||||
expect(mediaCapture).toContain('debugStatus: debug.status')
|
||||
expect(mediaServer).toContain('Client-reported upload failure')
|
||||
expect(mediaServer).toContain('diagnostic_text(data.debugMessage, 240)')
|
||||
})
|
||||
})
|
||||
@@ -1,93 +0,0 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
function source(path: string): string {
|
||||
return readFileSync(new URL(path, import.meta.url), 'utf8')
|
||||
}
|
||||
|
||||
const config = source('../../sky_phone/config/config.lua')
|
||||
const configDefault = source('../../sky_phone/source/shared/config_default.lua')
|
||||
const framework = source('../../sky_phone/source/bridge/server/framework.lua')
|
||||
const qbox = source('../../sky_phone/source/bridge/server/frameworks/qbox.lua')
|
||||
const configurator = source(
|
||||
'../../sky_phone/source/server/phone_configurator.lua',
|
||||
)
|
||||
const configuratorFixture = source('../testserver/configurator-fixture.cjs')
|
||||
|
||||
describe('fixed server permissions', () => {
|
||||
it('defines every protected phone capability only in config.lua', () => {
|
||||
expect(config).toContain('Config.CommandPermissions = {')
|
||||
for (const permission of [
|
||||
'phonepanel',
|
||||
'phonetestdata',
|
||||
'fliptokverify',
|
||||
'picstagramverify',
|
||||
'picstagramadmin',
|
||||
]) {
|
||||
expect(config).toMatch(new RegExp(`\\s${permission} = \\{`))
|
||||
}
|
||||
expect(config).not.toContain('AdminGroups =')
|
||||
expect(configDefault).not.toContain('Config.PhoneConfigurator')
|
||||
expect(configDefault).not.toContain('Config.CommandPermissions')
|
||||
expect(configDefault).not.toContain('Config.CustomTones')
|
||||
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" and key ~= "CustomTones" then',
|
||||
)
|
||||
expect(configuratorFixture).toContain('delete config.CommandPermissions')
|
||||
expect(configuratorFixture).toContain('delete config.CustomTones')
|
||||
for (const path of [
|
||||
'AdminPanel.AdminGroups',
|
||||
'TestData.AdminGroups',
|
||||
'FlipTok.AdminGroups',
|
||||
'Picstagram.AdminGroups',
|
||||
]) {
|
||||
expect(configurator).toContain(`["${path}"] = true`)
|
||||
}
|
||||
expect(configurator).toContain(
|
||||
'SKY PHONE CONFIGURATION FILES ARE DISABLED',
|
||||
)
|
||||
expect(configurator).toContain(
|
||||
'^1 Runtime settings from config.lua and media.lua are DISABLED.^0',
|
||||
)
|
||||
expect(configurator).toContain(
|
||||
'^1 Configure all phone and media settings IN GAME through /phonepanel.^0',
|
||||
)
|
||||
expect(configurator).toContain(
|
||||
'^1 Config.PhoneConfigurator, Config.CommandPermissions and Config.CustomTones remain file-based.^0',
|
||||
)
|
||||
})
|
||||
|
||||
it('authorizes Qbox through ACE before retaining framework group support', () => {
|
||||
expect(framework).toContain(
|
||||
'local groups = Config.CommandPermissions[permission]',
|
||||
)
|
||||
expect(qbox).toContain(
|
||||
'if IsPlayerAceAllowed(tostring(source), group) then',
|
||||
)
|
||||
expect(qbox.indexOf('IsPlayerAceAllowed')).toBeLessThan(
|
||||
qbox.indexOf('exports.qbx_core:HasGroup'),
|
||||
)
|
||||
})
|
||||
|
||||
it('uses stable permission identifiers for every protected operation', () => {
|
||||
const expectations = [
|
||||
['../../sky_phone/source/server/admin.lua', 'phonepanel'],
|
||||
['../../sky_phone/source/server/testdata.lua', 'phonetestdata'],
|
||||
['../../sky_phone/source/server/fliptok.lua', 'fliptokverify'],
|
||||
['../../sky_phone/source/server/picstagram.lua', 'picstagramverify'],
|
||||
['../../sky_phone/source/server/picstagram.lua', 'picstagramadmin'],
|
||||
] as const
|
||||
|
||||
for (const [path, permission] of expectations) {
|
||||
expect(source(path)).toContain(
|
||||
`Bridge.Framework.HasPermission(source, "${permission}")`,
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,37 +0,0 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const appSource = readFileSync(new URL('./App.vue', import.meta.url), 'utf8')
|
||||
const audioSource = readFileSync(
|
||||
new URL('./utils/phoneAudio.ts', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const mainCss = readFileSync(
|
||||
new URL('./assets/main.css', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const musicSource = readFileSync(
|
||||
new URL('./stores/music.ts', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
describe('phone audio output contract', () => {
|
||||
it('applies the hardware volume to app media and live YouTube playback', () => {
|
||||
expect(appSource).toContain('installPhoneAudioController()')
|
||||
expect(appSource).toContain('setPhoneOutputVolume(volume / 100)')
|
||||
expect(appSource).toContain(
|
||||
'if (radio.data.connected) void radio.setVolume(volume)',
|
||||
)
|
||||
expect(audioSource).toContain(
|
||||
"document.addEventListener('play', onMediaPlay, true)",
|
||||
)
|
||||
expect(audioSource).toContain('localVolume * outputVolume')
|
||||
expect(musicSource).toContain('registerPhoneMediaElement(new Audio())')
|
||||
expect(musicSource).toContain('store.volume * getPhoneOutputVolume() * 100')
|
||||
})
|
||||
|
||||
it('renders the hardware speaker symbol in grey', () => {
|
||||
expect(mainCss).toMatch(/\.phone-volume-hud\s*\{[\s\S]*?color:\s*#a9abb2;/)
|
||||
})
|
||||
})
|
||||
@@ -3,27 +3,17 @@ import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const readResourceFile = (path: string) =>
|
||||
readFileSync(
|
||||
new URL(`../../sky_phone/${path}`, import.meta.url),
|
||||
'utf8',
|
||||
).replace(/\r\n/g, '\n')
|
||||
readFileSync(new URL(`../../sky_phone/${path}`, import.meta.url), 'utf8')
|
||||
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'],
|
||||
@@ -44,73 +34,22 @@ describe('phone inventory contracts', () => {
|
||||
const phoneServer = readResourceFile('source/server/phone.lua')
|
||||
|
||||
expect(phoneServer).toContain(
|
||||
'Bridge.Inventory.RegisterUsableItem(item_name, function(...)',
|
||||
)
|
||||
expect(phoneServer).toContain('if Config.Phone.Item == item_name then')
|
||||
expect(phoneServer).toContain(
|
||||
'if not Bridge.Inventory.RegisterUsableItem(item_name, function(...)',
|
||||
'Bridge.Inventory.RegisterUsableItem(Config.Phone.Item, open_phone)',
|
||||
)
|
||||
expect(phoneServer).toContain('if not usable_registered then')
|
||||
})
|
||||
|
||||
it('uses One Inventory slot ids and authoritative slot reads', () => {
|
||||
const adapter = readResourceFile(
|
||||
'source/bridge/server/inventory/one.lua',
|
||||
)
|
||||
|
||||
expect(adapter).toContain('inventory:GetSlotIdsWithItem(')
|
||||
expect(adapter).toContain(
|
||||
'local normalized = Bridge.Inventory.GetSlot(source, slot_id)',
|
||||
)
|
||||
expect(adapter).not.toContain('inventory:SearchInventory(')
|
||||
expect(adapter).toContain(
|
||||
'inventory:SetItemMetadata(source, slot.slot, requested_metadata) == false',
|
||||
)
|
||||
})
|
||||
|
||||
it('serializes and rate-limits phone bootstrap requests on both sides', () => {
|
||||
const phoneClient = readResourceFile('source/client/main.lua')
|
||||
const phoneServer = readResourceFile('source/server/phone.lua')
|
||||
|
||||
expect(phoneClient).toContain('local function request_phone_open(')
|
||||
expect(phoneClient).toMatch(
|
||||
/request_phone_open\(callback_name\)[\s\S]*?open_requested = true[\s\S]*?Bridge\.Callbacks\.Trigger\(callback_name, \{\}\)/,
|
||||
)
|
||||
expect(phoneClient).toMatch(
|
||||
/RegisterNetEvent\("sky_phone:device:error"[\s\S]*?if not is_open then[\s\S]*?open_requested = false/,
|
||||
)
|
||||
expect(phoneServer).toContain('local phone_open_in_progress = {}')
|
||||
expect(phoneServer).toContain(
|
||||
'SkyPhone.AllowOperation(source, "phone_open", request_limit, 60)',
|
||||
)
|
||||
expect(phoneServer).toContain(
|
||||
'pcall(perform_phone_open, source, used_item)',
|
||||
)
|
||||
})
|
||||
|
||||
it('auto-detects registered inventories and forces metadata-free adapters into compatible modes', () => {
|
||||
it('auto-detects HEX and limits count-based ESX inventories to metadata-free modes', () => {
|
||||
const inventoryBridge = readResourceFile(
|
||||
'source/bridge/server/inventory.lua',
|
||||
)
|
||||
|
||||
expect(inventoryBridge).toContain(
|
||||
'{ name = "hex", resource = "hex_4_inventory", framework = "esx", metadata = false },',
|
||||
)
|
||||
expect(inventoryBridge).toContain(
|
||||
'GetResourceState(adapter.resource) == "started"',
|
||||
)
|
||||
expect(inventoryBridge).toContain('configured_inventory = adapter.name')
|
||||
expect(inventoryBridge).toContain('selected_adapter.metadata == false')
|
||||
expect(inventoryBridge).toContain('Config.Phone.Unique = false')
|
||||
expect(inventoryBridge).toContain('Config.Sim.Enabled = false')
|
||||
expect(inventoryBridge).toContain(
|
||||
'does not support item metadata; unique phones and physical SIM cards were disabled automatically',
|
||||
)
|
||||
expect(inventoryBridge).toContain(
|
||||
'AddEventHandler("sky_phone:configurator:serverUpdated"',
|
||||
)
|
||||
expect(inventoryBridge).not.toContain(
|
||||
'cannot store unique phone or physical SIM metadata',
|
||||
'GetResourceState("hex_4_inventory") == "started"',
|
||||
)
|
||||
expect(inventoryBridge).toContain('configured_inventory = "hex"')
|
||||
expect(inventoryBridge).toContain('Config.Phone.Unique ~= false')
|
||||
expect(inventoryBridge).toContain('Config.Sim.Enabled ~= false')
|
||||
})
|
||||
|
||||
it('provides the LB IsOpen export alias from the authoritative client state', () => {
|
||||
@@ -161,15 +100,9 @@ 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')
|
||||
@@ -201,10 +134,10 @@ describe('phone inventory contracts', () => {
|
||||
'SkyPhoneCompatibility.RegisterExportAlias("lb-phone", "ToggleOpen"',
|
||||
)
|
||||
expect(phoneClient).toContain(
|
||||
'local result = Bridge.Callbacks.Trigger(callback_name, {})',
|
||||
'local result = Bridge.Callbacks.Trigger("sky_phone:device:open-request", {})',
|
||||
)
|
||||
expect(phoneClient).toMatch(
|
||||
/result\.success == true[\s\S]*open_requested = false[\s\S]*open_without_focus = false[\s\S]*return false/,
|
||||
/result\.success ~= true[\s\S]*open_without_focus = false[\s\S]*return false/,
|
||||
)
|
||||
expect(phoneBridge).toContain(
|
||||
'SkyPhoneCompatibility.RegisterExportAlias("lb-phone", "IsPhoneOnScreen"',
|
||||
@@ -224,9 +157,7 @@ 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)')
|
||||
})
|
||||
|
||||
@@ -279,70 +210,28 @@ 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"')
|
||||
})
|
||||
|
||||
it('keeps the phone key mapping command stable so FiveM user rebindings persist', () => {
|
||||
it('opens from a configurable F1 mapping without client-provided device identity', () => {
|
||||
const config = readResourceFile('config/config.lua')
|
||||
const phoneClient = readResourceFile('source/client/main.lua')
|
||||
const phoneServer = readResourceFile('source/server/phone.lua')
|
||||
|
||||
expect(config).toContain('Keybind = "F1"')
|
||||
expect(phoneClient).toContain('local phone_key_mapping_registered = false')
|
||||
expect(phoneClient).toContain('refresh_phone_key_mapping = function()')
|
||||
expect(phoneClient).toMatch(
|
||||
/RegisterKeyMapping\(\s*"sky_phone_toggle",\s*locale\.Controls\.OpenPhone,\s*"keyboard",\s*key_name\s*\)/,
|
||||
)
|
||||
expect(phoneClient).not.toContain('sky_phone_toggle_config_')
|
||||
expect(phoneClient).not.toContain('key_mapping_revision')
|
||||
expect(phoneClient).toContain(
|
||||
'request_phone_open("sky_phone:device:open-request")',
|
||||
'RegisterKeyMapping("sky_phone_toggle", locale.Controls.OpenPhone, "keyboard", Config.Phone.Keybind)',
|
||||
)
|
||||
expect(phoneClient).toContain(
|
||||
'Bridge.Callbacks.Trigger("sky_phone:device:open-request", {})',
|
||||
)
|
||||
expect(phoneServer).toContain(
|
||||
'Bridge.Callbacks.Register("sky_phone:device:open-request", function(source)',
|
||||
)
|
||||
})
|
||||
|
||||
it('applies development command changes immediately without a resource restart', () => {
|
||||
const phoneClient = readResourceFile('source/client/main.lua')
|
||||
|
||||
expect(phoneClient).toContain('local active_development_command = nil')
|
||||
expect(phoneClient).toContain('refresh_development_command = function()')
|
||||
expect(phoneClient).toContain(
|
||||
'local command_name = Config.Phone.DevelopmentCommand and Config.Command or nil',
|
||||
)
|
||||
expect(phoneClient).toContain('RegisterCommand(command_name, function()')
|
||||
expect(phoneClient).toContain(
|
||||
'if active_development_command == command_name and Config.Phone.DevelopmentCommand then',
|
||||
)
|
||||
expect(phoneClient).toContain(
|
||||
'TriggerEvent("chat:removeSuggestion", "/" .. active_development_command)',
|
||||
)
|
||||
expect(phoneClient).toMatch(
|
||||
/AddEventHandler\("sky_phone:configurator:updated", function\(\)[\s\S]*?refresh_development_command\(\)/,
|
||||
)
|
||||
})
|
||||
|
||||
it('opens a running live activity with Space without affecting normal gameplay', () => {
|
||||
const phoneClient = readResourceFile('source/client/main.lua')
|
||||
|
||||
expect(phoneClient).toContain(
|
||||
'RegisterCommand("sky_phone_live_activity_open"',
|
||||
)
|
||||
expect(phoneClient).toContain(
|
||||
'if not live_activity_active or is_open or open_requested then',
|
||||
)
|
||||
expect(phoneClient).toContain(
|
||||
'RegisterKeyMapping(\n "sky_phone_live_activity_open"',
|
||||
)
|
||||
expect(phoneClient).toContain('"SPACE"')
|
||||
expect(phoneClient).toContain('RegisterNUICallback("ui:live-activity"')
|
||||
})
|
||||
|
||||
it('keeps a server-selected unique handset as the preferred hotkey device', () => {
|
||||
const phoneServer = readResourceFile('source/server/phone.lua')
|
||||
|
||||
@@ -357,7 +246,7 @@ describe('phone inventory contracts', () => {
|
||||
const phoneServer = readResourceFile('source/server/phone.lua')
|
||||
const migration = readResourceFile('source/server/db_migrate.lua')
|
||||
|
||||
expect(phoneServer).toContain('if Config.Phone.Unique == false then')
|
||||
expect(phoneServer).toContain('if not unique_phones then')
|
||||
expect(phoneServer).toContain('return map_character_device(source, slot)')
|
||||
expect(phoneServer).toContain('FROM `sky_phone_character_devices`')
|
||||
expect(phoneServer).toContain('WHERE `owner_identifier` = ?')
|
||||
|
||||
@@ -29,10 +29,4 @@ 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 ?? '/')")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const resourceSource = (path: string): string =>
|
||||
readFileSync(new URL(`../../sky_phone/${path}`, import.meta.url), 'utf8')
|
||||
|
||||
const configSource = resourceSource('config/config.lua')
|
||||
const configuratorSource = resourceSource(
|
||||
'source/server/phone_configurator.lua',
|
||||
)
|
||||
const radioSource = resourceSource('source/server/radio.lua')
|
||||
const editorSource = readFileSync(
|
||||
new URL('./components/AdminConfigValueEditor.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
describe('radio configurator access contract', () => {
|
||||
it('lets the server allow everyone or enforce configured job grades', () => {
|
||||
expect(configSource).toMatch(
|
||||
/DisplayName\s*=\s*\{[\s\S]*?AllowEveryone\s*=\s*false,[\s\S]*?AllowedJobs\s*=\s*\{/,
|
||||
)
|
||||
|
||||
const start = radioSource.indexOf(
|
||||
'local function can_set_display_name(source)',
|
||||
)
|
||||
const end = radioSource.indexOf(
|
||||
'\nlocal function normalize_display_name',
|
||||
start,
|
||||
)
|
||||
const permissionSource = radioSource.slice(start, end)
|
||||
|
||||
expect(permissionSource).toContain('if config.AllowEveryone == true then')
|
||||
expect(permissionSource.indexOf('config.AllowEveryone')).toBeLessThan(
|
||||
permissionSource.indexOf('Bridge.Framework.GetJob(source)'),
|
||||
)
|
||||
expect(permissionSource).toContain('config.AllowedJobs[job.name]')
|
||||
expect(permissionSource).toContain('(tonumber(job.grade) or 0)')
|
||||
})
|
||||
|
||||
it('keeps both radio job tables mutable and validates their value types', () => {
|
||||
expect(configuratorSource).toContain(
|
||||
'path == "Radio.DisplayName.AllowedJobs"',
|
||||
)
|
||||
expect(configuratorSource).toContain(
|
||||
'path:match("^Radio%.LockedChannels%.%d+%.jobs$")',
|
||||
)
|
||||
expect(configuratorSource).toContain(
|
||||
'path == "Companies.Definitions" or radio_job_entry_default(path) ~= nil',
|
||||
)
|
||||
expect(configuratorSource).toMatch(
|
||||
/entryDefault = radio_job_default,[\s\S]*?mutableKeys = true,[\s\S]*?valueType = type\(radio_job_default\)/,
|
||||
)
|
||||
expect(configuratorSource).toContain('not key:match("^[a-z0-9_-]+$")')
|
||||
})
|
||||
|
||||
it('shows a compact job-name input for both radio job tables', () => {
|
||||
expect(editorSource).toContain(
|
||||
"props.path === 'Radio.DisplayName.AllowedJobs'",
|
||||
)
|
||||
expect(editorSource).toContain(
|
||||
String.raw`/^Radio\.LockedChannels\[\d+\]\.jobs$/`,
|
||||
)
|
||||
expect(editorSource).toContain(
|
||||
'isJobTable ? labels.jobPlaceholder : labels.keyPlaceholder',
|
||||
)
|
||||
expect(editorSource).toContain(
|
||||
'isJobTable ? labels.addJob : labels.addField',
|
||||
)
|
||||
expect(editorSource).toContain('function updateNewObjectKey(event: Event)')
|
||||
expect(editorSource).toContain('.toLowerCase()')
|
||||
expect(editorSource).toContain(".replace(/[^a-z0-9_-]/g, '')")
|
||||
expect(editorSource).toContain('.slice(0, 64)')
|
||||
|
||||
for (const locale of ['en', 'de', 'es']) {
|
||||
const localeSource = resourceSource(`config/locales/${locale}.lua`)
|
||||
expect(localeSource).toContain(
|
||||
`Locales["${locale}"].Nui.AdminPanel.configurator.table.addJob`,
|
||||
)
|
||||
expect(localeSource).toContain(
|
||||
`Locales["${locale}"].Nui.AdminPanel.configurator.table.jobPlaceholder`,
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -15,12 +15,6 @@ const developmentRoutes: RouteRecordRaw[] = import.meta.env.DEV
|
||||
name: 'development-sky-ui',
|
||||
path: '/development/sky-ui/:demo?',
|
||||
},
|
||||
{
|
||||
component: () =>
|
||||
import('@/views/development/PhoneDynamicIslandGallery.vue'),
|
||||
name: 'development-dynamic-islands',
|
||||
path: '/development/dynamic-islands',
|
||||
},
|
||||
]
|
||||
: []
|
||||
|
||||
|
||||
@@ -1,370 +0,0 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import type {
|
||||
AdminAuditEntry,
|
||||
AdminActivityResponse,
|
||||
AdminBootstrap,
|
||||
AdminCallActivity,
|
||||
AdminConfigurator,
|
||||
AdminConfiguratorChange,
|
||||
AdminCredential,
|
||||
AdminCustomTone,
|
||||
AdminCustomToneCreate,
|
||||
AdminMessageActivity,
|
||||
AdminPlayerDetail,
|
||||
AdminPlayerSummary,
|
||||
AdminStats,
|
||||
} from '@/types/admin'
|
||||
import {
|
||||
cacheCustomPhoneTonePayload,
|
||||
CUSTOM_TONE_CHUNK_CHARS,
|
||||
} from '@/utils/customTones'
|
||||
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,
|
||||
customTones: [] as AdminCustomTone[],
|
||||
customTonesLoading: 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 loadCustomTones(): Promise<boolean> {
|
||||
this.customTonesLoading = true
|
||||
const response = await nuiCall<AdminCustomTone[]>('admin:tones')
|
||||
this.customTonesLoading = false
|
||||
if (!response.success || !response.data) {
|
||||
this.error = response.error ?? 'request_failed'
|
||||
return false
|
||||
}
|
||||
this.customTones = response.data
|
||||
this.error = ''
|
||||
return true
|
||||
},
|
||||
async createCustomTone(
|
||||
tone: AdminCustomToneCreate,
|
||||
): Promise<NuiResponse<AdminCustomTone[]>> {
|
||||
this.actionKey = 'custom-tone:create'
|
||||
const { payload, ...metadata } = tone
|
||||
let uploadId = ''
|
||||
try {
|
||||
const started = await nuiCall<{ uploadId: string }>(
|
||||
'admin:tone-upload-start',
|
||||
{
|
||||
...metadata,
|
||||
payloadLength: payload.length,
|
||||
},
|
||||
)
|
||||
uploadId = started.data?.uploadId ?? ''
|
||||
if (!started.success || !uploadId) {
|
||||
this.error = started.error ?? 'request_failed'
|
||||
return { error: this.error, success: false }
|
||||
}
|
||||
|
||||
for (let offset = 0, index = 1; offset < payload.length; index += 1) {
|
||||
const response = await nuiCall('admin:tone-upload-chunk', {
|
||||
chunk: payload.slice(offset, offset + CUSTOM_TONE_CHUNK_CHARS),
|
||||
index,
|
||||
uploadId,
|
||||
})
|
||||
if (!response.success) {
|
||||
await nuiCall('admin:tone-upload-cancel', { uploadId })
|
||||
this.error = response.error ?? 'request_failed'
|
||||
return { error: this.error, success: false }
|
||||
}
|
||||
offset += CUSTOM_TONE_CHUNK_CHARS
|
||||
}
|
||||
|
||||
const response: NuiResponse<AdminCustomTone[]> & {
|
||||
toneId?: unknown
|
||||
} = await nuiCall<AdminCustomTone[]>('admin:tone-upload-finish', {
|
||||
uploadId,
|
||||
})
|
||||
if (response.success && response.data) {
|
||||
this.customTones = response.data
|
||||
if (typeof response.toneId === 'string') {
|
||||
cacheCustomPhoneTonePayload(
|
||||
`custom:${response.toneId}`,
|
||||
tone.mimeType,
|
||||
payload,
|
||||
)
|
||||
}
|
||||
this.error = ''
|
||||
} else {
|
||||
await nuiCall('admin:tone-upload-cancel', { uploadId })
|
||||
this.error = response.error ?? 'request_failed'
|
||||
}
|
||||
return response
|
||||
} catch (error) {
|
||||
if (uploadId) {
|
||||
await nuiCall('admin:tone-upload-cancel', { uploadId })
|
||||
}
|
||||
console.error('[Phone admin] Custom tone upload failed.', error)
|
||||
this.error = 'request_failed'
|
||||
return { error: this.error, success: false }
|
||||
} finally {
|
||||
this.actionKey = ''
|
||||
}
|
||||
},
|
||||
async deleteCustomTone(
|
||||
id: string,
|
||||
): Promise<NuiResponse<AdminCustomTone[]>> {
|
||||
this.actionKey = `custom-tone:delete:${id}`
|
||||
const response = await nuiCall<AdminCustomTone[]>('admin:delete-tone', {
|
||||
id,
|
||||
})
|
||||
this.actionKey = ''
|
||||
if (response.success && response.data) {
|
||||
this.customTones = response.data
|
||||
this.error = ''
|
||||
} else {
|
||||
this.error = response.error ?? 'request_failed'
|
||||
}
|
||||
return response
|
||||
},
|
||||
async resetPasscode(
|
||||
source: number,
|
||||
imei: string,
|
||||
): Promise<NuiResponse<AdminPlayerDetail>> {
|
||||
this.actionKey = `${imei}:reset-passcode`
|
||||
const response = await nuiCall<AdminPlayerDetail>(
|
||||
'admin:reset-passcode',
|
||||
{ imei, source },
|
||||
)
|
||||
this.actionKey = ''
|
||||
if (response.success && response.data) {
|
||||
this.selectedPlayer = response.data
|
||||
delete this.revealedCredentials[imei]
|
||||
this.error = ''
|
||||
} else {
|
||||
this.error = response.error ?? 'request_failed'
|
||||
}
|
||||
return response
|
||||
},
|
||||
async changeNumber(
|
||||
source: number,
|
||||
imei: string,
|
||||
phoneNumber: string,
|
||||
): Promise<NuiResponse<AdminPlayerDetail>> {
|
||||
this.actionKey = `${imei}:change-number`
|
||||
const response = await nuiCall<AdminPlayerDetail>('admin:change-number', {
|
||||
imei,
|
||||
phoneNumber,
|
||||
source,
|
||||
})
|
||||
this.actionKey = ''
|
||||
if (response.success && response.data) {
|
||||
this.selectedPlayer = response.data
|
||||
delete this.deviceActivity[imei]
|
||||
this.error = ''
|
||||
} else {
|
||||
this.error = response.error ?? 'request_failed'
|
||||
}
|
||||
return response
|
||||
},
|
||||
async factoryReset(
|
||||
source: number,
|
||||
imei: string,
|
||||
): Promise<NuiResponse<AdminPlayerDetail>> {
|
||||
this.actionKey = `${imei}:factory-reset`
|
||||
const response = await nuiCall<AdminPlayerDetail>('admin:factory-reset', {
|
||||
imei,
|
||||
source,
|
||||
})
|
||||
this.actionKey = ''
|
||||
if (response.success && response.data) {
|
||||
this.selectedPlayer = response.data
|
||||
delete this.deviceActivity[imei]
|
||||
delete this.revealedCredentials[imei]
|
||||
this.error = ''
|
||||
} else {
|
||||
this.error = response.error ?? 'request_failed'
|
||||
}
|
||||
return response
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -64,63 +64,6 @@ describe('app store', () => {
|
||||
expect(apps.isInstalled('snake')).toBe(false)
|
||||
expect(apps.isInstalled('health')).toBe(true)
|
||||
expect(apps.isInstalled('citywarn')).toBe(true)
|
||||
expect(apps.homeLayout.dock).toEqual([
|
||||
'phone',
|
||||
'messages',
|
||||
'camera',
|
||||
'clock',
|
||||
])
|
||||
for (const dockAppId of ['phone', 'messages', 'camera', 'clock']) {
|
||||
expect(apps.homeLayout.grid).not.toContain(dockAppId)
|
||||
}
|
||||
})
|
||||
|
||||
it('removes server-disabled apps without forgetting device claims', () => {
|
||||
const apps = useAppStoreStore()
|
||||
|
||||
apps.hydrate({ claimedApps: ['weather', 'snake'] }, ['weather'])
|
||||
|
||||
expect(apps.isAvailable('weather')).toBe(false)
|
||||
expect(apps.isInstalled('weather')).toBe(false)
|
||||
expect(apps.homeLayout.grid).not.toContain('weather')
|
||||
expect(apps.claimedApps).toContain('weather')
|
||||
expect(apps.isInstalled('snake')).toBe(true)
|
||||
|
||||
apps.hydrate({ claimedApps: apps.claimedApps }, [])
|
||||
expect(apps.isInstalled('weather')).toBe(true)
|
||||
})
|
||||
|
||||
it('drops the retired admin app from persisted phone layouts', () => {
|
||||
const apps = useAppStoreStore()
|
||||
|
||||
apps.hydrate({ claimedApps: ['admin'] })
|
||||
expect(apps.homeLayout.grid).not.toContain('admin')
|
||||
})
|
||||
|
||||
it('migrates current layouts so dock apps are not repeated in the grid', () => {
|
||||
const apps = useAppStoreStore()
|
||||
|
||||
apps.hydrate({
|
||||
homeLayout: {
|
||||
dock: ['phone', 'messages', 'camera', 'clock'],
|
||||
grid: ['phone', 'messages', 'calculator', 'camera', 'clock'],
|
||||
hidden: [],
|
||||
pageCount: 1,
|
||||
version: HOME_LAYOUT_VERSION,
|
||||
},
|
||||
})
|
||||
|
||||
expect(apps.homeLayout.dock).toEqual([
|
||||
'phone',
|
||||
'messages',
|
||||
'camera',
|
||||
'clock',
|
||||
])
|
||||
for (const dockAppId of ['phone', 'messages', 'camera', 'clock']) {
|
||||
expect(apps.homeLayout.grid).not.toContain(dockAppId)
|
||||
}
|
||||
expect(apps.homeLayout.grid).toContain('calculator')
|
||||
expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('removes old automatic apps unless the player installed them', () => {
|
||||
@@ -264,23 +207,6 @@ describe('app store', () => {
|
||||
expect(apps.homeLayout.hidden).not.toContain('snake')
|
||||
})
|
||||
|
||||
it('uninstalls claimed Banking and Picstagram apps from every app state', () => {
|
||||
const apps = useAppStoreStore()
|
||||
apps.hydrate({ claimedApps: ['banking', 'picstagram'] })
|
||||
mocks.phone.saveDeviceNamespace.mockClear()
|
||||
|
||||
for (const appId of ['banking', 'picstagram'] as const) {
|
||||
expect(apps.isInstalled(appId)).toBe(true)
|
||||
expect(apps.uninstallApp(appId)).toBe(true)
|
||||
expect(apps.isInstalled(appId)).toBe(false)
|
||||
expect(apps.claimedApps).not.toContain(appId)
|
||||
expect(apps.uninstalledApps).toContain(appId)
|
||||
expect(apps.homeLayout.hidden).toContain(appId)
|
||||
}
|
||||
|
||||
expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('hydrates persisted removals while rejecting protected and invalid ids', () => {
|
||||
const apps = useAppStoreStore()
|
||||
|
||||
@@ -394,7 +320,7 @@ describe('app store', () => {
|
||||
const apps = useAppStoreStore()
|
||||
apps.hydrate(null)
|
||||
mocks.phone.saveDeviceNamespace.mockClear()
|
||||
const sourceIndex = apps.homeLayout.grid.indexOf('calculator')
|
||||
const sourceIndex = apps.homeLayout.grid.indexOf('phone')
|
||||
expect(sourceIndex).toBeGreaterThanOrEqual(0)
|
||||
|
||||
expect(
|
||||
@@ -403,7 +329,7 @@ describe('app store', () => {
|
||||
HOME_GRID_PAGE_SIZE,
|
||||
]),
|
||||
).toBe(true)
|
||||
expect(apps.homeLayout.grid[HOME_GRID_PAGE_SIZE]).toBe('calculator')
|
||||
expect(apps.homeLayout.grid[HOME_GRID_PAGE_SIZE]).toBe('phone')
|
||||
expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
@@ -460,18 +386,18 @@ describe('app store', () => {
|
||||
mocks.phone.saveDeviceNamespace.mockClear()
|
||||
|
||||
const notesIndex = apps.homeLayout.grid.indexOf('notes')
|
||||
const settingsIndex = apps.homeLayout.grid.indexOf('settings')
|
||||
const clockIndex = apps.homeLayout.grid.indexOf('clock')
|
||||
const folderId = apps.createHomeFolder(
|
||||
'grid',
|
||||
notesIndex,
|
||||
'grid',
|
||||
settingsIndex,
|
||||
clockIndex,
|
||||
'Utilities',
|
||||
)
|
||||
|
||||
expect(folderId).toBeTruthy()
|
||||
expect(getHomeFolder(apps.homeLayout, folderId!)?.apps).toEqual([
|
||||
'settings',
|
||||
'clock',
|
||||
'notes',
|
||||
])
|
||||
const mailIndex = apps.homeLayout.grid.indexOf('mail')
|
||||
@@ -479,7 +405,7 @@ describe('app store', () => {
|
||||
apps.moveHomeFolderApp(folderId!, 2, 0)
|
||||
apps.renameHomeFolder(folderId!, 'Work')
|
||||
expect(getHomeFolder(apps.homeLayout, folderId!)).toMatchObject({
|
||||
apps: ['mail', 'notes', 'settings'],
|
||||
apps: ['mail', 'notes', 'clock'],
|
||||
name: 'Work',
|
||||
})
|
||||
|
||||
@@ -491,41 +417,6 @@ describe('app store', () => {
|
||||
expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(5)
|
||||
})
|
||||
|
||||
it('materializes a trailing page only for a successful folder extraction', () => {
|
||||
const apps = useAppStoreStore()
|
||||
apps.hydrate(null)
|
||||
|
||||
const notesIndex = apps.homeLayout.grid.indexOf('notes')
|
||||
const settingsIndex = apps.homeLayout.grid.indexOf('settings')
|
||||
const folderId = apps.createHomeFolder(
|
||||
'grid',
|
||||
notesIndex,
|
||||
'grid',
|
||||
settingsIndex,
|
||||
'Utilities',
|
||||
)
|
||||
expect(folderId).toBeTruthy()
|
||||
|
||||
const originalPageCount = apps.homeLayout.pageCount
|
||||
const targetPage = originalPageCount + 1
|
||||
const targetIndex = originalPageCount * HOME_GRID_PAGE_SIZE
|
||||
expect(apps.homeLayout.grid[targetIndex]).toBeUndefined()
|
||||
|
||||
expect(
|
||||
apps.extractHomeFolderApp('missing-folder', 0, 'grid', targetIndex),
|
||||
).toBe(false)
|
||||
expect(apps.homeLayout.pageCount).toBe(originalPageCount)
|
||||
expect(apps.homeLayout.grid[targetIndex]).toBeUndefined()
|
||||
|
||||
mocks.phone.saveDeviceNamespace.mockClear()
|
||||
expect(apps.extractHomeFolderApp(folderId!, 0, 'grid', targetIndex)).toBe(
|
||||
true,
|
||||
)
|
||||
expect(apps.homeLayout.pageCount).toBe(targetPage)
|
||||
expect(apps.homeLayout.grid[targetIndex]).toBe('settings')
|
||||
expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not commit an installation to a different phone', () => {
|
||||
vi.useFakeTimers()
|
||||
const apps = useAppStoreStore()
|
||||
|
||||
@@ -19,14 +19,12 @@ import {
|
||||
createDefaultHomeLayout,
|
||||
deleteHomePage,
|
||||
extractHomeFolderApp,
|
||||
HOME_GRID_PAGE_SIZE,
|
||||
moveHomeFolderApp,
|
||||
moveHomeApp,
|
||||
moveHomeAppToGridPage,
|
||||
parseHomeLayout,
|
||||
reflowHomeGridForWidgetChange,
|
||||
renameHomeFolder,
|
||||
removeDockGridDuplicates,
|
||||
removeHomeApp,
|
||||
restoreHomeApp,
|
||||
type HomeArea,
|
||||
@@ -46,49 +44,27 @@ const pendingInstallations = new WeakMap<
|
||||
Map<LaunchablePhoneAppId, PendingInstallation>
|
||||
>()
|
||||
|
||||
function isAppDisabled(
|
||||
appId: LaunchablePhoneAppId,
|
||||
disabledApps: readonly LaunchablePhoneAppId[],
|
||||
): boolean {
|
||||
return disabledApps.includes(appId)
|
||||
}
|
||||
|
||||
function getDefaultGridIds(
|
||||
disabledApps: readonly LaunchablePhoneAppId[] = [],
|
||||
): LaunchablePhoneAppId[] {
|
||||
return PHONE_APPS.filter(
|
||||
(app) => app.dockOrder === null && !isAppDisabled(app.id, disabledApps),
|
||||
)
|
||||
function getDefaultGridIds(): LaunchablePhoneAppId[] {
|
||||
return [...PHONE_APPS]
|
||||
.sort((a, b) => a.gridOrder - b.gridOrder)
|
||||
.map((app) => app.id)
|
||||
}
|
||||
|
||||
function getDefaultDockIds(
|
||||
disabledApps: readonly LaunchablePhoneAppId[] = [],
|
||||
): LaunchablePhoneAppId[] {
|
||||
return PHONE_APPS.filter(
|
||||
(app) => app.dockOrder !== null && !isAppDisabled(app.id, disabledApps),
|
||||
)
|
||||
function getDefaultDockIds(): LaunchablePhoneAppId[] {
|
||||
return PHONE_APPS.filter((app) => app.dockOrder !== null)
|
||||
.sort((a, b) => (a.dockOrder ?? 0) - (b.dockOrder ?? 0))
|
||||
.map((app) => app.id)
|
||||
}
|
||||
|
||||
function getDefaultInstalledIds(
|
||||
disabledApps: readonly LaunchablePhoneAppId[] = [],
|
||||
): LaunchablePhoneAppId[] {
|
||||
return PHONE_APPS.filter((app) => {
|
||||
if (app.adminOnly || isAppDisabled(app.id, disabledApps)) return false
|
||||
return isExternalPhoneApp(app)
|
||||
function getDefaultInstalledIds(): LaunchablePhoneAppId[] {
|
||||
return PHONE_APPS.filter((app) =>
|
||||
isExternalPhoneApp(app)
|
||||
? app.defaultInstalled
|
||||
: DEFAULT_INSTALLED_PHONE_APP_IDS.has(app.id)
|
||||
}).map((app) => app.id)
|
||||
: DEFAULT_INSTALLED_PHONE_APP_IDS.has(app.id),
|
||||
).map((app) => app.id)
|
||||
}
|
||||
|
||||
function isProtectedHomeApp(
|
||||
appId: LaunchablePhoneAppId,
|
||||
disabledApps: readonly LaunchablePhoneAppId[],
|
||||
): boolean {
|
||||
if (isAppDisabled(appId, disabledApps)) return false
|
||||
function isProtectedHomeApp(appId: LaunchablePhoneAppId): boolean {
|
||||
const app = getPhoneApp(appId)
|
||||
return app
|
||||
? !isPhoneAppRemovable(app)
|
||||
@@ -126,7 +102,6 @@ function hasUninstalledBuiltinApp(
|
||||
export const useAppStoreStore = defineStore('app-store', {
|
||||
state: () => ({
|
||||
claimedApps: [] as LaunchablePhoneAppId[],
|
||||
disabledApps: [] as LaunchablePhoneAppId[],
|
||||
uninstalledApps: [] as LaunchablePhoneAppId[],
|
||||
homeLayout: createDefaultHomeLayout(
|
||||
getDefaultInstalledIds(),
|
||||
@@ -153,7 +128,6 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
return true
|
||||
},
|
||||
claimApp(id: LaunchablePhoneAppId): void {
|
||||
if (!this.isAvailable(id)) return
|
||||
this.uninstalledApps = this.uninstalledApps.filter(
|
||||
(appId) => appId !== id,
|
||||
)
|
||||
@@ -174,7 +148,6 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
this.installingApps = {}
|
||||
},
|
||||
installApp(id: LaunchablePhoneAppId): void {
|
||||
if (!this.isAvailable(id)) return
|
||||
const installed = this.isInstalled(id)
|
||||
if (
|
||||
this.installingApps[id] ||
|
||||
@@ -216,10 +189,6 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
)
|
||||
return
|
||||
}
|
||||
if (!this.isAvailable(id)) {
|
||||
delete this.installingApps[id]
|
||||
return
|
||||
}
|
||||
if (reportInstall && !isExternalPhoneApp(getPhoneApp(id))) {
|
||||
delete this.installingApps[id]
|
||||
console.error(
|
||||
@@ -249,14 +218,8 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
installations.set(id, { deviceImei, timer, token })
|
||||
pendingInstallations.set(this, installations)
|
||||
},
|
||||
hydrate(payload: unknown, disabledApps: unknown = []): void {
|
||||
hydrate(payload: unknown): void {
|
||||
this.cancelPendingInstalls()
|
||||
this.disabledApps = Array.isArray(disabledApps)
|
||||
? disabledApps.filter(
|
||||
(id): id is LaunchablePhoneAppId =>
|
||||
typeof id === 'string' && isPhoneAppId(id),
|
||||
)
|
||||
: []
|
||||
const data = payload as {
|
||||
claimedApps?: unknown
|
||||
homeLayout?: unknown
|
||||
@@ -273,15 +236,13 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
layoutVersion === 5 ||
|
||||
layoutVersion === 6
|
||||
this.claimedApps = Array.isArray(data?.claimedApps)
|
||||
? data.claimedApps.filter((id): id is LaunchablePhoneAppId => {
|
||||
if (typeof id !== 'string') return false
|
||||
const app = getPhoneApp(id)
|
||||
if (app?.adminOnly) return false
|
||||
return (
|
||||
isPhoneAppId(id) ||
|
||||
(supportsPersistedExternalApps && isValidExternalPhoneAppId(id))
|
||||
)
|
||||
})
|
||||
? data.claimedApps.filter(
|
||||
(id): id is LaunchablePhoneAppId =>
|
||||
typeof id === 'string' &&
|
||||
(isPhoneAppId(id) ||
|
||||
(supportsPersistedExternalApps &&
|
||||
isValidExternalPhoneAppId(id))),
|
||||
)
|
||||
: []
|
||||
this.uninstalledApps = Array.isArray(data?.uninstalledApps)
|
||||
? data.uninstalledApps.filter((id): id is LaunchablePhoneAppId => {
|
||||
@@ -291,36 +252,25 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
})
|
||||
: []
|
||||
const installedIds = [
|
||||
...new Set([
|
||||
...getDefaultInstalledIds(this.disabledApps),
|
||||
...this.claimedApps,
|
||||
]),
|
||||
].filter(
|
||||
(id) =>
|
||||
!this.uninstalledApps.includes(id) && this.isAvailable(id),
|
||||
)
|
||||
...new Set([...getDefaultInstalledIds(), ...this.claimedApps]),
|
||||
].filter((id) => !this.uninstalledApps.includes(id))
|
||||
const removedLegacyDefaults = hasUninstalledBuiltinApp(
|
||||
data?.homeLayout,
|
||||
installedIds,
|
||||
)
|
||||
const defaults = createDefaultHomeLayout(
|
||||
installedIds,
|
||||
getDefaultGridIds(this.disabledApps),
|
||||
getDefaultDockIds(this.disabledApps),
|
||||
getDefaultGridIds(),
|
||||
getDefaultDockIds(),
|
||||
)
|
||||
const parsedHomeLayout = parseHomeLayout(
|
||||
this.homeLayout = parseHomeLayout(
|
||||
data?.homeLayout,
|
||||
defaults,
|
||||
installedIds,
|
||||
false,
|
||||
)
|
||||
const normalizedHomeLayout = removeDockGridDuplicates(parsedHomeLayout)
|
||||
const removedDockGridDuplicates =
|
||||
normalizedHomeLayout !== parsedHomeLayout
|
||||
this.homeLayout = normalizedHomeLayout
|
||||
const protectedHiddenAppIds = this.homeLayout.hidden.filter((appId) =>
|
||||
isProtectedHomeApp(appId, this.disabledApps),
|
||||
)
|
||||
const protectedHiddenAppIds =
|
||||
this.homeLayout.hidden.filter(isProtectedHomeApp)
|
||||
for (const appId of protectedHiddenAppIds) {
|
||||
this.homeLayout = restoreHomeApp(this.homeLayout, appId)
|
||||
}
|
||||
@@ -342,7 +292,6 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
this.hydrated = true
|
||||
if (
|
||||
protectedHiddenAppIds.length ||
|
||||
removedDockGridDuplicates ||
|
||||
removedLegacyDefaults ||
|
||||
layoutVersion === 2 ||
|
||||
layoutVersion === 3 ||
|
||||
@@ -352,15 +301,10 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
this.persist()
|
||||
}
|
||||
},
|
||||
isAvailable(appId: LaunchablePhoneAppId): boolean {
|
||||
return !isAppDisabled(appId, this.disabledApps)
|
||||
},
|
||||
isInstalled(appId: LaunchablePhoneAppId): boolean {
|
||||
if (!this.isAvailable(appId)) return false
|
||||
const app = getPhoneApp(appId)
|
||||
if (app?.adminOnly) return false
|
||||
if (this.uninstalledApps.includes(appId)) return false
|
||||
if (this.claimedApps.includes(appId)) return true
|
||||
const app = getPhoneApp(appId)
|
||||
if (!app) return false
|
||||
return isExternalPhoneApp(app)
|
||||
? app.defaultInstalled
|
||||
@@ -368,26 +312,23 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
},
|
||||
reconcileCatalog(): void {
|
||||
const installedIds = [
|
||||
...new Set([
|
||||
...getDefaultInstalledIds(this.disabledApps),
|
||||
...this.claimedApps,
|
||||
]),
|
||||
].filter(
|
||||
(id) =>
|
||||
!this.uninstalledApps.includes(id) && this.isAvailable(id),
|
||||
)
|
||||
...new Set([...getDefaultInstalledIds(), ...this.claimedApps]),
|
||||
].filter((id) => !this.uninstalledApps.includes(id))
|
||||
const defaults = createDefaultHomeLayout(
|
||||
installedIds,
|
||||
getDefaultGridIds(this.disabledApps),
|
||||
getDefaultDockIds(this.disabledApps),
|
||||
getDefaultGridIds(),
|
||||
getDefaultDockIds(),
|
||||
)
|
||||
const previous = JSON.stringify(this.homeLayout)
|
||||
this.homeLayout = removeDockGridDuplicates(
|
||||
parseHomeLayout(this.homeLayout, defaults, installedIds, false),
|
||||
this.homeLayout = parseHomeLayout(
|
||||
this.homeLayout,
|
||||
defaults,
|
||||
installedIds,
|
||||
false,
|
||||
)
|
||||
|
||||
for (const appId of [...this.homeLayout.hidden]) {
|
||||
if (isProtectedHomeApp(appId, this.disabledApps)) {
|
||||
if (isProtectedHomeApp(appId)) {
|
||||
this.homeLayout = restoreHomeApp(this.homeLayout, appId)
|
||||
}
|
||||
}
|
||||
@@ -397,7 +338,6 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
}
|
||||
},
|
||||
recordLaunch(appId: LaunchablePhoneAppId): void {
|
||||
if (!this.isAvailable(appId)) return
|
||||
this.launchCounts[appId] = (this.launchCounts[appId] ?? 0) + 1
|
||||
this.persist()
|
||||
},
|
||||
@@ -520,35 +460,25 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
to: HomeArea,
|
||||
targetIndex: number,
|
||||
): boolean {
|
||||
let sourceLayout = this.homeLayout
|
||||
if (to === 'grid' && Number.isInteger(targetIndex) && targetIndex >= 0) {
|
||||
const targetPage = Math.floor(targetIndex / HOME_GRID_PAGE_SIZE) + 1
|
||||
while (sourceLayout.pageCount < targetPage) {
|
||||
const expanded = addHomePage(sourceLayout)
|
||||
if (expanded === sourceLayout) return false
|
||||
sourceLayout = expanded
|
||||
}
|
||||
}
|
||||
const next = extractHomeFolderApp(
|
||||
sourceLayout,
|
||||
this.homeLayout,
|
||||
folderId,
|
||||
sourceIndex,
|
||||
to,
|
||||
targetIndex,
|
||||
)
|
||||
if (next === sourceLayout) return false
|
||||
if (next === this.homeLayout) return false
|
||||
this.homeLayout = next
|
||||
this.persist()
|
||||
return true
|
||||
},
|
||||
removeHomeApp(appId: LaunchablePhoneAppId): void {
|
||||
if (isProtectedHomeApp(appId, this.disabledApps)) return
|
||||
if (isProtectedHomeApp(appId)) return
|
||||
|
||||
this.homeLayout = removeHomeApp(this.homeLayout, appId)
|
||||
this.persist()
|
||||
},
|
||||
restoreHomeApp(appId: LaunchablePhoneAppId): void {
|
||||
if (!this.isAvailable(appId)) return
|
||||
this.homeLayout = restoreHomeApp(this.homeLayout, appId)
|
||||
this.persist()
|
||||
},
|
||||
|
||||
@@ -4,18 +4,14 @@ import { ref } from 'vue'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import type { PhoneCall, PhoneContact, RecentCall } from '@/types/phone'
|
||||
import { nuiCall, type NuiResponse } from '@/utils/nui'
|
||||
import { findCustomPhoneTone, playCustomPhoneTone } from '@/utils/customTones'
|
||||
import {
|
||||
isBuiltInRingtoneId,
|
||||
type BuiltInRingtoneId,
|
||||
} from '@/utils/preferences'
|
||||
import type { RingtoneId } from '@/utils/preferences'
|
||||
import {
|
||||
playPhoneTone,
|
||||
playPhoneVibration,
|
||||
type PhoneToneId,
|
||||
} from '@/utils/tones'
|
||||
|
||||
const RINGTONE_TONES: Record<BuiltInRingtoneId, PhoneToneId> = {
|
||||
const RINGTONE_TONES: Record<RingtoneId, PhoneToneId> = {
|
||||
horizon: 'aurora',
|
||||
pulse: 'signal',
|
||||
skyline: 'apex',
|
||||
@@ -28,21 +24,6 @@ export const useCallsStore = defineStore('calls', () => {
|
||||
const recents = ref<RecentCall[]>([])
|
||||
let stopRingtone: (() => void) | null = null
|
||||
|
||||
function playSelectedRingtone(volume: number): () => void {
|
||||
const selected = phone.preferences.settings.ringtone
|
||||
const customTone = findCustomPhoneTone(
|
||||
phone.customTones.ringtones,
|
||||
selected,
|
||||
)
|
||||
if (customTone) return playCustomPhoneTone(customTone, volume, true)
|
||||
|
||||
return playPhoneTone(
|
||||
isBuiltInRingtoneId(selected) ? RINGTONE_TONES[selected] : 'apex',
|
||||
volume,
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
await Promise.all([loadContacts(), loadRecents()])
|
||||
}
|
||||
@@ -192,7 +173,11 @@ export const useCallsStore = defineStore('calls', () => {
|
||||
phone.preferences.settings.ringtoneVolume === 0
|
||||
stopRingtone = alertsMuted
|
||||
? playPhoneVibration('call', true)
|
||||
: playSelectedRingtone(phone.preferences.settings.ringtoneVolume)
|
||||
: playPhoneTone(
|
||||
RINGTONE_TONES[phone.preferences.settings.ringtone],
|
||||
phone.preferences.settings.ringtoneVolume,
|
||||
true,
|
||||
)
|
||||
}
|
||||
if (!['ringing', 'connected'].includes(call.state)) {
|
||||
window.setTimeout(() => {
|
||||
|
||||
@@ -7,11 +7,6 @@ import type {
|
||||
MusicTrack,
|
||||
} from '@/types/music'
|
||||
import { nuiCall } from '@/utils/nui'
|
||||
import {
|
||||
getPhoneOutputVolume,
|
||||
registerPhoneMediaElement,
|
||||
subscribePhoneOutputVolume,
|
||||
} from '@/utils/phoneAudio'
|
||||
|
||||
export type YouTubePlayer = {
|
||||
destroy: () => void
|
||||
@@ -53,7 +48,7 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
const audio = registerPhoneMediaElement(new Audio())
|
||||
const audio = new Audio()
|
||||
audio.preload = 'auto'
|
||||
const YOUTUBE_API_TIMEOUT_MS = 12000
|
||||
let audioBound = false
|
||||
@@ -64,11 +59,6 @@ let youtubeApiPromise: Promise<YouTubeApi> | null = null
|
||||
let youtubePlayer: YouTubePlayer | null = null
|
||||
let youtubeProgressTimer: number | null = null
|
||||
|
||||
subscribePhoneOutputVolume((volume) => {
|
||||
const localVolume = useMusicStore().volume
|
||||
youtubePlayer?.setVolume(localVolume * volume * 100)
|
||||
})
|
||||
|
||||
export function musicTrackKey(
|
||||
track: Pick<MusicTrack, 'id' | 'source'>,
|
||||
): string {
|
||||
@@ -286,7 +276,7 @@ async function loadYouTubeTrack(videoId: string): Promise<void> {
|
||||
const api = await loadYouTubeApi()
|
||||
const store = useMusicStore()
|
||||
if (youtubePlayer) {
|
||||
youtubePlayer.setVolume(store.volume * getPhoneOutputVolume() * 100)
|
||||
youtubePlayer.setVolume(store.volume * 100)
|
||||
youtubePlayer.loadVideoById(videoId)
|
||||
youtubePlayer.playVideo()
|
||||
startYoutubeProgress()
|
||||
@@ -329,9 +319,7 @@ async function loadYouTubeTrack(videoId: string): Promise<void> {
|
||||
},
|
||||
onReady: (event) => {
|
||||
youtubePlayer = event.target
|
||||
event.target.setVolume(
|
||||
useMusicStore().volume * getPhoneOutputVolume() * 100,
|
||||
)
|
||||
event.target.setVolume(useMusicStore().volume * 100)
|
||||
event.target.playVideo()
|
||||
startYoutubeProgress()
|
||||
resolve()
|
||||
@@ -566,7 +554,7 @@ export const useMusicStore = defineStore('music', {
|
||||
setVolume(value: number): void {
|
||||
this.volume = Math.max(0, Math.min(1, value))
|
||||
audio.volume = this.volume
|
||||
youtubePlayer?.setVolume(this.volume * getPhoneOutputVolume() * 100)
|
||||
youtubePlayer?.setVolume(this.volume * 100)
|
||||
},
|
||||
stop(): void {
|
||||
stopActiveMedia()
|
||||
|
||||
@@ -5,10 +5,8 @@ import { isPhoneAppId } from '@/config/apps'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import type { LaunchablePhoneAppId } from '@/types/apps'
|
||||
import { nuiCall } from '@/utils/nui'
|
||||
import { findCustomPhoneTone, playCustomPhoneTone } from '@/utils/customTones'
|
||||
import {
|
||||
DEFAULT_APP_NOTIFICATION_PREFERENCES,
|
||||
isBuiltInNotificationSoundId,
|
||||
type PhonePreferencesV1,
|
||||
} from '@/utils/preferences'
|
||||
import {
|
||||
@@ -192,34 +190,18 @@ export const useNotificationsStore = defineStore('notifications', () => {
|
||||
const alertsMuted =
|
||||
preferences.settings.notificationVolume === 0 &&
|
||||
preferences.settings.ringtoneVolume === 0
|
||||
const selectedSound = preferences.settings.notificationSound
|
||||
const customSound = notification.sound
|
||||
? undefined
|
||||
: findCustomPhoneTone(
|
||||
phone.customTones.notificationSounds,
|
||||
selectedSound,
|
||||
)
|
||||
const sound = notification.sound ?? preferences.settings.notificationSound
|
||||
const volume = notification.critical
|
||||
? preferences.settings.ringtoneVolume
|
||||
: preferences.settings.notificationVolume
|
||||
stopToneHandles.set(
|
||||
notification.id,
|
||||
alertsMuted
|
||||
? playPhoneVibration('notification', !!notification.persistent)
|
||||
: customSound
|
||||
? playCustomPhoneTone(
|
||||
customSound,
|
||||
volume,
|
||||
!!notification.persistent,
|
||||
)
|
||||
: playPhoneTone(
|
||||
notification.sound ??
|
||||
(isBuiltInNotificationSoundId(selectedSound)
|
||||
? selectedSound
|
||||
: 'chime'),
|
||||
volume,
|
||||
!!notification.persistent,
|
||||
),
|
||||
? playPhoneVibration(
|
||||
'notification',
|
||||
!!notification.persistent,
|
||||
)
|
||||
: playPhoneTone(sound, volume, !!notification.persistent),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -10,22 +10,14 @@ type LuaToken = {
|
||||
}
|
||||
|
||||
const frontendSourceDirectory = fileURLToPath(new URL('../', import.meta.url))
|
||||
const localeDirectory = fileURLToPath(
|
||||
new URL('../../../sky_phone/config/locales/', import.meta.url),
|
||||
const englishLocaleSource = readFileSync(
|
||||
new URL('../../../sky_phone/config/locales/en.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const localeSources = new Map(
|
||||
readdirSync(localeDirectory, { withFileTypes: true })
|
||||
.filter((entry) => entry.isFile() && entry.name.endsWith('.lua'))
|
||||
.map((entry) => [
|
||||
entry.name.replace(/\.lua$/, ''),
|
||||
readFileSync(join(localeDirectory, entry.name), 'utf8'),
|
||||
]),
|
||||
const germanLocaleSource = readFileSync(
|
||||
new URL('../../../sky_phone/config/locales/de.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const englishLocaleSource = localeSources.get('en')
|
||||
const germanLocaleSource = localeSources.get('de')
|
||||
if (!englishLocaleSource || !germanLocaleSource) {
|
||||
throw new Error('The bundled English and German phone locales are required.')
|
||||
}
|
||||
const phoneStoreSource = readFileSync(
|
||||
new URL('./phone.ts', import.meta.url),
|
||||
'utf8',
|
||||
@@ -126,29 +118,6 @@ function collectLuaLocaleValues(source: string): Map<string, string> {
|
||||
}
|
||||
|
||||
parseTable([])
|
||||
while (position < tokens.length) {
|
||||
const assignment = tokens.findIndex(
|
||||
(token, index) => index >= position && token.kind === '=',
|
||||
)
|
||||
if (assignment < 0) break
|
||||
const nui = tokens.findIndex(
|
||||
(token, index) =>
|
||||
index >= position && index < assignment && token.value === 'Nui',
|
||||
)
|
||||
if (nui < 0) {
|
||||
position = assignment + 1
|
||||
continue
|
||||
}
|
||||
const path = [
|
||||
'Nui',
|
||||
...tokens
|
||||
.slice(nui + 1, assignment)
|
||||
.filter((token) => token.kind === 'word')
|
||||
.map((token) => token.value),
|
||||
]
|
||||
position = assignment + 1
|
||||
parseValue(path)
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
@@ -156,10 +125,6 @@ function collectPlaceholders(value: string): string[] {
|
||||
return [...new Set(value.match(/\{[A-Za-z0-9_]+\}/g) ?? [])].sort()
|
||||
}
|
||||
|
||||
function collectNumberTokens(value: string): string[] {
|
||||
return value.match(/\d+/g) ?? []
|
||||
}
|
||||
|
||||
function collectDefaultLocalePaths(source: string): Set<string> {
|
||||
const ast = ts.createSourceFile(
|
||||
'phone.ts',
|
||||
@@ -235,26 +200,10 @@ function collectFrontendFiles(directory: string): string[] {
|
||||
}
|
||||
|
||||
describe('phone locale contract', () => {
|
||||
const localeValues = new Map(
|
||||
[...localeSources].map(([locale, source]) => [
|
||||
locale,
|
||||
collectLuaLocaleValues(source),
|
||||
]),
|
||||
)
|
||||
const englishValues = localeValues.get('en')!
|
||||
const germanValues = localeValues.get('de')!
|
||||
const translatedLocaleValues = [...localeValues].filter(
|
||||
([locale]) => locale !== 'en',
|
||||
)
|
||||
const englishValues = collectLuaLocaleValues(englishLocaleSource)
|
||||
const germanValues = collectLuaLocaleValues(germanLocaleSource)
|
||||
const englishPaths = new Set(englishValues.keys())
|
||||
|
||||
it.each([...localeSources])(
|
||||
'registers the %s locale under its file name',
|
||||
(locale, source) => {
|
||||
expect(source.match(/^Locales\["([^"]+)"\]/)?.[1]).toBe(locale)
|
||||
},
|
||||
)
|
||||
|
||||
it('keeps every bundled frontend fallback in en.lua', () => {
|
||||
const missing = [...collectDefaultLocalePaths(phoneStoreSource)].filter(
|
||||
(path) => !englishPaths.has(`Nui.${path}`),
|
||||
@@ -299,52 +248,26 @@ describe('phone locale contract', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it.each(translatedLocaleValues)(
|
||||
'keeps %s structurally aligned with English',
|
||||
(_locale, values) => {
|
||||
expect([...values.keys()].sort()).toEqual([...englishPaths].sort())
|
||||
},
|
||||
)
|
||||
it('keeps German structurally aligned with English', () => {
|
||||
const germanPaths = new Set(germanValues.keys())
|
||||
|
||||
it.each(translatedLocaleValues)(
|
||||
'keeps %s interpolation placeholders aligned with English',
|
||||
(_locale, values) => {
|
||||
const mismatches = [...englishValues].flatMap(
|
||||
([path, englishValue]) => {
|
||||
const translatedValue = values.get(path)
|
||||
return translatedValue !== undefined &&
|
||||
JSON.stringify(collectPlaceholders(translatedValue)) !==
|
||||
JSON.stringify(collectPlaceholders(englishValue))
|
||||
? [
|
||||
`${path}: ${collectPlaceholders(englishValue).join(', ')} != ${collectPlaceholders(translatedValue).join(', ')}`,
|
||||
]
|
||||
: []
|
||||
},
|
||||
)
|
||||
expect([...germanPaths].sort()).toEqual([...englishPaths].sort())
|
||||
})
|
||||
|
||||
expect(mismatches).toEqual([])
|
||||
},
|
||||
)
|
||||
it('keeps German interpolation placeholders aligned with English', () => {
|
||||
const mismatches = [...englishValues].flatMap(([path, englishValue]) => {
|
||||
const germanValue = germanValues.get(path)
|
||||
return germanValue !== undefined &&
|
||||
JSON.stringify(collectPlaceholders(germanValue)) !==
|
||||
JSON.stringify(collectPlaceholders(englishValue))
|
||||
? [
|
||||
`${path}: ${collectPlaceholders(englishValue).join(', ')} != ${collectPlaceholders(germanValue).join(', ')}`,
|
||||
]
|
||||
: []
|
||||
})
|
||||
|
||||
it.each(translatedLocaleValues)(
|
||||
'keeps %s numeric source values intact',
|
||||
(_locale, values) => {
|
||||
const mismatches = [...englishValues].flatMap(([path, englishValue]) => {
|
||||
const expected = collectNumberTokens(englishValue)
|
||||
if (!expected.length) return []
|
||||
|
||||
const remaining = collectNumberTokens(values.get(path) ?? '')
|
||||
for (const token of expected) {
|
||||
const index = remaining.indexOf(token)
|
||||
if (index >= 0) remaining.splice(index, 1)
|
||||
else return [`${path}: missing numeric token ${token}`]
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
expect(mismatches).toEqual([])
|
||||
},
|
||||
)
|
||||
expect(mismatches).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps standard app names German and custom game names unchanged', () => {
|
||||
const standardAppNames = {
|
||||
|
||||
+41
-672
@@ -8,11 +8,6 @@ import type {
|
||||
} from '@/types/device'
|
||||
import { clampPage } from '@/utils/pages'
|
||||
import { cloneJsonData } from '@/utils/clone'
|
||||
import {
|
||||
EMPTY_CUSTOM_PHONE_TONES,
|
||||
isCustomTonePreferenceId,
|
||||
parseCustomPhoneToneCatalog,
|
||||
} from '@/utils/customTones'
|
||||
import { nuiCall } from '@/utils/nui'
|
||||
import type { NuiResponse } from '@/utils/nui'
|
||||
import {
|
||||
@@ -38,13 +33,11 @@ export type PasscodeResponseData = {
|
||||
export type PhoneOpenPayload = {
|
||||
account?: DeviceBootstrap['account']
|
||||
device?: PhoneDevice
|
||||
disabledApps?: string[]
|
||||
fallbackLocales?: LocaleTree
|
||||
lang?: string
|
||||
locales?: LocaleTree
|
||||
memos?: DeviceBootstrap['memos']
|
||||
notes?: DeviceBootstrap['notes']
|
||||
phoneNumberFormat?: DeviceBootstrap['phoneNumberFormat']
|
||||
player?: DeviceBootstrap['player']
|
||||
security?: DeviceSecurity
|
||||
token?: string
|
||||
@@ -645,8 +638,7 @@ const cryptoFallbackLocales = {
|
||||
invalid_handle: 'Use 3–20 letters, numbers, dots or underscores.',
|
||||
handle_taken: 'That VaultX handle is already taken.',
|
||||
profile_exists: 'This character already owns a VaultX profile.',
|
||||
invalid_password:
|
||||
'Use 8–72 characters with uppercase, lowercase, a number, and a special character.',
|
||||
invalid_password: 'Password must be 8–72 characters.',
|
||||
password_mismatch: 'The passwords do not match.',
|
||||
accept_terms: 'Confirm that this is a fictional in-game wallet.',
|
||||
invalid_credentials: 'The password is incorrect.',
|
||||
@@ -813,433 +805,7 @@ const citywarnFallbackLocales = {
|
||||
},
|
||||
}
|
||||
|
||||
const adminPanelFallbackLocales = {
|
||||
name: 'Phone Admin',
|
||||
subtitle: 'Administration',
|
||||
navigation: 'Admin navigation',
|
||||
refresh: 'Refresh admin data',
|
||||
loading: 'Loading protected data...',
|
||||
tabs: {
|
||||
overview: 'Overview',
|
||||
players: 'Players',
|
||||
devices: 'Devices',
|
||||
apps: 'Apps',
|
||||
accounts: 'Accounts',
|
||||
messages: 'Messages',
|
||||
calls: 'Calls',
|
||||
moderation: 'Moderation',
|
||||
tones: 'Sounds',
|
||||
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',
|
||||
tonesFeature: 'Manage ringtones and notification sounds',
|
||||
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.',
|
||||
customTones: {
|
||||
context: 'Sound library',
|
||||
eyebrow: 'Audio management',
|
||||
library: 'Library',
|
||||
title: 'Custom ringtones and notification sounds',
|
||||
body: 'Manage local audio files from the database or config.lua without external URLs.',
|
||||
configTitle: 'File-based alternative',
|
||||
configBody:
|
||||
'If the FiveM client does not open a file dialog, place the file in the resource folder and register it in config.lua.',
|
||||
configSource: 'config.lua',
|
||||
configManaged:
|
||||
'This tone is managed through config.lua and can only be previewed here.',
|
||||
name: 'Display name',
|
||||
namePlaceholder: 'For example Dispatch',
|
||||
category: 'Use as',
|
||||
ringtone: 'Ringtone',
|
||||
notification: 'Notification sound',
|
||||
chooseFile: 'Choose audio file',
|
||||
fileHint: 'MP3, OGG, WAV, or WebM · up to 2 MB and 30 seconds',
|
||||
preview: 'Preview',
|
||||
add: 'Add tone',
|
||||
loading: 'Loading tone library...',
|
||||
ringtones: 'Ringtones',
|
||||
notifications: 'Notification sounds',
|
||||
empty: 'No custom tones in this category yet.',
|
||||
delete: 'Delete tone',
|
||||
confirmDelete: 'Click again to confirm',
|
||||
saved: 'The tone was saved and is immediately available on every phone.',
|
||||
deleted: 'The tone was deleted.',
|
||||
errors: {
|
||||
type: 'Choose an MP3, OGG, WAV, or WebM audio file.',
|
||||
size: 'The audio file may not exceed 2 MB.',
|
||||
duration: 'The tone must be between 0.25 and 30 seconds long.',
|
||||
invalid: 'The audio file could not be read.',
|
||||
playback: 'The tone could not be played.',
|
||||
},
|
||||
},
|
||||
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',
|
||||
addJob: 'Add job',
|
||||
remove: 'Remove',
|
||||
emptyList: 'No rows yet. Add the first row with plus.',
|
||||
emptyTable: 'No fields yet. Add the first key below.',
|
||||
keyPlaceholder: 'New key',
|
||||
jobPlaceholder: 'Job name',
|
||||
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',
|
||||
create_custom_tone: 'Custom tone added',
|
||||
delete_custom_tone: 'Custom tone deleted',
|
||||
},
|
||||
},
|
||||
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.',
|
||||
invalid_tone: 'Check the tone name, file type, file size, and duration.',
|
||||
tone_name_taken: 'A tone with this name already exists in this category.',
|
||||
tone_limit: 'This category already contains 32 custom tones.',
|
||||
tone_not_found: 'That tone no longer exists.',
|
||||
invalid_upload: 'The tone upload is incomplete or invalid.',
|
||||
operation_in_progress: 'Another tone upload is already in progress.',
|
||||
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,
|
||||
@@ -2520,7 +2086,7 @@ const defaultLocales: LocaleTree = {
|
||||
to: 'To:',
|
||||
connectPrivately: 'Connect privately',
|
||||
newChatBody:
|
||||
"Enter another person's Dark-ID or invitation code. You can share your own ID from your profile.",
|
||||
'Enter an exact Dark-ID or invitation code. Unknown identities require confirmation.',
|
||||
darkIdOrInvite: 'Dark-ID or invitation code',
|
||||
continue: 'Continue',
|
||||
contacts: 'DarkChat Contacts',
|
||||
@@ -2766,8 +2332,6 @@ const defaultLocales: LocaleTree = {
|
||||
recipient_not_found: 'That number is unavailable.',
|
||||
blocked: 'This contact has blocked calls and messages from your SIM.',
|
||||
messaging_unavailable: 'This company contact does not accept messages.',
|
||||
service_line_text_only:
|
||||
'Company service lines currently accept text messages only.',
|
||||
no_sim: 'This phone has no SIM card.',
|
||||
rate_limited: 'Too many messages. Try again in a minute.',
|
||||
request_failed: 'Messages are temporarily unavailable.',
|
||||
@@ -2868,190 +2432,46 @@ const defaultLocales: LocaleTree = {
|
||||
'neon-drop': 'Neon block-dropping puzzle',
|
||||
},
|
||||
previews: {
|
||||
citywarn: {
|
||||
first: 'Live alerts',
|
||||
second: 'Safety zones',
|
||||
third: 'Incident updates',
|
||||
},
|
||||
crypto: {
|
||||
first: 'Synthetic markets',
|
||||
second: 'Portfolio',
|
||||
third: 'Wallet transfers',
|
||||
},
|
||||
health: {
|
||||
first: 'Activity rings',
|
||||
second: 'Medical ID',
|
||||
third: 'Health records',
|
||||
},
|
||||
'weazel-news': {
|
||||
first: 'Top stories',
|
||||
second: 'Local reports',
|
||||
third: 'Breaking news',
|
||||
},
|
||||
companies: {
|
||||
first: 'Business directory',
|
||||
second: 'Job requests',
|
||||
third: 'Services',
|
||||
},
|
||||
music: {
|
||||
first: 'Now playing',
|
||||
second: 'Playlists',
|
||||
third: 'Music library',
|
||||
},
|
||||
picstagram: {
|
||||
first: 'Photo feed',
|
||||
second: 'Stories',
|
||||
third: 'Profiles',
|
||||
},
|
||||
feather: {
|
||||
first: 'Short posts',
|
||||
second: 'Following feed',
|
||||
third: 'Conversations',
|
||||
},
|
||||
fliptok: {
|
||||
first: 'Video feed',
|
||||
second: 'Creator tools',
|
||||
third: 'Trends',
|
||||
},
|
||||
flare: {
|
||||
first: 'Discover people',
|
||||
second: 'Matches',
|
||||
third: 'Live moments',
|
||||
},
|
||||
calendar: {
|
||||
first: 'Upcoming events',
|
||||
second: 'Day planner',
|
||||
third: 'Reminders',
|
||||
},
|
||||
radio: {
|
||||
first: 'Live channels',
|
||||
second: 'Team radio',
|
||||
third: 'Favorites',
|
||||
},
|
||||
'local-pages': {
|
||||
first: 'Local pages',
|
||||
second: 'Reviews',
|
||||
third: 'City discovery',
|
||||
},
|
||||
crewlink: {
|
||||
first: 'Crew roster',
|
||||
second: 'Shared locations',
|
||||
third: 'Coordination',
|
||||
},
|
||||
phone: {
|
||||
first: 'Recent calls',
|
||||
second: 'Contacts',
|
||||
third: 'Voicemail',
|
||||
},
|
||||
messages: {
|
||||
first: 'Conversations',
|
||||
second: 'Media sharing',
|
||||
third: 'Quick replies',
|
||||
},
|
||||
darkchat: {
|
||||
first: 'Private chats',
|
||||
second: 'Secure groups',
|
||||
third: 'Invitations',
|
||||
},
|
||||
garage: {
|
||||
first: 'Vehicle list',
|
||||
second: 'Parking locations',
|
||||
third: 'Valet',
|
||||
},
|
||||
house: {
|
||||
first: 'Property access',
|
||||
second: 'Residents',
|
||||
third: 'Management',
|
||||
},
|
||||
map: {
|
||||
first: 'Live navigation',
|
||||
second: 'Nearby places',
|
||||
third: 'Route guidance',
|
||||
},
|
||||
skyride: {
|
||||
first: 'Ride booking',
|
||||
second: 'Driver tracking',
|
||||
third: 'Trip history',
|
||||
},
|
||||
banking: {
|
||||
first: 'Account balance',
|
||||
second: 'Transfers',
|
||||
third: 'Transactions',
|
||||
},
|
||||
billing: {
|
||||
first: 'Open invoices',
|
||||
second: 'Payment requests',
|
||||
third: 'Payment history',
|
||||
},
|
||||
citywarn: { first: 'Live alerts', second: 'Safety zones', third: 'Incident updates' },
|
||||
crypto: { first: 'Synthetic markets', second: 'Portfolio', third: 'Wallet transfers' },
|
||||
health: { first: 'Activity rings', second: 'Medical ID', third: 'Health records' },
|
||||
'weazel-news': { first: 'Top stories', second: 'Local reports', third: 'Breaking news' },
|
||||
companies: { first: 'Business directory', second: 'Job requests', third: 'Services' },
|
||||
music: { first: 'Now playing', second: 'Playlists', third: 'Music library' },
|
||||
picstagram: { first: 'Photo feed', second: 'Stories', third: 'Profiles' },
|
||||
feather: { first: 'Short posts', second: 'Following feed', third: 'Conversations' },
|
||||
fliptok: { first: 'Video feed', second: 'Creator tools', third: 'Trends' },
|
||||
flare: { first: 'Discover people', second: 'Matches', third: 'Live moments' },
|
||||
calendar: { first: 'Upcoming events', second: 'Day planner', third: 'Reminders' },
|
||||
radio: { first: 'Live channels', second: 'Team radio', third: 'Favorites' },
|
||||
'local-pages': { first: 'Local pages', second: 'Reviews', third: 'City discovery' },
|
||||
crewlink: { first: 'Crew roster', second: 'Shared locations', third: 'Coordination' },
|
||||
phone: { first: 'Recent calls', second: 'Contacts', third: 'Voicemail' },
|
||||
messages: { first: 'Conversations', second: 'Media sharing', third: 'Quick replies' },
|
||||
darkchat: { first: 'Private chats', second: 'Secure groups', third: 'Invitations' },
|
||||
garage: { first: 'Vehicle list', second: 'Parking locations', third: 'Valet' },
|
||||
house: { first: 'Property access', second: 'Residents', third: 'Management' },
|
||||
map: { first: 'Live navigation', second: 'Nearby places', third: 'Route guidance' },
|
||||
skyride: { first: 'Ride booking', second: 'Driver tracking', third: 'Trip history' },
|
||||
banking: { first: 'Account balance', second: 'Transfers', third: 'Transactions' },
|
||||
billing: { first: 'Open invoices', second: 'Payment requests', third: 'Payment history' },
|
||||
mail: { first: 'Inbox', second: 'Attachments', third: 'Mailboxes' },
|
||||
notes: { first: 'Notes', second: 'Checklists', third: 'Pinned ideas' },
|
||||
memos: {
|
||||
first: 'Voice recordings',
|
||||
second: 'Playback',
|
||||
third: 'Favorites',
|
||||
},
|
||||
calculator: {
|
||||
first: 'Basic calculation',
|
||||
second: 'Scientific tools',
|
||||
third: 'History',
|
||||
},
|
||||
camera: {
|
||||
first: 'Photo mode',
|
||||
second: 'Video capture',
|
||||
third: 'Zoom controls',
|
||||
},
|
||||
memos: { first: 'Voice recordings', second: 'Playback', third: 'Favorites' },
|
||||
calculator: { first: 'Basic calculation', second: 'Scientific tools', third: 'History' },
|
||||
camera: { first: 'Photo mode', second: 'Video capture', third: 'Zoom controls' },
|
||||
clock: { first: 'World clock', second: 'Alarms', third: 'Timers' },
|
||||
weather: {
|
||||
first: 'Current weather',
|
||||
second: 'Hourly forecast',
|
||||
third: 'Seven-day outlook',
|
||||
},
|
||||
photos: {
|
||||
first: 'Media library',
|
||||
second: 'Albums',
|
||||
third: 'Shared media',
|
||||
},
|
||||
settings: {
|
||||
first: 'Device controls',
|
||||
second: 'Privacy',
|
||||
third: 'Personalization',
|
||||
},
|
||||
weather: { first: 'Current weather', second: 'Hourly forecast', third: 'Seven-day outlook' },
|
||||
photos: { first: 'Media library', second: 'Albums', third: 'Shared media' },
|
||||
settings: { first: 'Device controls', second: 'Privacy', third: 'Personalization' },
|
||||
snake: { first: 'High score', second: 'Speed', third: 'Classic grid' },
|
||||
memory: {
|
||||
first: 'Matched pairs',
|
||||
second: 'Best time',
|
||||
third: 'Card themes',
|
||||
},
|
||||
'number-merge': {
|
||||
first: 'Highest tile',
|
||||
second: 'Score',
|
||||
third: 'Strategy grid',
|
||||
},
|
||||
minesweeper: {
|
||||
first: 'Mine counter',
|
||||
second: 'Best time',
|
||||
third: 'Difficulty',
|
||||
},
|
||||
'tower-stack': {
|
||||
first: 'Tower height',
|
||||
second: 'Perfect drops',
|
||||
third: 'High score',
|
||||
},
|
||||
'sky-flappy': {
|
||||
first: 'Flight score',
|
||||
second: 'Best run',
|
||||
third: 'Obstacles',
|
||||
},
|
||||
citymarkt: {
|
||||
first: 'Listings',
|
||||
second: 'Categories',
|
||||
third: 'Saved offers',
|
||||
},
|
||||
'neon-drop': {
|
||||
first: 'Lines cleared',
|
||||
second: 'Level',
|
||||
third: 'Neon pieces',
|
||||
},
|
||||
memory: { first: 'Matched pairs', second: 'Best time', third: 'Card themes' },
|
||||
'number-merge': { first: 'Highest tile', second: 'Score', third: 'Strategy grid' },
|
||||
minesweeper: { first: 'Mine counter', second: 'Best time', third: 'Difficulty' },
|
||||
'tower-stack': { first: 'Tower height', second: 'Perfect drops', third: 'High score' },
|
||||
'sky-flappy': { first: 'Flight score', second: 'Best run', third: 'Obstacles' },
|
||||
citymarkt: { first: 'Listings', second: 'Categories', third: 'Saved offers' },
|
||||
'neon-drop': { first: 'Lines cleared', second: 'Level', third: 'Neon pieces' },
|
||||
},
|
||||
search: {
|
||||
recommended: 'Recommended',
|
||||
@@ -3081,7 +2501,6 @@ const defaultLocales: LocaleTree = {
|
||||
uninstallTitle: 'Uninstall this app?',
|
||||
uninstallBody:
|
||||
'{app} will be removed from this phone. You can download it again from the App Store.',
|
||||
uninstallFailed: 'The app could not be uninstalled. Please try again.',
|
||||
},
|
||||
details: {
|
||||
skyStudios: 'Sky Studios',
|
||||
@@ -3617,8 +3036,6 @@ const defaultLocales: LocaleTree = {
|
||||
viewRides: 'View Ride Options',
|
||||
change: 'Change',
|
||||
requestRide: 'Request SkyRide',
|
||||
playerDriverNotice:
|
||||
'SkyRide matches you with real player drivers. A driver must be online and accept your request.',
|
||||
serviceMeta: '{eta} min away · {seats} seats',
|
||||
distanceMeters: '{distance} m',
|
||||
distanceKilometers: '{distance} km',
|
||||
@@ -3689,8 +3106,7 @@ const defaultLocales: LocaleTree = {
|
||||
cancelled: 'Cancelled',
|
||||
},
|
||||
statusBody: {
|
||||
searching:
|
||||
'Your request is waiting for an available player driver to accept it.',
|
||||
searching: 'We are matching you with a nearby driver.',
|
||||
accepted: 'Your driver is preparing to pick you up.',
|
||||
driver_arriving: 'Your driver is on the way to your pickup.',
|
||||
arrived: 'Your driver is waiting at the pickup point.',
|
||||
@@ -4580,8 +3996,8 @@ const defaultLocales: LocaleTree = {
|
||||
video: 'Video',
|
||||
microphoneOn: 'Microphone on',
|
||||
microphoneOff: 'Microphone muted',
|
||||
focusHelp: 'Hold the look key to look around',
|
||||
returnHelp: 'Release the look key for controls',
|
||||
focusHelp: 'Space for movement',
|
||||
returnHelp: 'Space to return',
|
||||
uploading: '{count} uploading',
|
||||
saving: 'Saving video...',
|
||||
openGallery: 'Open Photos',
|
||||
@@ -4596,11 +4012,6 @@ const defaultLocales: LocaleTree = {
|
||||
invalid_media_type: 'The uploaded media type is invalid.',
|
||||
invalid_upload: 'The upload could not be verified.',
|
||||
invalid_upload_token: 'The upload session is no longer valid.',
|
||||
media_provider_failed: 'The camera upload service is unavailable.',
|
||||
media_provider_rate_limited:
|
||||
'The camera upload service is busy. Try again shortly.',
|
||||
media_provider_unauthorized:
|
||||
'The configured FiveManage API key was rejected.',
|
||||
missing_config: 'Camera uploads are not configured.',
|
||||
microphone_unavailable:
|
||||
'Allow microphone access or mute the microphone before recording.',
|
||||
@@ -5738,8 +5149,6 @@ export const usePhoneStore = defineStore('phone', {
|
||||
state: () => ({
|
||||
cameraLandscape: false,
|
||||
currentPage: 1,
|
||||
customTones: cloneJsonData(EMPTY_CUSTOM_PHONE_TONES),
|
||||
customTonesLoaded: false,
|
||||
device: null as PhoneDevice | null,
|
||||
deviceRevisions: {} as Record<string, number>,
|
||||
deviceSessionToken: null as string | null,
|
||||
@@ -5774,45 +5183,6 @@ export const usePhoneStore = defineStore('phone', {
|
||||
this.cameraLandscape = false
|
||||
this.isOpen = false
|
||||
},
|
||||
setLocale(
|
||||
lang: string,
|
||||
locales: LocaleTree,
|
||||
fallbackLocales: LocaleTree,
|
||||
): void {
|
||||
this.lang = lang
|
||||
this.locales = locales
|
||||
this.fallbackLocales = fallbackLocales
|
||||
},
|
||||
setCustomTones(payload: unknown): void {
|
||||
this.customTones = parseCustomPhoneToneCatalog(payload)
|
||||
this.customTonesLoaded = true
|
||||
this.reconcileCustomTonePreferences()
|
||||
},
|
||||
reconcileCustomTonePreferences(): void {
|
||||
if (!this.customTonesLoaded) return
|
||||
let changed = false
|
||||
const ringtone = this.preferences.settings.ringtone
|
||||
if (
|
||||
isCustomTonePreferenceId(ringtone) &&
|
||||
!this.customTones.ringtones.some((tone) => tone.id === ringtone)
|
||||
) {
|
||||
this.preferences.settings.ringtone = 'skyline'
|
||||
changed = true
|
||||
}
|
||||
const notificationSound = this.preferences.settings.notificationSound
|
||||
if (
|
||||
isCustomTonePreferenceId(notificationSound) &&
|
||||
!this.customTones.notificationSounds.some(
|
||||
(tone) => tone.id === notificationSound,
|
||||
)
|
||||
) {
|
||||
this.preferences.settings.notificationSound = 'chime'
|
||||
changed = true
|
||||
}
|
||||
if (changed && this.device) {
|
||||
this.saveDeviceNamespace('settings', this.preferences)
|
||||
}
|
||||
},
|
||||
open(payload: PhoneOpenPayload = {}): void {
|
||||
const nextImei = payload.device?.imei ?? this.device?.imei ?? null
|
||||
const nextToken = payload.token ?? this.deviceSessionToken
|
||||
@@ -5827,7 +5197,6 @@ export const usePhoneStore = defineStore('phone', {
|
||||
this.fallbackLocales = payload.fallbackLocales ?? defaultLocales
|
||||
this.locales = payload.locales ?? this.fallbackLocales
|
||||
if (payload.device) this.hydrateDevice(payload.device)
|
||||
this.reconcileCustomTonePreferences()
|
||||
if (payload.player) this.player = payload.player
|
||||
this.security = payload.security ?? {
|
||||
enabled: false,
|
||||
|
||||
@@ -16,15 +16,10 @@ describe('test data seeding contracts', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('refreshes the test-data command when its runtime configuration changes', () => {
|
||||
expect(testData).toContain('local function refresh_test_data_command()')
|
||||
expect(testData).toContain(
|
||||
'active_test_data_command = Config.TestData.Enabled and Config.TestData.Command or nil',
|
||||
)
|
||||
expect(testData).toContain(
|
||||
'AddEventHandler("sky_phone:configurator:serverUpdated", refresh_test_data_command)',
|
||||
)
|
||||
expect(testData).not.toContain('RegisterCommand(Config.TestData.Command')
|
||||
it('returns before registering the test-data command when disabled', () => {
|
||||
expect(
|
||||
testData.indexOf('if not Config.TestData.Enabled then'),
|
||||
).toBeLessThan(testData.indexOf('RegisterCommand(Config.TestData.Command'))
|
||||
})
|
||||
|
||||
it('moves an existing player SIM before attaching it to the selected phone', () => {
|
||||
|
||||
@@ -1,208 +0,0 @@
|
||||
export type AdminStats = {
|
||||
accounts: number
|
||||
activeDevices: number
|
||||
auditEntries: number
|
||||
auditToday: number
|
||||
callsToday: number
|
||||
devices: number
|
||||
linkedDevices: number
|
||||
messagesToday: number
|
||||
online: number
|
||||
simDevices: number
|
||||
}
|
||||
|
||||
export type AdminPlayerSummary = {
|
||||
deviceCount: number
|
||||
grade: number
|
||||
identifier: string
|
||||
job: string
|
||||
name: string
|
||||
onDuty: boolean
|
||||
phoneNumber: string | null
|
||||
serverName: string
|
||||
source: number
|
||||
}
|
||||
|
||||
export type AdminDevice = {
|
||||
account: {
|
||||
email: string
|
||||
id: number
|
||||
passwordAvailable: boolean
|
||||
} | null
|
||||
apps: {
|
||||
claimed: string[]
|
||||
revision: number
|
||||
uninstalled: string[]
|
||||
}
|
||||
createdAt: string
|
||||
imei: string
|
||||
name: string
|
||||
number: string | null
|
||||
security: {
|
||||
enabled: boolean
|
||||
failedAttempts: number
|
||||
length: number | null
|
||||
lockedUntil: number
|
||||
}
|
||||
simRegistered: boolean
|
||||
simType: string | null
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export type AdminPlayerDetail = {
|
||||
birthdate: string
|
||||
devices: AdminDevice[]
|
||||
firstName: string
|
||||
identifier: string
|
||||
job: {
|
||||
grade: number
|
||||
gradeLabel: string
|
||||
label: string
|
||||
name: string
|
||||
onDuty: boolean
|
||||
}
|
||||
lastName: string
|
||||
money: {
|
||||
bank: number
|
||||
cash: number
|
||||
currency: string
|
||||
}
|
||||
name: string
|
||||
serverName: string
|
||||
source: number
|
||||
}
|
||||
|
||||
export type AdminAuditEntry = {
|
||||
action: string
|
||||
actorName: string
|
||||
createdAt: string
|
||||
details: Record<string, unknown>
|
||||
deviceImei: string | null
|
||||
id: number
|
||||
targetIdentifier: string
|
||||
targetSource: number | null
|
||||
}
|
||||
|
||||
export type AdminBootstrap = {
|
||||
audit: AdminAuditEntry[]
|
||||
disabledApps: string[]
|
||||
players: AdminPlayerSummary[]
|
||||
stats: AdminStats
|
||||
}
|
||||
|
||||
export type AdminCredential = {
|
||||
email: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export type AdminMessageActivity = {
|
||||
body: string
|
||||
createdAt: string
|
||||
direction: 'incoming' | 'outgoing'
|
||||
id: string
|
||||
messageType: string
|
||||
otherNumber: string
|
||||
readAt: string | null
|
||||
}
|
||||
|
||||
export type AdminCallActivity = {
|
||||
answeredAt: string | null
|
||||
direction: 'incoming' | 'outgoing'
|
||||
durationSeconds: number
|
||||
endedAt: string | null
|
||||
id: string
|
||||
otherNumber: string
|
||||
startedAt: string
|
||||
status: string
|
||||
}
|
||||
|
||||
export type AdminActivityResponse =
|
||||
| { entries: AdminMessageActivity[]; kind: 'messages' }
|
||||
| { entries: AdminCallActivity[]; kind: 'calls' }
|
||||
|
||||
export type AdminConfiguratorField = {
|
||||
configured?: boolean
|
||||
label: string
|
||||
path: string
|
||||
structure?: AdminConfiguratorStructure
|
||||
scope: 'config' | 'media'
|
||||
sensitive: boolean
|
||||
type: 'boolean' | 'json' | 'number' | 'string' | 'stringOrFalse'
|
||||
value: unknown
|
||||
}
|
||||
|
||||
export type AdminConfiguratorStructure =
|
||||
| {
|
||||
kind: 'list'
|
||||
items: AdminConfiguratorStructure[]
|
||||
template?: AdminConfiguratorStructure
|
||||
}
|
||||
| {
|
||||
entryDefault?: unknown
|
||||
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
|
||||
}
|
||||
|
||||
export type AdminCustomTone = {
|
||||
byteSize: number
|
||||
createdAt: string
|
||||
createdBy: string
|
||||
durationMs: number
|
||||
id: string
|
||||
label: string
|
||||
mimeType: string
|
||||
source: 'config' | 'database'
|
||||
toneType: 'notification' | 'ringtone'
|
||||
}
|
||||
|
||||
export type AdminCustomToneCreate = {
|
||||
durationMs: number
|
||||
label: string
|
||||
mimeType: string
|
||||
payload: string
|
||||
toneType: 'notification' | 'ringtone'
|
||||
}
|
||||
@@ -68,7 +68,6 @@ export type AppLaunchOrigin = {
|
||||
}
|
||||
|
||||
type PhoneAppDefinitionBase = {
|
||||
adminOnly?: boolean
|
||||
category: PhoneAppCategory
|
||||
dockOrder: number | null
|
||||
gridOrder: number
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Note } from '@/utils/notes'
|
||||
import type { MemoDto } from '@/types/memos'
|
||||
import type { PhoneNumberFormat, PhoneSim } from '@/types/phone'
|
||||
import type { PhoneSim } from '@/types/phone'
|
||||
|
||||
export type DeviceDataEntry<T = unknown> = {
|
||||
payload: T
|
||||
@@ -50,7 +50,6 @@ export type DeviceBootstrap = {
|
||||
device: PhoneDevice
|
||||
memos: MemoDto[]
|
||||
notes: Note[]
|
||||
phoneNumberFormat: PhoneNumberFormat
|
||||
player: PhonePlayerIdentity
|
||||
security: DeviceSecurity
|
||||
token: string
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
export type DynamicIslandActivity =
|
||||
| 'call'
|
||||
| 'incoming-call'
|
||||
| 'music'
|
||||
| 'recording'
|
||||
| 'stopwatch'
|
||||
| 'timer'
|
||||
@@ -70,6 +70,7 @@ export type MediaImportResult = {
|
||||
}
|
||||
|
||||
export type UploadReady = {
|
||||
captureToken: string
|
||||
correlationId: string
|
||||
mediaType: MediaType
|
||||
photo?: {
|
||||
|
||||
@@ -39,6 +39,7 @@ export type MemoRecordingMetadata = {
|
||||
export type MemoUploadReady = {
|
||||
requestId: string
|
||||
correlationId: string
|
||||
captureToken: string
|
||||
presignedUrl: string
|
||||
uploadTimeoutMs?: number
|
||||
}
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
export type SimType = 'registered' | 'anonymous'
|
||||
|
||||
export type PhoneNumberFormat = {
|
||||
groups: number[]
|
||||
length: number
|
||||
}
|
||||
|
||||
export type PhoneSim = {
|
||||
id: string
|
||||
number: string
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const sourceDirectory = fileURLToPath(new URL('..', import.meta.url))
|
||||
const tokensSource = readFileSync(new URL('./tokens.css', import.meta.url), 'utf8')
|
||||
const mainCssSource = readFileSync(
|
||||
new URL('../assets/main.css', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
function styleSources(directory: string): Array<{
|
||||
file: string
|
||||
source: string
|
||||
}> {
|
||||
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||
const path = join(directory, entry.name)
|
||||
if (entry.isDirectory()) return styleSources(path)
|
||||
if (!/\.(?:css|vue)$/.test(entry.name)) return []
|
||||
return [{ file: path, source: readFileSync(path, 'utf8') }]
|
||||
})
|
||||
}
|
||||
|
||||
describe('Inter font contract', () => {
|
||||
it('bundles normal and italic variable fonts for every supported weight', () => {
|
||||
expect(
|
||||
existsSync(new URL('../assets/fonts/InterVariable.woff2', import.meta.url)),
|
||||
).toBe(true)
|
||||
expect(
|
||||
existsSync(
|
||||
new URL('../assets/fonts/InterVariable-Italic.woff2', import.meta.url),
|
||||
),
|
||||
).toBe(true)
|
||||
expect(tokensSource.match(/@font-face/g)).toHaveLength(2)
|
||||
expect(tokensSource.match(/font-weight:\s*100 900;/g)).toHaveLength(2)
|
||||
expect(tokensSource).toContain("--sky-font-family: 'Inter', Arial, sans-serif;")
|
||||
})
|
||||
|
||||
it('applies the shared font to the document and native form controls', () => {
|
||||
expect(mainCssSource).toMatch(
|
||||
/:root\s*\{[\s\S]*?font-family:\s*var\(--sky-font-family\);/,
|
||||
)
|
||||
expect(mainCssSource).toMatch(
|
||||
/button,\s*input,\s*textarea,\s*select\s*\{\s*font:\s*inherit;/,
|
||||
)
|
||||
})
|
||||
|
||||
it('does not bypass the shared token with a generic system UI stack', () => {
|
||||
const genericSystemStack =
|
||||
/font-family\s*:\s*(?:-apple-system|BlinkMacSystemFont|system-ui|ui-sans-serif|['"]Segoe UI['"])/
|
||||
const violations = styleSources(sourceDirectory)
|
||||
.filter(({ source }) => genericSystemStack.test(source))
|
||||
.map(({ file }) => file.slice(sourceDirectory.length + 1))
|
||||
|
||||
expect(violations).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -43,17 +43,6 @@ describe('SkyNavbar', () => {
|
||||
expect(html).toContain('Account')
|
||||
})
|
||||
|
||||
it('constrains long compact titles to the center column', () => {
|
||||
const titleRule = foundationStyles.match(
|
||||
/\.sky-navbar__title\s*\{([^}]*)\}/s,
|
||||
)?.[1]
|
||||
|
||||
expect(titleRule).toContain('max-width: 100%')
|
||||
expect(titleRule).toContain('overflow: hidden')
|
||||
expect(titleRule).toContain('text-overflow: ellipsis')
|
||||
expect(titleRule).toContain('white-space: nowrap')
|
||||
})
|
||||
|
||||
it('exposes the large-title header without changing heading semantics', async () => {
|
||||
const html = await renderToString(
|
||||
createSSRApp(SkyNavbar, {
|
||||
|
||||
@@ -1265,9 +1265,8 @@ label.sky-list-item__row {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.sky-field__select option {
|
||||
background: var(--sky-native-select-option-background, #ffffff);
|
||||
color: var(--sky-native-select-option-text, #000000);
|
||||
.sky-field--floating-label .sky-field__select option {
|
||||
color: var(--sky-text, #000000);
|
||||
}
|
||||
|
||||
.sky-field--floating-label.sky-field--outline:not(
|
||||
|
||||
@@ -164,23 +164,6 @@ describe('SkyField', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps native select options readable on the CEF popup surface', () => {
|
||||
const controls = readFileSync(
|
||||
new URL('../controls.css', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const tokens = readFileSync(
|
||||
new URL('../tokens.css', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
expect(controls).toMatch(
|
||||
/\.sky-field__select option\s*\{[^}]*background:\s*var\(--sky-native-select-option-background, #ffffff\);[^}]*color:\s*var\(--sky-native-select-option-text, #000000\);/s,
|
||||
)
|
||||
expect(tokens).toContain('--sky-native-select-option-background: #ffffff;')
|
||||
expect(tokens).toContain('--sky-native-select-option-text: #000000;')
|
||||
})
|
||||
|
||||
it('raises floating labels only after the field has a value', async () => {
|
||||
const emptyApp = createSSRApp(SkyField, {
|
||||
floatingLabel: true,
|
||||
|
||||
Vendored
+3
-3
@@ -168,7 +168,6 @@
|
||||
|
||||
.sky-navbar__title {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
margin: 0;
|
||||
padding: 0 var(--sky-space-1);
|
||||
overflow: hidden;
|
||||
@@ -526,8 +525,9 @@
|
||||
height: var(--sky-widget-label-height);
|
||||
overflow: hidden;
|
||||
color: var(--sky-widget-label-color, #fff);
|
||||
font-family: var(--sky-font-family);
|
||||
font-size: var(--sky-home-label-font-size);
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', sans-serif;
|
||||
font-size: 11.5px;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.15px;
|
||||
line-height: var(--sky-widget-label-height);
|
||||
|
||||
@@ -1,19 +1,3 @@
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url('../assets/fonts/InterVariable.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: italic;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url('../assets/fonts/InterVariable-Italic.woff2') format('woff2');
|
||||
}
|
||||
|
||||
:root {
|
||||
--sky-device-pixel-ratio: 1;
|
||||
--sky-hairline-scale: 1;
|
||||
@@ -42,7 +26,9 @@
|
||||
--sky-font-title: 17px;
|
||||
--sky-font-medium-title: 24px;
|
||||
--sky-font-large-title: 34px;
|
||||
--sky-font-family: 'Inter', Arial, sans-serif;
|
||||
--sky-font-family:
|
||||
-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'SF UI Text',
|
||||
'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
--sky-transition-fast: 100ms;
|
||||
--sky-transition-normal: 200ms;
|
||||
--sky-ease-out: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
@@ -69,10 +55,8 @@
|
||||
--sky-shadow-thumb:
|
||||
0 0.5px 4px rgba(0, 0, 0, 0.12), 0 6px 13px rgba(0, 0, 0, 0.12);
|
||||
--sky-glass-highlight-color: rgba(255, 255, 255, 0.5);
|
||||
--sky-home-label-font-size: 13px;
|
||||
--sky-home-label-height: 16px;
|
||||
--sky-widget-label-gap: 5px;
|
||||
--sky-widget-label-height: var(--sky-home-label-height);
|
||||
--sky-widget-label-height: 15px;
|
||||
--sky-widget-radius-small: 23px;
|
||||
--sky-widget-radius-medium: 25px;
|
||||
--sky-widget-radius-large: 28px;
|
||||
@@ -188,8 +172,6 @@
|
||||
--sky-hairline: rgba(0, 0, 0, 0.2);
|
||||
--sky-field-outline: rgba(0, 0, 0, 0.3);
|
||||
--sky-field-placeholder: rgba(0, 0, 0, 0.3);
|
||||
--sky-native-select-option-background: #ffffff;
|
||||
--sky-native-select-option-text: #000000;
|
||||
--sky-pressed: rgba(0, 0, 0, 0.1);
|
||||
--sky-tabbar-highlight-background: rgba(0, 0, 0, 0.1);
|
||||
--sky-tabbar-thumb-background: rgba(0, 0, 0, 0.05);
|
||||
|
||||
@@ -1,178 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { AdminConfiguratorStructure } from '@/types/admin'
|
||||
import {
|
||||
blankFromConfiguratorStructure,
|
||||
createMutableTableEntry,
|
||||
} from '@/utils/adminConfiguratorDefaults'
|
||||
|
||||
describe('admin configurator defaults', () => {
|
||||
it('creates a usable company draft from its key and the server defaults', () => {
|
||||
const entryDefault = {
|
||||
AcceptsRequests: true,
|
||||
Category: 'public_services',
|
||||
Job: '',
|
||||
LogoUrl: 'https://picsum.photos/seed/companies-new-logo/180/180',
|
||||
Name: '',
|
||||
ServiceLine: {
|
||||
Number: '500',
|
||||
Routing: 'round_robin',
|
||||
},
|
||||
Services: [
|
||||
{
|
||||
Description: '',
|
||||
Id: '',
|
||||
Price: '',
|
||||
RequestsEnabled: true,
|
||||
Title: '',
|
||||
},
|
||||
],
|
||||
}
|
||||
const structure: AdminConfiguratorStructure = {
|
||||
entryDefault,
|
||||
fields: {},
|
||||
kind: 'table',
|
||||
mutableKeys: true,
|
||||
template: {
|
||||
fields: {
|
||||
Job: { kind: 'value', valueType: 'string' },
|
||||
Name: { kind: 'value', valueType: 'string' },
|
||||
},
|
||||
kind: 'table',
|
||||
},
|
||||
}
|
||||
|
||||
expect(
|
||||
createMutableTableEntry(
|
||||
structure,
|
||||
'Companies.Definitions',
|
||||
'pizza_palace',
|
||||
),
|
||||
).toEqual({
|
||||
AcceptsRequests: true,
|
||||
Category: 'public_services',
|
||||
Job: 'pizza_palace',
|
||||
LogoUrl: 'https://picsum.photos/seed/companies-pizza_palace-logo/180/180',
|
||||
Name: 'Pizza Palace',
|
||||
ServiceLine: {
|
||||
Number: '500',
|
||||
Routing: 'round_robin',
|
||||
},
|
||||
Services: [
|
||||
{
|
||||
Description: '',
|
||||
Id: 'pizza_palace',
|
||||
Price: '',
|
||||
RequestsEnabled: true,
|
||||
Title: 'Pizza Palace',
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(entryDefault).toMatchObject({
|
||||
Job: '',
|
||||
Name: '',
|
||||
Services: [{ Id: '', Title: '' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('allocates distinct service numbers for multiple unsaved companies', () => {
|
||||
const structure: AdminConfiguratorStructure = {
|
||||
entryDefault: {
|
||||
Job: '',
|
||||
LogoUrl: '',
|
||||
Name: '',
|
||||
ServiceLine: { Number: '500' },
|
||||
},
|
||||
fields: {},
|
||||
kind: 'table',
|
||||
mutableKeys: true,
|
||||
}
|
||||
const existing = {
|
||||
pizzeria: { ServiceLine: { Number: '500' } },
|
||||
restaurant: { ServiceLine: { Number: '501' } },
|
||||
}
|
||||
|
||||
expect(
|
||||
createMutableTableEntry(
|
||||
structure,
|
||||
'Companies.Definitions',
|
||||
'bakery',
|
||||
existing,
|
||||
),
|
||||
).toMatchObject({ ServiceLine: { Number: '502' } })
|
||||
})
|
||||
|
||||
it('keeps generic mutable tables on their schema-derived blank value', () => {
|
||||
const structure: AdminConfiguratorStructure = {
|
||||
fields: {},
|
||||
kind: 'table',
|
||||
mutableKeys: true,
|
||||
template: { kind: 'value', valueType: 'boolean' },
|
||||
}
|
||||
|
||||
expect(createMutableTableEntry(structure, 'FeatureFlags', 'example')).toBe(
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the configured access value when adding radio jobs', () => {
|
||||
const channelJobs: AdminConfiguratorStructure = {
|
||||
entryDefault: true,
|
||||
fields: {},
|
||||
kind: 'table',
|
||||
mutableKeys: true,
|
||||
template: { kind: 'value', valueType: 'boolean' },
|
||||
}
|
||||
const displayNameJobs: AdminConfiguratorStructure = {
|
||||
entryDefault: 0,
|
||||
fields: {},
|
||||
kind: 'table',
|
||||
mutableKeys: true,
|
||||
template: { kind: 'value', valueType: 'number' },
|
||||
}
|
||||
|
||||
expect(
|
||||
createMutableTableEntry(
|
||||
channelJobs,
|
||||
'Radio.LockedChannels[1].jobs',
|
||||
'mechanic',
|
||||
),
|
||||
).toBe(true)
|
||||
expect(
|
||||
createMutableTableEntry(
|
||||
displayNameJobs,
|
||||
'Radio.DisplayName.AllowedJobs',
|
||||
'mechanic',
|
||||
),
|
||||
).toBe(0)
|
||||
})
|
||||
|
||||
it('preserves required nested list fields in schema-derived rows', () => {
|
||||
const structure: AdminConfiguratorStructure = {
|
||||
fields: {
|
||||
jobs: {
|
||||
fields: {
|
||||
police: { kind: 'value', valueType: 'boolean' },
|
||||
},
|
||||
kind: 'table',
|
||||
mutableKeys: true,
|
||||
template: { kind: 'value', valueType: 'boolean' },
|
||||
},
|
||||
range: {
|
||||
items: [
|
||||
{ kind: 'value', valueType: 'number' },
|
||||
{ kind: 'value', valueType: 'number' },
|
||||
],
|
||||
kind: 'list',
|
||||
template: { kind: 'value', valueType: 'number' },
|
||||
},
|
||||
},
|
||||
kind: 'table',
|
||||
}
|
||||
|
||||
expect(blankFromConfiguratorStructure(structure)).toEqual({
|
||||
jobs: { police: false },
|
||||
range: [0, 0],
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,142 +0,0 @@
|
||||
import type { AdminConfiguratorStructure } from '@/types/admin'
|
||||
|
||||
function cloneConfiguratorValue(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(cloneConfiguratorValue)
|
||||
if (value !== null && typeof value === 'object') {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, child]) => [
|
||||
key,
|
||||
cloneConfiguratorValue(child),
|
||||
]),
|
||||
)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function blankScalar(valueType: 'boolean' | 'number' | 'string'): unknown {
|
||||
if (valueType === 'boolean') return false
|
||||
if (valueType === 'number') return 0
|
||||
return ''
|
||||
}
|
||||
|
||||
export function blankFromConfiguratorStructure(
|
||||
structure: AdminConfiguratorStructure,
|
||||
): unknown {
|
||||
if (structure.kind === 'optionalString') return ''
|
||||
if (structure.kind === 'value') return blankScalar(structure.valueType)
|
||||
if (structure.kind === 'vector') {
|
||||
const axes = ['x', 'y', 'z', 'w'].slice(
|
||||
0,
|
||||
Number(structure.vectorType.slice(-1)),
|
||||
)
|
||||
return Object.fromEntries([
|
||||
['__skyType', structure.vectorType],
|
||||
...axes.map((axis) => [axis, 0]),
|
||||
])
|
||||
}
|
||||
if (structure.kind === 'list') {
|
||||
return structure.items.map(blankFromConfiguratorStructure)
|
||||
}
|
||||
if (structure.kind === 'map') {
|
||||
return {
|
||||
__skyType: 'map',
|
||||
entries: structure.entries.map((entry) => ({
|
||||
key: entry.key,
|
||||
keyType: entry.keyType,
|
||||
value: blankFromConfiguratorStructure(entry.structure),
|
||||
})),
|
||||
}
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(structure.fields).map(([key, field]) => [
|
||||
key,
|
||||
blankFromConfiguratorStructure(field),
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
export function createMutableTableEntry(
|
||||
structure: Extract<AdminConfiguratorStructure, { kind: 'table' }>,
|
||||
path: string,
|
||||
key: string,
|
||||
entries: Record<string, unknown> = {},
|
||||
): unknown {
|
||||
const value =
|
||||
structure.entryDefault !== undefined
|
||||
? cloneConfiguratorValue(structure.entryDefault)
|
||||
: structure.template
|
||||
? blankFromConfiguratorStructure(structure.template)
|
||||
: undefined
|
||||
|
||||
if (
|
||||
path !== 'Companies.Definitions' ||
|
||||
value === null ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value)
|
||||
) {
|
||||
return value
|
||||
}
|
||||
|
||||
const company = value as Record<string, unknown>
|
||||
company.Job = key
|
||||
company.Name = key
|
||||
.replace(/[_-]+/g, ' ')
|
||||
.replace(/\b\w/g, (character) => character.toUpperCase())
|
||||
company.LogoUrl = `https://picsum.photos/seed/companies-${key}-logo/180/180`
|
||||
if (Array.isArray(company.Services)) {
|
||||
if (company.Services.length === 0) {
|
||||
company.Services.push({
|
||||
Description: '',
|
||||
Id: key,
|
||||
Price: '',
|
||||
RequestsEnabled: true,
|
||||
Title: company.Name,
|
||||
})
|
||||
} else {
|
||||
const service = company.Services[0]
|
||||
if (
|
||||
service !== null &&
|
||||
typeof service === 'object' &&
|
||||
!Array.isArray(service) &&
|
||||
!(service as Record<string, unknown>).Id
|
||||
) {
|
||||
const mutableService = service as Record<string, unknown>
|
||||
mutableService.Id = key
|
||||
mutableService.Title = company.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
const serviceLine = company.ServiceLine
|
||||
if (
|
||||
serviceLine !== null &&
|
||||
typeof serviceLine === 'object' &&
|
||||
!Array.isArray(serviceLine)
|
||||
) {
|
||||
const mutableServiceLine = serviceLine as Record<string, unknown>
|
||||
const number = String(mutableServiceLine.Number ?? '')
|
||||
const numeric = Number(number)
|
||||
if (/^\d+$/.test(number) && Number.isSafeInteger(numeric) && numeric > 0) {
|
||||
const used = new Set(
|
||||
Object.values(entries).map((entry) => {
|
||||
if (entry === null || typeof entry !== 'object') return ''
|
||||
const line = (entry as Record<string, unknown>).ServiceLine
|
||||
if (line === null || typeof line !== 'object') return ''
|
||||
return String((line as Record<string, unknown>).Number ?? '').replace(
|
||||
/\D/g,
|
||||
'',
|
||||
)
|
||||
}),
|
||||
)
|
||||
const maximum = 10 ** number.length - 1
|
||||
for (let offset = 0; offset < maximum; offset += 1) {
|
||||
const candidate = ((numeric - 1 + offset) % maximum) + 1
|
||||
const formatted = String(candidate).padStart(number.length, '0')
|
||||
if (!used.has(formatted)) {
|
||||
mutableServiceLine.Number = formatted
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return company
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { AdminConfiguratorStructure } from '@/types/admin'
|
||||
|
||||
import {
|
||||
configuratorDescriptionKey,
|
||||
configuratorPathName,
|
||||
describeConfiguratorValue,
|
||||
} from './adminConfiguratorDescription'
|
||||
|
||||
describe('admin configurator descriptions', () => {
|
||||
it('selects specific descriptions before generic value descriptions', () => {
|
||||
expect(configuratorDescriptionKey('Bridge.CallbackTimeout', 15000)).toBe(
|
||||
'milliseconds',
|
||||
)
|
||||
expect(configuratorDescriptionKey('Media.RequestTimeoutMs', 10000)).toBe(
|
||||
'milliseconds',
|
||||
)
|
||||
expect(configuratorDescriptionKey('Radio.AllowedJobs', [])).toBe('access')
|
||||
expect(configuratorDescriptionKey('FiveManage.ApiKey', '')).toBe(
|
||||
'credential',
|
||||
)
|
||||
})
|
||||
|
||||
it('describes structured values from their schema', () => {
|
||||
const vector: AdminConfiguratorStructure = {
|
||||
kind: 'vector',
|
||||
vectorType: 'vector3',
|
||||
}
|
||||
const table: AdminConfiguratorStructure = {
|
||||
fields: {},
|
||||
kind: 'table',
|
||||
}
|
||||
expect(configuratorDescriptionKey('Location', {}, vector)).toBe(
|
||||
'coordinates',
|
||||
)
|
||||
expect(configuratorDescriptionKey('Settings', {}, table)).toBe('table')
|
||||
})
|
||||
|
||||
it('passes a readable field name to the localized template', () => {
|
||||
const translate = (key: string, params?: Record<string, string>) =>
|
||||
`${key}:${params?.name}`
|
||||
expect(
|
||||
describeConfiguratorValue(
|
||||
translate,
|
||||
'CustomApps.MaximumStorageBytesPerApp',
|
||||
262144,
|
||||
),
|
||||
).toBe('configurator.descriptions.byteLimit:Maximum Storage Bytes Per App')
|
||||
expect(
|
||||
describeConfiguratorValue(translate, 'Radio.AllowedJobs[2]', 'police'),
|
||||
).toBe('configurator.descriptions.access:Allowed Jobs #2')
|
||||
})
|
||||
|
||||
it('humanizes subtab keys', () => {
|
||||
expect(configuratorPathName('ExternalPingResources')).toBe(
|
||||
'External Ping Resources',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,103 +0,0 @@
|
||||
import type { AdminConfiguratorStructure } from '@/types/admin'
|
||||
|
||||
type ConfiguratorDescriptionTranslator = (
|
||||
key: string,
|
||||
params?: Record<string, string>,
|
||||
) => string
|
||||
|
||||
export type AdminConfiguratorDescribe = (
|
||||
path: string,
|
||||
value: unknown,
|
||||
structure?: AdminConfiguratorStructure,
|
||||
label?: string,
|
||||
) => string
|
||||
|
||||
const DESCRIPTION_RULES: Array<[RegExp, string]> = [
|
||||
[/(?:^|\.)(?:apikey|token|password|secret)$/i, 'credential'],
|
||||
[/(?:base|manifest|image|icon)?url$/i, 'url'],
|
||||
[/(?:allowed)?(?:gif|media)?hosts?$/i, 'hosts'],
|
||||
[
|
||||
/(?:timeout|timeoutms|milliseconds|durationms|intervalms|pollms)$/i,
|
||||
'milliseconds',
|
||||
],
|
||||
[/(?:timeoutseconds|seconds)$/i, 'seconds'],
|
||||
[/perminute$/i, 'rateLimit'],
|
||||
[/(?:maximum|max).*bytes/i, 'byteLimit'],
|
||||
[/(?:maximum|max).*length$|length$/i, 'textLimit'],
|
||||
[/(?:distance)$/i, 'distance'],
|
||||
[/(?:location|position|rotation|coords|coordinates)$/i, 'coordinates'],
|
||||
[/(?:model|prop|propmodel|customprop|replacementprop)$/i, 'gameAsset'],
|
||||
[/(?:dictionary|dictionaries|clip|clips|pedclip|propclip)$/i, 'animation'],
|
||||
[
|
||||
/(?:permissions?|admingroups?|allowedjobs?|jobs?|minimumgrade|requiredace)$/i,
|
||||
'access',
|
||||
],
|
||||
[/(?:framework|inventory|provider|voiceprovider|adapter)$/i, 'integration'],
|
||||
[/(?:path)$/i, 'path'],
|
||||
[/(?:color|colour|accent)$/i, 'color'],
|
||||
[
|
||||
/(?:label|name|title|description|address|district|locationlabel|devicename)$/i,
|
||||
'displayText',
|
||||
],
|
||||
[/(?:number|callernumber|numberprefix)$/i, 'phoneNumber'],
|
||||
[/(?:routing)$/i, 'routing'],
|
||||
[/(?:command)$/i, 'command'],
|
||||
[/(?:locale)$/i, 'locale'],
|
||||
[/(?:debug)$/i, 'debug'],
|
||||
[/(?:enabled|active|public|verified)$/i, 'featureToggle'],
|
||||
[/(?:quality|bitratekbps|volume)$/i, 'mediaQuality'],
|
||||
[
|
||||
/(?:pagesize|batchsize|limit|count|maxselection|maximumplayers|samples|decimals)$/i,
|
||||
'amount',
|
||||
],
|
||||
]
|
||||
|
||||
export function configuratorPathName(path: string): string {
|
||||
const listEntry = path.match(/^(.*)\[(\d+)\]$/)
|
||||
const source = listEntry?.[1] ?? path
|
||||
const segment = source.split('.').at(-1) ?? source
|
||||
const name = segment
|
||||
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
|
||||
.replace(/([a-z\d])([A-Z])/g, '$1 $2')
|
||||
.replace(/[_-]+/g, ' ')
|
||||
.trim()
|
||||
return listEntry ? `${name} #${listEntry[2]}` : name
|
||||
}
|
||||
|
||||
export function configuratorDescriptionKey(
|
||||
path: string,
|
||||
value: unknown,
|
||||
structure?: AdminConfiguratorStructure,
|
||||
): string {
|
||||
const segment =
|
||||
path
|
||||
.replace(/\[\d+\]$/, '')
|
||||
.split('.')
|
||||
.at(-1) ?? path
|
||||
const semanticRule = DESCRIPTION_RULES.find(([pattern]) =>
|
||||
pattern.test(segment),
|
||||
)
|
||||
if (semanticRule) return semanticRule[1]
|
||||
|
||||
if (structure?.kind === 'vector') return 'coordinates'
|
||||
if (structure?.kind === 'list' || Array.isArray(value)) return 'list'
|
||||
if (structure?.kind === 'map' || structure?.kind === 'table') return 'table'
|
||||
if (structure?.kind === 'optionalString') return 'optionalText'
|
||||
if (value !== null && typeof value === 'object') return 'table'
|
||||
if (typeof value === 'boolean') return 'boolean'
|
||||
if (typeof value === 'number') return 'number'
|
||||
return 'text'
|
||||
}
|
||||
|
||||
export function describeConfiguratorValue(
|
||||
translate: ConfiguratorDescriptionTranslator,
|
||||
path: string,
|
||||
value: unknown,
|
||||
structure?: AdminConfiguratorStructure,
|
||||
label?: string,
|
||||
): string {
|
||||
return translate(
|
||||
`configurator.descriptions.${configuratorDescriptionKey(path, value, structure)}`,
|
||||
{ name: label?.trim() || configuratorPathName(path) },
|
||||
)
|
||||
}
|
||||
@@ -13,10 +13,7 @@ const previewModules = import.meta.glob<string>(
|
||||
|
||||
const APP_STORE_PREVIEW_IMAGES = Object.fromEntries(
|
||||
Object.entries(previewModules).map(([path, imageUrl]) => {
|
||||
const appId = path
|
||||
.split('/')
|
||||
.at(-1)
|
||||
?.replace(/\.jpg$/, '')
|
||||
const appId = path.split('/').at(-1)?.replace(/\.jpg$/, '')
|
||||
if (!appId) throw new Error(`Invalid App Store preview path: ${path}`)
|
||||
return [appId, imageUrl]
|
||||
}),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { readdirSync, readFileSync } from 'node:fs'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
@@ -17,8 +17,7 @@ function escapeRegExp(value: string): string {
|
||||
describe('App Store preview catalog', () => {
|
||||
it('contains a real captured screenshot for every built-in store app', () => {
|
||||
const storeAppIds = PHONE_APPS.filter(
|
||||
(app) =>
|
||||
app.kind !== 'external' && app.id !== 'app-store' && !app.adminOnly,
|
||||
(app) => app.kind !== 'external' && app.id !== 'app-store',
|
||||
).map((app) => app.id)
|
||||
|
||||
expect([...APP_STORE_PREVIEW_IMAGE_IDS].sort()).toEqual(storeAppIds.sort())
|
||||
@@ -26,8 +25,7 @@ describe('App Store preview catalog', () => {
|
||||
|
||||
it('provides specialized preview data for every built-in store app', () => {
|
||||
const storeAppIds = PHONE_APPS.filter(
|
||||
(app) =>
|
||||
app.kind !== 'external' && app.id !== 'app-store' && !app.adminOnly,
|
||||
(app) => app.kind !== 'external' && app.id !== 'app-store',
|
||||
).map((app) => app.id)
|
||||
|
||||
expect([...PREVIEWABLE_BUILTIN_APP_IDS].sort()).toEqual(
|
||||
@@ -41,21 +39,24 @@ describe('App Store preview catalog', () => {
|
||||
expect(visualSignatures.size).toBe(storeAppIds.length)
|
||||
})
|
||||
|
||||
it('localizes every specialized preview in the fallback and every configured locale', () => {
|
||||
const localeDirectory = fileURLToPath(
|
||||
new URL('../../../sky_phone/config/locales/', import.meta.url),
|
||||
)
|
||||
it('localizes every specialized preview in the fallback, English and German locales', () => {
|
||||
const localeSources = [
|
||||
readFileSync(
|
||||
fileURLToPath(new URL('../stores/phone.ts', import.meta.url)),
|
||||
'utf8',
|
||||
),
|
||||
...readdirSync(localeDirectory)
|
||||
.filter((fileName) => fileName.endsWith('.lua'))
|
||||
.sort()
|
||||
.map((fileName) =>
|
||||
readFileSync(`${localeDirectory}/${fileName}`, 'utf8'),
|
||||
readFileSync(
|
||||
fileURLToPath(
|
||||
new URL('../../../sky_phone/config/locales/en.lua', import.meta.url),
|
||||
),
|
||||
'utf8',
|
||||
),
|
||||
readFileSync(
|
||||
fileURLToPath(
|
||||
new URL('../../../sky_phone/config/locales/de.lua', import.meta.url),
|
||||
),
|
||||
'utf8',
|
||||
),
|
||||
]
|
||||
|
||||
for (const appId of PREVIEWABLE_BUILTIN_APP_IDS) {
|
||||
|
||||
@@ -5,10 +5,10 @@ import type {
|
||||
SkyPhoneAppCapability,
|
||||
} from '@/types/apps'
|
||||
import type { NuiResponse } from '@/utils/nui'
|
||||
import type { BuiltInNotificationSoundId } from '@/utils/preferences'
|
||||
import type { NotificationSoundId } from '@/utils/preferences'
|
||||
|
||||
const STORAGE_KEY_PATTERN = /^[A-Za-z0-9._-]{1,64}$/
|
||||
const NOTIFICATION_SOUNDS: ReadonlySet<BuiltInNotificationSoundId> = new Set([
|
||||
const NOTIFICATION_SOUNDS: ReadonlySet<NotificationSoundId> = new Set([
|
||||
'chime',
|
||||
'signal',
|
||||
'soft',
|
||||
@@ -24,7 +24,7 @@ type JsonResult = { success: true; value: unknown } | { success: false }
|
||||
export type CustomAppBridgeNotification = {
|
||||
appId: string
|
||||
route: string
|
||||
sound?: BuiltInNotificationSoundId
|
||||
sound?: NotificationSoundId
|
||||
subtitle?: string
|
||||
text: string
|
||||
title: string
|
||||
@@ -210,8 +210,8 @@ function normalizeNotificationPayload(
|
||||
const sound =
|
||||
payload.sound === undefined ||
|
||||
(typeof payload.sound === 'string' &&
|
||||
NOTIFICATION_SOUNDS.has(payload.sound as BuiltInNotificationSoundId))
|
||||
? (payload.sound as BuiltInNotificationSoundId | undefined)
|
||||
NOTIFICATION_SOUNDS.has(payload.sound as NotificationSoundId))
|
||||
? (payload.sound as NotificationSoundId | undefined)
|
||||
: null
|
||||
if (sound === null) return null
|
||||
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { nuiCall } from '@/utils/nui'
|
||||
import { playPhoneMediaTone } from '@/utils/tones'
|
||||
|
||||
import {
|
||||
CUSTOM_TONE_CHUNK_CHARS,
|
||||
MAX_CUSTOM_TONE_PAYLOAD_CHARS,
|
||||
findCustomPhoneTone,
|
||||
isCustomTonePreferenceId,
|
||||
parseCustomPhoneToneCatalog,
|
||||
playCustomPhoneTone,
|
||||
} from './customTones'
|
||||
|
||||
vi.mock('@/utils/nui', () => ({ nuiCall: vi.fn() }))
|
||||
vi.mock('@/utils/tones', () => ({ playPhoneMediaTone: vi.fn() }))
|
||||
|
||||
const DISPATCH_ID = '1fbd07b4-d231-4a76-b661-c9e5d0ab19a8'
|
||||
const RETRO_ID = 'a1e8db72-bc56-41c8-84e2-521180e14db1'
|
||||
|
||||
function tone(
|
||||
id: string,
|
||||
label: string,
|
||||
toneType: 'notification' | 'ringtone',
|
||||
byteSize = 120_000,
|
||||
) {
|
||||
return {
|
||||
byteSize,
|
||||
createdAt: '2026-08-27 12:00:00',
|
||||
durationMs: 8_500,
|
||||
id,
|
||||
label,
|
||||
mimeType: 'audio/mpeg',
|
||||
toneType,
|
||||
}
|
||||
}
|
||||
|
||||
describe('custom phone tones', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('keeps transfer chunks below the reliable event ceiling and Base64-aligned', () => {
|
||||
expect(CUSTOM_TONE_CHUNK_CHARS).toBe(8_000)
|
||||
expect(CUSTOM_TONE_CHUNK_CHARS % 4).toBe(0)
|
||||
expect(
|
||||
Math.ceil(MAX_CUSTOM_TONE_PAYLOAD_CHARS / CUSTOM_TONE_CHUNK_CHARS),
|
||||
).toBe(334)
|
||||
})
|
||||
|
||||
it('accepts valid stored-audio catalog entries and creates namespaced preference ids', () => {
|
||||
const catalog = parseCustomPhoneToneCatalog({
|
||||
notificationSounds: [tone(DISPATCH_ID, 'Dispatch Alert', 'notification')],
|
||||
ringtones: [tone(RETRO_ID, 'Retro Ring', 'ringtone')],
|
||||
})
|
||||
|
||||
expect(catalog.ringtones[0]).toMatchObject({
|
||||
id: `custom:${RETRO_ID}`,
|
||||
label: 'Retro Ring',
|
||||
mimeType: 'audio/mpeg',
|
||||
})
|
||||
expect(
|
||||
findCustomPhoneTone(catalog.notificationSounds, `custom:${DISPATCH_ID}`)
|
||||
?.label,
|
||||
).toBe('Dispatch Alert')
|
||||
})
|
||||
|
||||
it('rejects malformed ids, mismatched categories, duplicates and blank labels', () => {
|
||||
const catalog = parseCustomPhoneToneCatalog({
|
||||
notificationSounds: [],
|
||||
ringtones: [
|
||||
tone('../escape', 'Escape', 'ringtone'),
|
||||
tone(DISPATCH_ID, 'Wrong category', 'notification'),
|
||||
tone(RETRO_ID, ' ', 'ringtone'),
|
||||
tone(DISPATCH_ID, 'Safe', 'ringtone'),
|
||||
tone(DISPATCH_ID, 'Duplicate', 'ringtone'),
|
||||
],
|
||||
})
|
||||
|
||||
expect(catalog.ringtones).toHaveLength(1)
|
||||
expect(catalog.ringtones[0]).toMatchObject({
|
||||
id: `custom:${DISPATCH_ID}`,
|
||||
label: 'Safe',
|
||||
})
|
||||
expect(isCustomTonePreferenceId(`custom:${DISPATCH_ID}`)).toBe(true)
|
||||
expect(isCustomTonePreferenceId('custom:../escape')).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts safe config-file tone ids in their matching category', () => {
|
||||
const catalog = parseCustomPhoneToneCatalog({
|
||||
notificationSounds: [],
|
||||
ringtones: [
|
||||
{
|
||||
...tone('config:ringtone:dispatch_call', 'Dispatch Call', 'ringtone'),
|
||||
source: 'config',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(catalog.ringtones[0]).toMatchObject({
|
||||
id: 'custom:config:ringtone:dispatch_call',
|
||||
source: 'config',
|
||||
})
|
||||
expect(
|
||||
isCustomTonePreferenceId('custom:config:ringtone:dispatch_call'),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts tones above the old 270 KB limit up to 2 MB', () => {
|
||||
const catalog = parseCustomPhoneToneCatalog({
|
||||
notificationSounds: [],
|
||||
ringtones: [
|
||||
tone(DISPATCH_ID, 'Large valid tone', 'ringtone', 1_500_000),
|
||||
tone(RETRO_ID, 'Too large', 'ringtone', 2_000_001),
|
||||
],
|
||||
})
|
||||
|
||||
expect(catalog.ringtones).toHaveLength(1)
|
||||
expect(catalog.ringtones[0]?.byteSize).toBe(1_500_000)
|
||||
})
|
||||
|
||||
it('loads preview audio in one NUI request before starting playback', async () => {
|
||||
const customTone = parseCustomPhoneToneCatalog({
|
||||
notificationSounds: [],
|
||||
ringtones: [tone(DISPATCH_ID, 'Fast preview', 'ringtone')],
|
||||
}).ringtones[0]!
|
||||
const onStarted = vi.fn()
|
||||
const stopPlayback = vi.fn()
|
||||
vi.mocked(nuiCall).mockResolvedValue({
|
||||
data: {
|
||||
id: DISPATCH_ID,
|
||||
mimeType: 'audio/ogg',
|
||||
payload: 'T2dnUw==',
|
||||
},
|
||||
success: true,
|
||||
})
|
||||
vi.mocked(playPhoneMediaTone).mockImplementation(
|
||||
(_url, _volume, _loop, callbacks) => {
|
||||
callbacks?.onStarted?.()
|
||||
return stopPlayback
|
||||
},
|
||||
)
|
||||
|
||||
const stop = playCustomPhoneTone(customTone, 70, false, { onStarted })
|
||||
|
||||
await vi.waitFor(() => expect(onStarted).toHaveBeenCalledOnce())
|
||||
expect(nuiCall).toHaveBeenCalledOnce()
|
||||
expect(nuiCall).toHaveBeenCalledWith('tones:audio', { id: DISPATCH_ID })
|
||||
expect(playPhoneMediaTone).toHaveBeenCalledWith(
|
||||
'data:audio/ogg;base64,T2dnUw==',
|
||||
70,
|
||||
false,
|
||||
{ onStarted },
|
||||
)
|
||||
stop()
|
||||
expect(stopPlayback).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -1,240 +0,0 @@
|
||||
import { nuiCall } from '@/utils/nui'
|
||||
import { playPhoneMediaTone } from '@/utils/tones'
|
||||
|
||||
export type CustomTonePreferenceId = `custom:${string}`
|
||||
export type CustomToneType = 'notification' | 'ringtone'
|
||||
|
||||
export type CustomPhoneTone = {
|
||||
byteSize: number
|
||||
createdAt: string
|
||||
durationMs: number
|
||||
id: CustomTonePreferenceId
|
||||
label: string
|
||||
mimeType: string
|
||||
source: 'config' | 'database'
|
||||
toneType: CustomToneType
|
||||
}
|
||||
|
||||
export type CustomPhoneToneCatalog = {
|
||||
notificationSounds: CustomPhoneTone[]
|
||||
ringtones: CustomPhoneTone[]
|
||||
}
|
||||
|
||||
type CustomToneAudio = {
|
||||
id: string
|
||||
mimeType: string
|
||||
payload: string
|
||||
}
|
||||
|
||||
export const EMPTY_CUSTOM_PHONE_TONES: CustomPhoneToneCatalog = {
|
||||
notificationSounds: [],
|
||||
ringtones: [],
|
||||
}
|
||||
|
||||
export const MAX_CUSTOM_TONE_BYTES = 2_000_000
|
||||
export const MAX_CUSTOM_TONE_PAYLOAD_CHARS = 2_666_668
|
||||
export const CUSTOM_TONE_CHUNK_CHARS = 8_000
|
||||
|
||||
const CUSTOM_TONE_ID_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
const CONFIG_TONE_ID_PATTERN =
|
||||
/^config:(ringtone|notification):[a-z0-9][a-z0-9_-]{0,47}$/
|
||||
const MAX_CUSTOM_TONES_PER_TYPE = 32
|
||||
const MAX_CACHED_TONES = 2
|
||||
const ALLOWED_MIME_TYPES = new Set([
|
||||
'audio/mpeg',
|
||||
'audio/ogg',
|
||||
'audio/wav',
|
||||
'audio/webm',
|
||||
])
|
||||
const cachedSources = new Map<string, string>()
|
||||
|
||||
export function isCustomTonePreferenceId(
|
||||
value: unknown,
|
||||
): value is CustomTonePreferenceId {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
value.startsWith('custom:') &&
|
||||
(CUSTOM_TONE_ID_PATTERN.test(value.slice('custom:'.length)) ||
|
||||
CONFIG_TONE_ID_PATTERN.test(value.slice('custom:'.length)))
|
||||
)
|
||||
}
|
||||
|
||||
function parseToneList(
|
||||
value: unknown,
|
||||
expectedType: CustomToneType,
|
||||
): CustomPhoneTone[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
|
||||
const tones: CustomPhoneTone[] = []
|
||||
const acceptedIds = new Set<string>()
|
||||
for (const entry of value.slice(0, MAX_CUSTOM_TONES_PER_TYPE)) {
|
||||
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue
|
||||
const candidate = entry as Record<string, unknown>
|
||||
const source = candidate.source === 'config' ? 'config' : 'database'
|
||||
const validId =
|
||||
typeof candidate.id === 'string' &&
|
||||
(source === 'config'
|
||||
? CONFIG_TONE_ID_PATTERN.test(candidate.id) &&
|
||||
candidate.id.startsWith(`config:${expectedType}:`)
|
||||
: CUSTOM_TONE_ID_PATTERN.test(candidate.id))
|
||||
if (
|
||||
!validId ||
|
||||
typeof candidate.id !== 'string' ||
|
||||
acceptedIds.has(candidate.id) ||
|
||||
typeof candidate.label !== 'string' ||
|
||||
candidate.label.length < 1 ||
|
||||
candidate.label.length > 64 ||
|
||||
candidate.label.trim().length < 1 ||
|
||||
candidate.toneType !== expectedType ||
|
||||
typeof candidate.mimeType !== 'string' ||
|
||||
!ALLOWED_MIME_TYPES.has(candidate.mimeType) ||
|
||||
typeof candidate.durationMs !== 'number' ||
|
||||
!Number.isInteger(candidate.durationMs) ||
|
||||
candidate.durationMs < 250 ||
|
||||
candidate.durationMs > 30_000 ||
|
||||
typeof candidate.byteSize !== 'number' ||
|
||||
!Number.isInteger(candidate.byteSize) ||
|
||||
candidate.byteSize < 1 ||
|
||||
candidate.byteSize > MAX_CUSTOM_TONE_BYTES ||
|
||||
typeof candidate.createdAt !== 'string'
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
acceptedIds.add(candidate.id)
|
||||
tones.push({
|
||||
byteSize: candidate.byteSize,
|
||||
createdAt: candidate.createdAt,
|
||||
durationMs: candidate.durationMs,
|
||||
id: `custom:${candidate.id}`,
|
||||
label: candidate.label,
|
||||
mimeType: candidate.mimeType,
|
||||
source,
|
||||
toneType: expectedType,
|
||||
})
|
||||
}
|
||||
return tones
|
||||
}
|
||||
|
||||
export function parseCustomPhoneToneCatalog(
|
||||
value: unknown,
|
||||
): CustomPhoneToneCatalog {
|
||||
const source =
|
||||
value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {}
|
||||
return {
|
||||
notificationSounds: parseToneList(
|
||||
source.notificationSounds,
|
||||
'notification',
|
||||
),
|
||||
ringtones: parseToneList(source.ringtones, 'ringtone'),
|
||||
}
|
||||
}
|
||||
|
||||
export function findCustomPhoneTone(
|
||||
tones: CustomPhoneTone[],
|
||||
id: string,
|
||||
): CustomPhoneTone | undefined {
|
||||
return isCustomTonePreferenceId(id)
|
||||
? tones.find((tone) => tone.id === id)
|
||||
: undefined
|
||||
}
|
||||
|
||||
function cacheSource(id: string, source: string): void {
|
||||
cachedSources.delete(id)
|
||||
cachedSources.set(id, source)
|
||||
while (cachedSources.size > MAX_CACHED_TONES) {
|
||||
const oldest = cachedSources.keys().next().value
|
||||
if (typeof oldest !== 'string') break
|
||||
cachedSources.delete(oldest)
|
||||
}
|
||||
}
|
||||
|
||||
export function cacheCustomPhoneTonePayload(
|
||||
id: CustomTonePreferenceId,
|
||||
mimeType: string,
|
||||
payload: string,
|
||||
): boolean {
|
||||
if (
|
||||
!isCustomTonePreferenceId(id) ||
|
||||
!ALLOWED_MIME_TYPES.has(mimeType) ||
|
||||
payload.length < 1 ||
|
||||
payload.length > MAX_CUSTOM_TONE_PAYLOAD_CHARS ||
|
||||
payload.length % 4 !== 0 ||
|
||||
!/^[A-Za-z0-9+/]*={0,2}$/.test(payload)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
cacheSource(id.slice('custom:'.length), `data:${mimeType};base64,${payload}`)
|
||||
return true
|
||||
}
|
||||
|
||||
async function loadToneSource(tone: CustomPhoneTone): Promise<string> {
|
||||
const rawId = tone.id.slice('custom:'.length)
|
||||
const cached = cachedSources.get(rawId)
|
||||
if (cached) {
|
||||
cacheSource(rawId, cached)
|
||||
return cached
|
||||
}
|
||||
|
||||
const response = await nuiCall<CustomToneAudio>('tones:audio', {
|
||||
id: rawId,
|
||||
})
|
||||
const audio = response.data
|
||||
if (
|
||||
!response.success ||
|
||||
!audio ||
|
||||
audio.id !== rawId ||
|
||||
!ALLOWED_MIME_TYPES.has(audio.mimeType) ||
|
||||
typeof audio.payload !== 'string' ||
|
||||
audio.payload.length < 1 ||
|
||||
audio.payload.length > MAX_CUSTOM_TONE_PAYLOAD_CHARS ||
|
||||
audio.payload.length % 4 !== 0 ||
|
||||
!/^[A-Za-z0-9+/]*={0,2}$/.test(audio.payload)
|
||||
) {
|
||||
throw new Error(response.error ?? 'invalid_custom_tone')
|
||||
}
|
||||
|
||||
const source = `data:${audio.mimeType};base64,${audio.payload}`
|
||||
cacheSource(rawId, source)
|
||||
return source
|
||||
}
|
||||
|
||||
export function playCustomPhoneTone(
|
||||
tone: CustomPhoneTone,
|
||||
volumePercent: number,
|
||||
loop: boolean,
|
||||
callbacks: {
|
||||
onError?: (error: unknown) => void
|
||||
onStarted?: () => void
|
||||
} = {},
|
||||
): () => void {
|
||||
let stopped = false
|
||||
let stopPlayback: (() => void) | null = null
|
||||
void loadToneSource(tone)
|
||||
.then((source) => {
|
||||
if (!stopped) {
|
||||
stopPlayback = playPhoneMediaTone(
|
||||
source,
|
||||
volumePercent,
|
||||
loop,
|
||||
callbacks,
|
||||
)
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!stopped) {
|
||||
console.error('[Phone audio] Failed to load custom tone', error)
|
||||
callbacks.onError?.(error)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
stopped = true
|
||||
stopPlayback?.()
|
||||
stopPlayback = null
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
moveHomeFolderApp,
|
||||
parseHomeLayout,
|
||||
reflowHomeGridForWidgetChange,
|
||||
removeDockGridDuplicates,
|
||||
removeHomeApp,
|
||||
renameHomeFolder,
|
||||
restoreHomeApp,
|
||||
@@ -79,40 +78,6 @@ describe('home layout', () => {
|
||||
expect(layout.version).toBe(HOME_LAYOUT_VERSION)
|
||||
})
|
||||
|
||||
it('removes dock apps from the grid and normalizes affected folders', () => {
|
||||
const layout: HomeLayout = {
|
||||
...defaults,
|
||||
dock: [
|
||||
'phone',
|
||||
{
|
||||
apps: ['messages', 'mail'],
|
||||
id: 'folder-abcdef',
|
||||
name: 'Dock',
|
||||
type: 'folder',
|
||||
},
|
||||
null,
|
||||
null,
|
||||
],
|
||||
grid: [
|
||||
'phone',
|
||||
{
|
||||
apps: ['messages', 'notes'],
|
||||
id: 'folder-ghijkl',
|
||||
name: 'Grid',
|
||||
type: 'folder',
|
||||
},
|
||||
'mail',
|
||||
'clock',
|
||||
...Array.from({ length: HOME_GRID_PAGE_SIZE - 4 }, () => null),
|
||||
],
|
||||
}
|
||||
|
||||
const normalized = removeDockGridDuplicates(layout)
|
||||
|
||||
expect(normalized.grid.slice(0, 3)).toEqual(['notes', 'clock', null])
|
||||
expect(normalized.dock).toEqual(layout.dock)
|
||||
})
|
||||
|
||||
it('migrates compact persisted arrays and appends newly installed apps', () => {
|
||||
const layout = parseHomeLayout(
|
||||
{
|
||||
|
||||
@@ -505,37 +505,6 @@ export function createDefaultHomeLayout(
|
||||
}
|
||||
}
|
||||
|
||||
export function removeDockGridDuplicates(layout: HomeLayout): HomeLayout {
|
||||
const dockAppIds = new Set<LaunchablePhoneAppId>()
|
||||
for (const item of layout.dock) {
|
||||
if (typeof item === 'string') dockAppIds.add(item)
|
||||
if (isHomeFolder(item)) {
|
||||
for (const appId of item.apps) dockAppIds.add(appId)
|
||||
}
|
||||
}
|
||||
if (!dockAppIds.size) return layout
|
||||
|
||||
let changed = false
|
||||
const grid = layout.grid.map((item): HomeSlot => {
|
||||
if (typeof item === 'string') {
|
||||
if (!dockAppIds.has(item)) return item
|
||||
changed = true
|
||||
return null
|
||||
}
|
||||
if (!isHomeFolder(item)) return null
|
||||
|
||||
const apps = item.apps.filter((appId) => !dockAppIds.has(appId))
|
||||
if (apps.length === item.apps.length) return cloneItem(item)
|
||||
changed = true
|
||||
return normalizeFolder({ ...item, apps })
|
||||
})
|
||||
if (!changed) return layout
|
||||
|
||||
const next = cloneLayout(layout)
|
||||
next.grid = compactGridPages(grid)
|
||||
return next
|
||||
}
|
||||
|
||||
export function parseHomeLayout(
|
||||
value: unknown,
|
||||
defaults: HomeLayout,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { runInNewContext } from 'node:vm'
|
||||
|
||||
import type { ExternalPhoneAppDefinition } from '@/types/apps'
|
||||
import {
|
||||
@@ -140,79 +139,6 @@ 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 = {
|
||||
|
||||
@@ -130,25 +130,6 @@ 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 ?? {};
|
||||
@@ -208,11 +189,6 @@ globalThis.getSettings = async () => globalThis.settings;
|
||||
|
||||
globalThis.addEventListener('message', (event) => {
|
||||
const message = event.data;
|
||||
if (message === 'componentsLoaded') {
|
||||
globalThis.componentsLoaded = true;
|
||||
prepareDocument();
|
||||
return;
|
||||
}
|
||||
if (!message || typeof message !== 'object') return;
|
||||
|
||||
if (message.type === 'sky-phone:lb-settings') {
|
||||
@@ -234,10 +210,7 @@ globalThis.addEventListener('message', (event) => {
|
||||
});
|
||||
|
||||
applySettings(config.settings);
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
prepareDocument();
|
||||
applySettings(globalThis.settings);
|
||||
}, { once: true });
|
||||
document.addEventListener('DOMContentLoaded', () => applySettings(globalThis.settings), { once: true });
|
||||
`
|
||||
|
||||
function escapeAttribute(value: string): string {
|
||||
|
||||
@@ -158,13 +158,6 @@ describe('media utilities', () => {
|
||||
expect(mediaErrorKey('profile_photo_required')).toBe(
|
||||
'profile_photo_required',
|
||||
)
|
||||
expect(mediaErrorKey('media_provider_failed')).toBe('media_provider_failed')
|
||||
expect(mediaErrorKey('media_provider_rate_limited')).toBe(
|
||||
'media_provider_rate_limited',
|
||||
)
|
||||
expect(mediaErrorKey('media_provider_unauthorized')).toBe(
|
||||
'media_provider_unauthorized',
|
||||
)
|
||||
expect(mediaErrorKey('private_provider_error')).toBe('request_failed')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -95,9 +95,6 @@ export function mediaErrorKey(error?: string): string {
|
||||
'invalid_import_url',
|
||||
'invalid_upload',
|
||||
'invalid_upload_token',
|
||||
'media_provider_failed',
|
||||
'media_provider_rate_limited',
|
||||
'media_provider_unauthorized',
|
||||
'missing_config',
|
||||
'import_media_not_allowed',
|
||||
'import_media_too_large',
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
configurePhoneNumberFormat,
|
||||
formatPhoneNumber,
|
||||
normalizePhoneNumber,
|
||||
} from './phone'
|
||||
import { formatPhoneNumber, normalizePhoneNumber } from './phone'
|
||||
|
||||
describe('phone numbers', () => {
|
||||
it('normalizes formatted ten digit values', () => {
|
||||
@@ -17,13 +13,4 @@ describe('phone numbers', () => {
|
||||
expect(formatPhoneNumber('5551')).toBe('555 1')
|
||||
expect(formatPhoneNumber(5551234567)).toBe('555 123 4567')
|
||||
})
|
||||
|
||||
it('uses the server-provided number length and display groups', () => {
|
||||
configurePhoneNumberFormat({ groups: [4, 3, 3], length: 10 })
|
||||
|
||||
expect(formatPhoneNumber('0171234567')).toBe('0171 234 567')
|
||||
expect(normalizePhoneNumber('0171 234 567')).toBe('0171234567')
|
||||
|
||||
configurePhoneNumberFormat()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,53 +1,12 @@
|
||||
import type { PhoneNumberFormat } from '@/types/phone'
|
||||
|
||||
export const PHONE_NUMBER_LENGTH = 10
|
||||
const DEFAULT_PHONE_NUMBER_FORMAT: PhoneNumberFormat = {
|
||||
groups: [3, 3, 4],
|
||||
length: PHONE_NUMBER_LENGTH,
|
||||
}
|
||||
let phoneNumberFormat: PhoneNumberFormat = DEFAULT_PHONE_NUMBER_FORMAT
|
||||
|
||||
export function configurePhoneNumberFormat(value?: PhoneNumberFormat): void {
|
||||
const length = value?.length
|
||||
const groups = value?.groups
|
||||
if (
|
||||
typeof length !== 'number' ||
|
||||
!Number.isInteger(length) ||
|
||||
length < 1 ||
|
||||
length > 24 ||
|
||||
!Array.isArray(groups) ||
|
||||
groups.length === 0 ||
|
||||
groups.some((group) => !Number.isInteger(group) || group < 1)
|
||||
) {
|
||||
phoneNumberFormat = DEFAULT_PHONE_NUMBER_FORMAT
|
||||
return
|
||||
}
|
||||
|
||||
phoneNumberFormat = {
|
||||
groups: [...groups],
|
||||
length,
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizePhoneNumber(value: string): string | null {
|
||||
const digits = value.replace(/\D/g, '')
|
||||
return digits.length === phoneNumberFormat.length ? digits : null
|
||||
return digits.length === PHONE_NUMBER_LENGTH ? digits : null
|
||||
}
|
||||
|
||||
export function formatPhoneNumber(value: string | number): string {
|
||||
const digits = String(value)
|
||||
.replace(/\D/g, '')
|
||||
.slice(0, phoneNumberFormat.length)
|
||||
const formatted: string[] = []
|
||||
let offset = 0
|
||||
|
||||
for (const size of phoneNumberFormat.groups) {
|
||||
const group = digits.slice(offset, offset + size)
|
||||
if (!group) break
|
||||
formatted.push(group)
|
||||
offset += size
|
||||
}
|
||||
if (offset < digits.length) formatted.push(digits.slice(offset))
|
||||
|
||||
return formatted.join(' ')
|
||||
const digits = String(value).replace(/\D/g, '').slice(0, PHONE_NUMBER_LENGTH)
|
||||
const groups = [digits.slice(0, 3), digits.slice(3, 6), digits.slice(6, 10)]
|
||||
return groups.filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
type PhoneAudioVolumeListener = (volume: number) => void
|
||||
|
||||
const mediaElements = new Map<HTMLMediaElement, boolean>()
|
||||
const mediaLocalVolumes = new WeakMap<HTMLMediaElement, number>()
|
||||
const volumeListeners = new Set<PhoneAudioVolumeListener>()
|
||||
|
||||
let documentListenerReferences = 0
|
||||
let outputVolume = 1
|
||||
|
||||
function clampVolume(volume: number): number {
|
||||
return Math.max(0, Math.min(1, Number.isFinite(volume) ? volume : 0))
|
||||
}
|
||||
|
||||
function applyMediaVolume(element: HTMLMediaElement): void {
|
||||
const localVolume = mediaLocalVolumes.get(element) ?? element.volume
|
||||
const nextVolume = clampVolume(localVolume * outputVolume)
|
||||
if (Math.abs(element.volume - nextVolume) < 0.001) return
|
||||
element.volume = nextVolume
|
||||
}
|
||||
|
||||
function onMediaVolumeChange(event: Event): void {
|
||||
const element = event.currentTarget as HTMLMediaElement
|
||||
const currentLocalVolume = mediaLocalVolumes.get(element) ?? element.volume
|
||||
const expectedVolume = clampVolume(currentLocalVolume * outputVolume)
|
||||
if (Math.abs(element.volume - expectedVolume) < 0.001) return
|
||||
mediaLocalVolumes.set(element, clampVolume(element.volume))
|
||||
applyMediaVolume(element)
|
||||
}
|
||||
|
||||
function onMediaPlay(event: Event): void {
|
||||
if (event.target instanceof HTMLMediaElement) {
|
||||
trackPhoneMediaElement(event.target, false)
|
||||
}
|
||||
}
|
||||
|
||||
function trackPhoneMediaElement<T extends HTMLMediaElement>(
|
||||
element: T,
|
||||
persistent: boolean,
|
||||
): T {
|
||||
if (mediaElements.has(element)) {
|
||||
if (persistent) mediaElements.set(element, true)
|
||||
return element
|
||||
}
|
||||
mediaElements.set(element, persistent)
|
||||
mediaLocalVolumes.set(element, clampVolume(element.volume))
|
||||
element.addEventListener('volumechange', onMediaVolumeChange)
|
||||
applyMediaVolume(element)
|
||||
return element
|
||||
}
|
||||
|
||||
export function getPhoneOutputVolume(): number {
|
||||
return outputVolume
|
||||
}
|
||||
|
||||
export function installPhoneAudioController(): () => void {
|
||||
documentListenerReferences += 1
|
||||
if (documentListenerReferences === 1) {
|
||||
document.addEventListener('play', onMediaPlay, true)
|
||||
document
|
||||
.querySelectorAll<HTMLMediaElement>('audio, video')
|
||||
.forEach((element) => trackPhoneMediaElement(element, false))
|
||||
}
|
||||
|
||||
return () => {
|
||||
documentListenerReferences = Math.max(0, documentListenerReferences - 1)
|
||||
if (documentListenerReferences === 0) {
|
||||
document.removeEventListener('play', onMediaPlay, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function registerPhoneMediaElement<T extends HTMLMediaElement>(
|
||||
element: T,
|
||||
): T {
|
||||
return trackPhoneMediaElement(element, true)
|
||||
}
|
||||
|
||||
export function unregisterPhoneMediaElement(element: HTMLMediaElement): void {
|
||||
element.removeEventListener('volumechange', onMediaVolumeChange)
|
||||
mediaElements.delete(element)
|
||||
mediaLocalVolumes.delete(element)
|
||||
}
|
||||
|
||||
export function setPhoneOutputVolume(volume: number): void {
|
||||
outputVolume = clampVolume(volume)
|
||||
for (const [element, persistent] of mediaElements) {
|
||||
if (!persistent && !element.isConnected && element.paused) {
|
||||
element.removeEventListener('volumechange', onMediaVolumeChange)
|
||||
mediaElements.delete(element)
|
||||
continue
|
||||
}
|
||||
applyMediaVolume(element)
|
||||
}
|
||||
for (const listener of volumeListeners) listener(outputVolume)
|
||||
}
|
||||
|
||||
export function subscribePhoneOutputVolume(
|
||||
listener: PhoneAudioVolumeListener,
|
||||
): () => void {
|
||||
volumeListeners.add(listener)
|
||||
return () => volumeListeners.delete(listener)
|
||||
}
|
||||
@@ -1,316 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
normalizePhoneViewportRect,
|
||||
phoneViewportRectContainsPoint,
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
const PHONE_BASE_ZOOM = 0.69 * 1.2
|
||||
const PHONE_HEIGHT = 844
|
||||
const PHONE_WIDTH = 390
|
||||
const REFERENCE_VIEWPORT_HEIGHT = 1080
|
||||
const REFERENCE_VIEWPORT_WIDTH = 1920
|
||||
|
||||
const displayCases = [
|
||||
{ height: 1080, label: '1080p 16:9', width: 1920 },
|
||||
{ height: 2160, label: '4K 16:9', width: 3840 },
|
||||
{ height: 1440, label: '21:9', width: 3440 },
|
||||
{ height: 1440, label: '32:9', width: 5120 },
|
||||
{ height: 1600, label: '16:10', width: 2560 },
|
||||
] as const
|
||||
|
||||
const phoneScales = [80, 100, 120] as const
|
||||
|
||||
function productionPhoneZoom(
|
||||
viewportWidth: number,
|
||||
viewportHeight: number,
|
||||
phoneScale: number,
|
||||
): number {
|
||||
const viewportScale = Math.min(
|
||||
viewportWidth / REFERENCE_VIEWPORT_WIDTH,
|
||||
viewportHeight / REFERENCE_VIEWPORT_HEIGHT,
|
||||
)
|
||||
const preferred = PHONE_BASE_ZOOM * viewportScale * (phoneScale / 100)
|
||||
const edgeGap = 24 * viewportScale
|
||||
const viewportMaximum = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
(viewportWidth - edgeGap) / PHONE_WIDTH,
|
||||
(viewportHeight - edgeGap) / PHONE_HEIGHT,
|
||||
),
|
||||
)
|
||||
|
||||
return Math.min(viewportMaximum, Math.max(260 / PHONE_WIDTH, preferred))
|
||||
}
|
||||
|
||||
const cefDisplayMatrix = displayCases.flatMap((display) =>
|
||||
phoneScales.map((phoneScale) => ({ display, phoneScale })),
|
||||
)
|
||||
|
||||
describe('phone viewport geometry', () => {
|
||||
it('treats every rectangle boundary as inside and rejects points beyond it', () => {
|
||||
const rect: PhoneViewportRect = {
|
||||
bottom: 260,
|
||||
height: 160,
|
||||
left: 120,
|
||||
right: 360,
|
||||
top: 100,
|
||||
width: 240,
|
||||
}
|
||||
|
||||
expect(phoneViewportRectContainsPoint(rect, 240, 180)).toBe(true)
|
||||
expect(phoneViewportRectContainsPoint(rect, rect.left, rect.top)).toBe(true)
|
||||
expect(phoneViewportRectContainsPoint(rect, rect.right, rect.bottom)).toBe(
|
||||
true,
|
||||
)
|
||||
expect(phoneViewportRectContainsPoint(rect, rect.left - 0.001, 180)).toBe(
|
||||
false,
|
||||
)
|
||||
expect(phoneViewportRectContainsPoint(rect, rect.right + 0.001, 180)).toBe(
|
||||
false,
|
||||
)
|
||||
expect(phoneViewportRectContainsPoint(rect, 240, rect.top - 0.001)).toBe(
|
||||
false,
|
||||
)
|
||||
expect(phoneViewportRectContainsPoint(rect, 240, rect.bottom + 0.001)).toBe(
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
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.each(cefDisplayMatrix)(
|
||||
'keeps an opened folder panel hit-test exact at $phoneScale% on $display.label',
|
||||
({ display, phoneScale }) => {
|
||||
const viewportScale = Math.min(
|
||||
display.width / REFERENCE_VIEWPORT_WIDTH,
|
||||
display.height / REFERENCE_VIEWPORT_HEIGHT,
|
||||
)
|
||||
const zoom = productionPhoneZoom(
|
||||
display.width,
|
||||
display.height,
|
||||
phoneScale,
|
||||
)
|
||||
const edgeGap = 24 * viewportScale
|
||||
const wrapper = measuredRect(
|
||||
display.width - edgeGap - PHONE_WIDTH * zoom,
|
||||
display.height - edgeGap - PHONE_HEIGHT * zoom,
|
||||
PHONE_WIDTH * zoom,
|
||||
PHONE_HEIGHT * zoom,
|
||||
)
|
||||
// Live CEF 103 can report the zoomed canvas in a different coordinate
|
||||
// space from pointer events. Preserve that mismatch in this fixture.
|
||||
const rawCanvas = measuredRect(
|
||||
display.width + 117.375,
|
||||
83.625,
|
||||
PHONE_WIDTH,
|
||||
PHONE_HEIGHT,
|
||||
)
|
||||
const rawPanel = measuredRect(
|
||||
rawCanvas.left + 23.25,
|
||||
rawCanvas.top + 246.75,
|
||||
343.5,
|
||||
324.25,
|
||||
)
|
||||
const panel = normalizePhoneViewportRect(rawPanel, rawCanvas, wrapper)
|
||||
const expected = {
|
||||
bottom: wrapper.top + (246.75 + 324.25) * zoom,
|
||||
height: 324.25 * zoom,
|
||||
left: wrapper.left + 23.25 * zoom,
|
||||
right: wrapper.left + (23.25 + 343.5) * zoom,
|
||||
top: wrapper.top + 246.75 * zoom,
|
||||
width: 343.5 * zoom,
|
||||
}
|
||||
|
||||
expectRectClose(panel, expected)
|
||||
expect(
|
||||
phoneViewportRectContainsPoint(
|
||||
panel,
|
||||
panel.left + panel.width / 2,
|
||||
panel.top + panel.height / 2,
|
||||
),
|
||||
).toBe(true)
|
||||
expect(phoneViewportRectContainsPoint(panel, panel.left, panel.top)).toBe(
|
||||
true,
|
||||
)
|
||||
expect(
|
||||
phoneViewportRectContainsPoint(panel, panel.right, panel.bottom),
|
||||
).toBe(true)
|
||||
expect(
|
||||
phoneViewportRectContainsPoint(panel, panel.left - 0.25, panel.top),
|
||||
).toBe(false)
|
||||
expect(
|
||||
phoneViewportRectContainsPoint(panel, panel.right + 0.25, panel.bottom),
|
||||
).toBe(false)
|
||||
expect(panel.left).toBeGreaterThanOrEqual(0)
|
||||
expect(panel.right).toBeLessThanOrEqual(display.width)
|
||||
expect(panel.top).toBeGreaterThanOrEqual(0)
|
||||
expect(panel.bottom).toBeLessThanOrEqual(display.height)
|
||||
},
|
||||
)
|
||||
|
||||
it('reads visual scale and normalized element rects from a canvas anchor', () => {
|
||||
const fixture = createGeometryFixture({
|
||||
canvasOffsetHeight: 844,
|
||||
canvasOffsetWidth: 390,
|
||||
rawCanvasRect: measuredRect(1899.87, 152.5, 389.98, 844),
|
||||
wrapperRect: measuredRect(1573.09, 126.25, 322.92, 698.832),
|
||||
})
|
||||
const geometry = readPhoneViewportGeometry(fixture.anchor)
|
||||
const layer = fixture.element(measuredRect(1920.03, 214.25, 349.66, 728.5))
|
||||
|
||||
expect(geometry).not.toBeNull()
|
||||
expect(geometry?.scaleX).toBeCloseTo(0.828, 6)
|
||||
expect(geometry?.scaleY).toBeCloseTo(0.828, 6)
|
||||
expect(geometry?.rect(layer).left).toBeCloseTo(1589.7833360685163, 6)
|
||||
})
|
||||
|
||||
it('uses finite identity fallbacks for zero geometry and missing anchors', () => {
|
||||
const rawCanvas = measuredRect(100, 50, 0, 0)
|
||||
const corrected = normalizePhoneViewportRect(
|
||||
measuredRect(112.5, 58.25, 40, 20),
|
||||
rawCanvas,
|
||||
measuredRect(500, 300, 0, 0),
|
||||
)
|
||||
const fixture = createGeometryFixture({
|
||||
canvasOffsetHeight: 0,
|
||||
canvasOffsetWidth: 0,
|
||||
rawCanvasRect: rawCanvas,
|
||||
wrapperRect: measuredRect(500, 300, 0, 0),
|
||||
})
|
||||
const geometry = readPhoneViewportGeometry(fixture.anchor)
|
||||
|
||||
expect(corrected).toEqual({
|
||||
bottom: 328.25,
|
||||
height: 20,
|
||||
left: 512.5,
|
||||
right: 552.5,
|
||||
top: 308.25,
|
||||
width: 40,
|
||||
})
|
||||
expect(Object.values(corrected).every(Number.isFinite)).toBe(true)
|
||||
expect(geometry?.scaleX).toBe(1)
|
||||
expect(geometry?.scaleY).toBe(1)
|
||||
expect(readPhoneViewportGeometry(null)).toBeNull()
|
||||
expect(
|
||||
readPhoneViewportGeometry({ closest: () => null } as unknown as Element),
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,81 +0,0 @@
|
||||
export type PhoneViewportRect = {
|
||||
bottom: number
|
||||
height: number
|
||||
left: number
|
||||
right: number
|
||||
top: number
|
||||
width: number
|
||||
}
|
||||
|
||||
export function phoneViewportRectContainsPoint(
|
||||
rect: PhoneViewportRect,
|
||||
x: number,
|
||||
y: number,
|
||||
): boolean {
|
||||
return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom
|
||||
}
|
||||
|
||||
type RectMeasurement = Pick<
|
||||
DOMRectReadOnly,
|
||||
'height' | 'left' | 'top' | 'width'
|
||||
>
|
||||
|
||||
export type PhoneViewportGeometry = {
|
||||
readonly scaleX: number
|
||||
readonly scaleY: number
|
||||
rect(element: Element): PhoneViewportRect
|
||||
}
|
||||
|
||||
function positiveRatio(numerator: number, denominator: number): number {
|
||||
return Number.isFinite(numerator) &&
|
||||
Number.isFinite(denominator) &&
|
||||
numerator > 0 &&
|
||||
denominator > 0
|
||||
? numerator / denominator
|
||||
: 1
|
||||
}
|
||||
|
||||
export function normalizePhoneViewportRect(
|
||||
rawRect: RectMeasurement,
|
||||
rawCanvasRect: RectMeasurement,
|
||||
wrapperRect: RectMeasurement,
|
||||
): PhoneViewportRect {
|
||||
const factorX = positiveRatio(wrapperRect.width, rawCanvasRect.width)
|
||||
const factorY = positiveRatio(wrapperRect.height, rawCanvasRect.height)
|
||||
const left = wrapperRect.left + (rawRect.left - rawCanvasRect.left) * factorX
|
||||
const top = wrapperRect.top + (rawRect.top - rawCanvasRect.top) * factorY
|
||||
const width = rawRect.width * factorX
|
||||
const height = rawRect.height * factorY
|
||||
|
||||
return {
|
||||
bottom: top + height,
|
||||
height,
|
||||
left,
|
||||
right: left + width,
|
||||
top,
|
||||
width,
|
||||
}
|
||||
}
|
||||
|
||||
export function readPhoneViewportGeometry(
|
||||
anchor: Element | null,
|
||||
): PhoneViewportGeometry | null {
|
||||
const canvas = anchor?.closest<HTMLElement>('.phone-resolution-canvas')
|
||||
const wrapper = canvas?.closest<HTMLElement>('.phone-resolution-wrapper')
|
||||
if (!canvas || !wrapper) return null
|
||||
|
||||
const rawCanvasRect = canvas.getBoundingClientRect()
|
||||
const wrapperRect = wrapper.getBoundingClientRect()
|
||||
|
||||
return {
|
||||
scaleX: positiveRatio(wrapperRect.width, canvas.offsetWidth),
|
||||
scaleY: positiveRatio(wrapperRect.height, canvas.offsetHeight),
|
||||
rect(element: Element): PhoneViewportRect {
|
||||
return normalizePhoneViewportRect(
|
||||
element.getBoundingClientRect(),
|
||||
rawCanvasRect,
|
||||
wrapperRect,
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -2,16 +2,11 @@ import { describe, expect, it } from 'vitest'
|
||||
import type { LaunchablePhoneAppId } from '@/types/apps'
|
||||
import {
|
||||
DEFAULT_PHONE_PREFERENCES,
|
||||
PHONE_SCALE_STEP,
|
||||
PHONE_SETUP_LAST_STEP,
|
||||
parsePhonePreferences,
|
||||
WALLPAPER_IDS,
|
||||
} from './preferences'
|
||||
describe('preferences', () => {
|
||||
it('allows one-percent phone scale adjustments', () => {
|
||||
expect(PHONE_SCALE_STEP).toBe(1)
|
||||
})
|
||||
|
||||
it('starts Setup Assistant for a phone without saved settings', () => {
|
||||
const value = parsePhonePreferences(null)
|
||||
|
||||
@@ -123,38 +118,6 @@ describe('preferences', () => {
|
||||
expect(value.settings.screenBrightness).toBe(10)
|
||||
})
|
||||
|
||||
it('preserves valid custom tone ids without persisting their URLs', () => {
|
||||
const notificationId = 'custom:1fbd07b4-d231-4a76-b661-c9e5d0ab19a8'
|
||||
const ringtoneId = 'custom:a1e8db72-bc56-41c8-84e2-521180e14db1'
|
||||
const value = parsePhonePreferences(
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
settings: {
|
||||
notificationSound: notificationId,
|
||||
ringtone: ringtoneId,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
expect(value.settings.notificationSound).toBe(notificationId)
|
||||
expect(value.settings.ringtone).toBe(ringtoneId)
|
||||
})
|
||||
|
||||
it('rejects malformed custom tone ids', () => {
|
||||
const value = parsePhonePreferences(
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
settings: {
|
||||
notificationSound: 'custom:../unsafe',
|
||||
ringtone: 'custom:',
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
expect(value.settings.notificationSound).toBe('chime')
|
||||
expect(value.settings.ringtone).toBe('skyline')
|
||||
})
|
||||
|
||||
it('keeps the phone above the minimum usable scale', () => {
|
||||
const value = parsePhonePreferences(
|
||||
JSON.stringify({
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import type { LaunchablePhoneAppId } from '@/types/apps'
|
||||
import { cloneJsonData } from '@/utils/clone'
|
||||
import {
|
||||
isCustomTonePreferenceId,
|
||||
type CustomTonePreferenceId,
|
||||
} from '@/utils/customTones'
|
||||
|
||||
export const APPEARANCE_MODE_IDS = ['automatic', 'light', 'dark'] as const
|
||||
export const GRAPHICS_MODE_IDS = ['performance', 'ultimate'] as const
|
||||
@@ -42,18 +38,14 @@ export const WALLPAPER_IDS = [
|
||||
] as const
|
||||
export const PHONE_SCALE_MIN = 75
|
||||
export const PHONE_SCALE_MAX = 150
|
||||
export const PHONE_SCALE_STEP = 1
|
||||
export const PHONE_SCALE_STEP = 5
|
||||
export const PHONE_SETUP_LAST_STEP = 9
|
||||
|
||||
export type AppearanceMode = (typeof APPEARANCE_MODE_IDS)[number]
|
||||
export type GraphicsMode = (typeof GRAPHICS_MODE_IDS)[number]
|
||||
export type PhoneFrameId = (typeof PHONE_FRAME_IDS)[number]
|
||||
export type BuiltInRingtoneId = (typeof RINGTONE_IDS)[number]
|
||||
export type BuiltInNotificationSoundId = (typeof NOTIFICATION_SOUND_IDS)[number]
|
||||
export type RingtoneId = BuiltInRingtoneId | CustomTonePreferenceId
|
||||
export type NotificationSoundId =
|
||||
| BuiltInNotificationSoundId
|
||||
| CustomTonePreferenceId
|
||||
export type RingtoneId = (typeof RINGTONE_IDS)[number]
|
||||
export type NotificationSoundId = (typeof NOTIFICATION_SOUND_IDS)[number]
|
||||
export type BuiltInWallpaperId = (typeof WALLPAPER_IDS)[number]
|
||||
export type WallpaperId = BuiltInWallpaperId | 'custom'
|
||||
export type WallpaperTarget = 'home' | 'lock'
|
||||
@@ -204,33 +196,6 @@ function readChoice<T extends string>(
|
||||
: fallback
|
||||
}
|
||||
|
||||
export function isBuiltInRingtoneId(
|
||||
value: unknown,
|
||||
): value is BuiltInRingtoneId {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
RINGTONE_IDS.includes(value as BuiltInRingtoneId)
|
||||
)
|
||||
}
|
||||
|
||||
export function isBuiltInNotificationSoundId(
|
||||
value: unknown,
|
||||
): value is BuiltInNotificationSoundId {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
NOTIFICATION_SOUND_IDS.includes(value as BuiltInNotificationSoundId)
|
||||
)
|
||||
}
|
||||
|
||||
function readToneChoice<T extends string>(
|
||||
value: unknown,
|
||||
choices: readonly T[],
|
||||
fallback: T,
|
||||
): T | CustomTonePreferenceId {
|
||||
if (isCustomTonePreferenceId(value)) return value
|
||||
return readChoice(value, choices, fallback)
|
||||
}
|
||||
|
||||
function readWallpaperImageUrl(value: unknown): string | null {
|
||||
if (typeof value !== 'string') return null
|
||||
const imageUrl = value.trim()
|
||||
@@ -363,7 +328,7 @@ export function parsePhonePreferences(raw: string | null): PhonePreferencesV1 {
|
||||
),
|
||||
lockWallpaper,
|
||||
lockWallpaperImageUrl,
|
||||
notificationSound: readToneChoice(
|
||||
notificationSound: readChoice(
|
||||
settings.notificationSound,
|
||||
NOTIFICATION_SOUND_IDS,
|
||||
defaults.notificationSound,
|
||||
@@ -389,7 +354,7 @@ export function parsePhonePreferences(raw: string | null): PhonePreferencesV1 {
|
||||
Number.MAX_SAFE_INTEGER,
|
||||
),
|
||||
),
|
||||
ringtone: readToneChoice(
|
||||
ringtone: readChoice(
|
||||
settings.ringtone,
|
||||
RINGTONE_IDS,
|
||||
defaults.ringtone,
|
||||
|
||||
@@ -140,56 +140,4 @@ describe('springboard widget drag', () => {
|
||||
expect(delta.x).toBeCloseTo(100)
|
||||
expect(delta.y).toBeCloseTo(200)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['80% preview', 0.6624],
|
||||
['80% production clamp', 2 / 3],
|
||||
['100%', 0.828],
|
||||
['120%', 0.9936],
|
||||
])('keeps the rendered drag delta 1:1 at %s zoom', (_label, zoom) => {
|
||||
const layoutWidth = 350
|
||||
const layoutHeight = 808
|
||||
const viewportLeft = 128.25
|
||||
const viewportTop = 64.5
|
||||
const viewportWidth = layoutWidth * zoom
|
||||
const viewportHeight = layoutHeight * zoom
|
||||
const pointerStart = {
|
||||
x: viewportLeft + 48.5 * zoom,
|
||||
y: viewportTop + 132.25 * zoom,
|
||||
}
|
||||
const viewportDelta = { x: 73.25, y: -41.75 }
|
||||
const start = springboardViewportToLocal(
|
||||
pointerStart.x,
|
||||
pointerStart.y,
|
||||
viewportLeft,
|
||||
viewportTop,
|
||||
viewportWidth,
|
||||
viewportHeight,
|
||||
layoutWidth,
|
||||
layoutHeight,
|
||||
)
|
||||
const end = springboardViewportToLocal(
|
||||
pointerStart.x + viewportDelta.x,
|
||||
pointerStart.y + viewportDelta.y,
|
||||
viewportLeft,
|
||||
viewportTop,
|
||||
viewportWidth,
|
||||
viewportHeight,
|
||||
layoutWidth,
|
||||
layoutHeight,
|
||||
)
|
||||
const localDelta = springboardViewportDeltaToLocal(
|
||||
viewportDelta.x,
|
||||
viewportDelta.y,
|
||||
viewportWidth,
|
||||
viewportHeight,
|
||||
layoutWidth,
|
||||
layoutHeight,
|
||||
)
|
||||
|
||||
expect((end.x - start.x) * zoom).toBeCloseTo(viewportDelta.x, 6)
|
||||
expect((end.y - start.y) * zoom).toBeCloseTo(viewportDelta.y, 6)
|
||||
expect(localDelta.x * zoom).toBeCloseTo(viewportDelta.x, 6)
|
||||
expect(localDelta.y * zoom).toBeCloseTo(viewportDelta.y, 6)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { readPhoneViewportGeometry } from '@/utils/phoneViewportGeometry'
|
||||
|
||||
export type PageTurnDirection = -1 | 0 | 1
|
||||
|
||||
export type SpringboardEdgeTurn = {
|
||||
@@ -41,9 +39,7 @@ export function readSpringboardDragMetrics(
|
||||
'.springboard-page, .home-folder-panel',
|
||||
)
|
||||
if (!surface) return null
|
||||
const bounds =
|
||||
readPhoneViewportGeometry(surface)?.rect(surface) ??
|
||||
surface.getBoundingClientRect()
|
||||
const bounds = surface.getBoundingClientRect()
|
||||
return {
|
||||
layoutHeight: surface.offsetHeight,
|
||||
layoutWidth: surface.offsetWidth,
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { ALARM_SOUND_IDS } from './alarms'
|
||||
import {
|
||||
phoneToneDuration,
|
||||
playPhoneMediaTone,
|
||||
playPhoneVibration,
|
||||
} from './tones'
|
||||
import { phoneToneDuration, playPhoneVibration } from './tones'
|
||||
|
||||
describe('phone tones', () => {
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
@@ -31,14 +27,12 @@ describe('phone tones', () => {
|
||||
pause: () => void
|
||||
play: () => Promise<void>
|
||||
preload: string
|
||||
load: () => void
|
||||
removeAttribute: (name: string) => void
|
||||
src: string
|
||||
volume: number
|
||||
}> = []
|
||||
vi.stubGlobal(
|
||||
'Audio',
|
||||
class extends EventTarget {
|
||||
class {
|
||||
currentTime = 7
|
||||
loop = false
|
||||
pause = pause
|
||||
@@ -47,14 +41,7 @@ describe('phone tones', () => {
|
||||
src: string
|
||||
volume = 0
|
||||
|
||||
load(): void {}
|
||||
|
||||
removeAttribute(name: string): void {
|
||||
if (name === 'src') this.src = ''
|
||||
}
|
||||
|
||||
constructor(src: string) {
|
||||
super()
|
||||
this.src = src
|
||||
players.push(this)
|
||||
}
|
||||
@@ -75,46 +62,4 @@ describe('phone tones', () => {
|
||||
expect(players[0].currentTime).toBe(0)
|
||||
},
|
||||
)
|
||||
|
||||
it('reports media playback only after play has actually started', async () => {
|
||||
let resolvePlay: (() => void) | undefined
|
||||
const onError = vi.fn()
|
||||
const onStarted = vi.fn()
|
||||
vi.stubGlobal(
|
||||
'Audio',
|
||||
class extends EventTarget {
|
||||
loop = false
|
||||
preload = ''
|
||||
volume = 0
|
||||
|
||||
load(): void {}
|
||||
|
||||
pause(): void {}
|
||||
|
||||
play(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
resolvePlay = resolve
|
||||
})
|
||||
}
|
||||
|
||||
removeAttribute(): void {}
|
||||
},
|
||||
)
|
||||
|
||||
const stop = playPhoneMediaTone(
|
||||
'data:audio/ogg;base64,T2dnUw==',
|
||||
75,
|
||||
false,
|
||||
{
|
||||
onError,
|
||||
onStarted,
|
||||
},
|
||||
)
|
||||
|
||||
expect(onStarted).not.toHaveBeenCalled()
|
||||
resolvePlay?.()
|
||||
await vi.waitFor(() => expect(onStarted).toHaveBeenCalledOnce())
|
||||
expect(onError).not.toHaveBeenCalled()
|
||||
stop()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import {
|
||||
registerPhoneMediaElement,
|
||||
unregisterPhoneMediaElement,
|
||||
} from '@/utils/phoneAudio'
|
||||
import type { AlarmSoundId } from '@/utils/alarms'
|
||||
import type { BuiltInNotificationSoundId } from '@/utils/preferences'
|
||||
import type { NotificationSoundId } from '@/utils/preferences'
|
||||
|
||||
export type PhoneToneId = AlarmSoundId | BuiltInNotificationSoundId
|
||||
export type PhoneToneId = AlarmSoundId | NotificationSoundId
|
||||
export type PhoneVibrationKind = 'call' | 'notification'
|
||||
|
||||
type ToneVoice = {
|
||||
@@ -495,50 +491,12 @@ export function playPhoneTone(
|
||||
}
|
||||
}
|
||||
|
||||
export function playPhoneMediaTone(
|
||||
url: string,
|
||||
volumePercent: number,
|
||||
loop: boolean,
|
||||
callbacks: {
|
||||
onError?: (error: unknown) => void
|
||||
onStarted?: () => void
|
||||
} = {},
|
||||
): () => void {
|
||||
const player = new Audio(url)
|
||||
player.loop = loop
|
||||
player.preload = 'auto'
|
||||
player.volume = Math.max(0, Math.min(1, volumePercent / 100))
|
||||
registerPhoneMediaElement(player)
|
||||
|
||||
let stopped = false
|
||||
void player
|
||||
.play()
|
||||
.then(() => {
|
||||
if (!stopped) callbacks.onStarted?.()
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!stopped) {
|
||||
console.error('[Phone audio] Failed to start custom tone', error)
|
||||
callbacks.onError?.(error)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
if (stopped) return
|
||||
stopped = true
|
||||
player.pause()
|
||||
player.removeAttribute('src')
|
||||
player.load()
|
||||
unregisterPhoneMediaElement(player)
|
||||
}
|
||||
}
|
||||
|
||||
export function playPhoneVibration(
|
||||
kind: PhoneVibrationKind,
|
||||
loop: boolean,
|
||||
): () => void {
|
||||
const player = registerPhoneMediaElement(
|
||||
new Audio(`${import.meta.env.BASE_URL}${VIBRATION_SOUND_PATHS[kind]}`),
|
||||
const player = new Audio(
|
||||
`${import.meta.env.BASE_URL}${VIBRATION_SOUND_PATHS[kind]}`,
|
||||
)
|
||||
let stopped = false
|
||||
player.loop = loop
|
||||
@@ -551,12 +509,8 @@ export function playPhoneVibration(
|
||||
})
|
||||
|
||||
return () => {
|
||||
if (stopped) return
|
||||
stopped = true
|
||||
player.pause()
|
||||
player.currentTime = 0
|
||||
player.removeAttribute('src')
|
||||
player.load()
|
||||
unregisterPhoneMediaElement(player)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,13 +28,9 @@ const launchStyle = computed(() => {
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="app && !app.adminOnly"
|
||||
v-if="app"
|
||||
class="app-window"
|
||||
:class="{
|
||||
'app-window--camera-landscape':
|
||||
app.id === 'camera' && phone.cameraLandscape,
|
||||
'app-window--citywarn': app.id === 'citywarn',
|
||||
}"
|
||||
:class="{ 'app-window--citywarn': app.id === 'citywarn' }"
|
||||
:style="launchStyle"
|
||||
>
|
||||
<CustomAppFrame
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user