mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-01 18:58:58 +00:00
ADD - open unique phones by item and hotkey
Register a configurable F1 phone toggle through a server-authoritative open request and retain the selected unique handset by IMEI. Verify usable-item support for every inventory adapter, fail visibly on registration errors, and document the behavior.
This commit is contained in:
@@ -214,6 +214,8 @@ Default entries for unique phones with physical SIM cards:
|
||||
|
||||
Do not configure an LB Phone client event or client export. Sky Phone registers the usable items through its server-side inventory adapter.
|
||||
|
||||
The server registers `Config.Phone.Item` as usable for every supported inventory adapter: `ox`, `qb`, `lj`, `qs`, `codem`, `core`, `mf`, and `smx`. Resource startup fails visibly if the selected adapter cannot complete that registration.
|
||||
|
||||
### QBCore-style item tables
|
||||
|
||||
- Set the phone's `unique` value to match `Config.Phone.Unique`.
|
||||
@@ -235,6 +237,8 @@ Config.Sim.Enabled = true
|
||||
| `Unique = true` | Every phone item receives its own IMEI. Settings, apps, local data, linked account, and SIM move with the item. The item must not stack. |
|
||||
| `Unique = false` | Every framework character receives one persistent virtual device. Any configured phone item opens that device. The item may stack. |
|
||||
|
||||
With unique phones, using an inventory item selects that exact handset whenever the inventory reports its slot. The F1 hotkey reopens the last selected IMEI; if no handset has been selected yet, the server chooses the first concrete phone slot. The client never supplies a slot or IMEI.
|
||||
|
||||
| SIM mode | Behavior |
|
||||
| --- | --- |
|
||||
| `Enabled = true` | A registered or anonymous physical SIM item is required for cellular service. |
|
||||
@@ -363,6 +367,8 @@ The model must also be listed in `Config.Payphones.Props`.
|
||||
|
||||
## Commands
|
||||
|
||||
`Config.Phone.Keybind` defaults to `F1` and can be rebound in FiveM's key bindings. Set it to `false` to disable the phone hotkey.
|
||||
|
||||
| Command | Where | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `/phone` | In game | Opens the development phone command when `Config.Phone.DevelopmentCommand` is enabled |
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const readResourceFile = (path: string) =>
|
||||
readFileSync(new URL(`../../sky_phone/${path}`, import.meta.url), 'utf8')
|
||||
|
||||
const inventoryAdapters = [
|
||||
['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'],
|
||||
['codem', 'source/bridge/server/inventory/codem.lua'],
|
||||
['core', 'source/bridge/server/inventory/core.lua'],
|
||||
['mf', 'source/bridge/server/inventory/mf.lua'],
|
||||
['smx', 'source/bridge/server/inventory/smx.lua'],
|
||||
] as const
|
||||
|
||||
describe('phone inventory contracts', () => {
|
||||
it.each(inventoryAdapters)(
|
||||
'registers the phone as a usable item through the %s adapter',
|
||||
(_inventory, path) => {
|
||||
expect(readResourceFile(path)).toContain(
|
||||
'function Bridge.Inventory.RegisterUsableItem',
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
it('fails startup when the selected inventory cannot register the phone item', () => {
|
||||
const phoneServer = readResourceFile('source/server/phone.lua')
|
||||
|
||||
expect(phoneServer).toContain(
|
||||
'Bridge.Inventory.RegisterUsableItem(Config.Phone.Item, open_phone)',
|
||||
)
|
||||
expect(phoneServer).toContain('if not usable_registered then')
|
||||
})
|
||||
|
||||
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(
|
||||
'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('keeps a server-selected unique handset as the preferred hotkey device', () => {
|
||||
const phoneServer = readResourceFile('source/server/phone.lua')
|
||||
|
||||
expect(phoneServer).toContain('local preferred_device_imeis = {}')
|
||||
expect(phoneServer).toContain('local preferred_imei = preferred_device_imeis[source]')
|
||||
expect(phoneServer).toContain('preferred_device_imeis[source] = imei')
|
||||
})
|
||||
})
|
||||
@@ -12,6 +12,16 @@ const phoneServer = readFileSync(
|
||||
)
|
||||
|
||||
describe('server startup contracts', () => {
|
||||
it('registers the production phone-open callback before the database migration runs', () => {
|
||||
expect(
|
||||
phoneServer.indexOf(
|
||||
'Bridge.Callbacks.Register("sky_phone:device:open-request"',
|
||||
),
|
||||
).toBeLessThan(
|
||||
phoneServer.indexOf('Bridge.Database.AfterMigration("sky_phone"'),
|
||||
)
|
||||
})
|
||||
|
||||
it('registers the development-open callback before the database migration runs', () => {
|
||||
expect(manifest.indexOf("'source/server/phone.lua'")).toBeLessThan(
|
||||
manifest.indexOf("'source/server/db_migrate.lua'"),
|
||||
@@ -25,11 +35,12 @@ describe('server startup contracts', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('queues early development opens until the resource has fully started', () => {
|
||||
it('queues early opens until the resource has fully started', () => {
|
||||
expect(phoneServer).toContain(
|
||||
'AddEventHandler("onServerResourceStart", function(resource_name)',
|
||||
)
|
||||
expect(phoneServer).toContain('pending_development_opens[source] = true')
|
||||
expect(phoneServer).toContain('development_open_handler = open_phone')
|
||||
expect(phoneServer).toContain('pending_phone_opens[source] = true')
|
||||
expect(phoneServer).toContain('phone_open_handler = open_phone')
|
||||
expect(phoneServer).toContain('flush_pending_phone_opens()')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -27,6 +27,7 @@ Config.Command = "phone"
|
||||
Config.Phone = {
|
||||
Item = "phone",
|
||||
Unique = true, -- true: data follows each phone item; false: one persistent phone per character
|
||||
Keybind = "F1", -- false disables the configurable phone key mapping
|
||||
AllowMovement = true, -- true: game input stays active while the mobile phone is open
|
||||
DevelopmentCommand = true,
|
||||
DeviceName = "iFruit Phone",
|
||||
|
||||
@@ -252,6 +252,9 @@ local german = translate_shared(clone(Locales["en"] or {}))
|
||||
|
||||
merge(german, {
|
||||
CommandDescription = "Öffne dein Handy.",
|
||||
Controls = {
|
||||
OpenPhone = "Handy öffnen",
|
||||
},
|
||||
TestData = {
|
||||
CommandDescription = "Testinhalte für alle datenbasierten Handy-Apps erstellen oder aktualisieren.",
|
||||
Success = "Die Testinhalte sind bereit. Deine Sky-Cloud-Anmeldung lautet {email}. Öffne das Handy erneut, um alle Apps zu aktualisieren.",
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
Locales["en"] = {
|
||||
CommandDescription = "Open your phone.",
|
||||
Controls = {
|
||||
OpenPhone = "Open phone",
|
||||
},
|
||||
TestData = {
|
||||
CommandDescription = "Create or refresh test content in every data-driven phone app.",
|
||||
Success = "Test content is ready. Your Sky Cloud login is {email}. Reopen the phone to refresh every app.",
|
||||
|
||||
@@ -435,7 +435,7 @@ end
|
||||
|
||||
if Config.Phone.DevelopmentCommand then
|
||||
RegisterCommand(Config.Command, function()
|
||||
if is_open then
|
||||
if is_open or open_requested then
|
||||
close_phone()
|
||||
return
|
||||
end
|
||||
@@ -443,6 +443,23 @@ if Config.Phone.DevelopmentCommand then
|
||||
end, false)
|
||||
end
|
||||
|
||||
RegisterCommand("sky_phone_toggle", function()
|
||||
if is_open or open_requested then
|
||||
close_phone()
|
||||
return
|
||||
end
|
||||
|
||||
Bridge.Callbacks.Trigger("sky_phone:device:open-request", {})
|
||||
end, false)
|
||||
|
||||
if Config.Phone.Keybind then
|
||||
if type(Config.Phone.Keybind) ~= "string" or Config.Phone.Keybind == "" then
|
||||
error("[sky_phone] Config.Phone.Keybind must be a non-empty keyboard key name or false.")
|
||||
end
|
||||
|
||||
RegisterKeyMapping("sky_phone_toggle", locale.Controls.OpenPhone, "keyboard", Config.Phone.Keybind)
|
||||
end
|
||||
|
||||
RegisterNetEvent("sky_phone:testdata:feedback", function(success, detail)
|
||||
local test_data_locale = locale.TestData
|
||||
local message = success and test_data_locale.Success or test_data_locale.Failed
|
||||
|
||||
@@ -1,20 +1,41 @@
|
||||
local development_open_handler
|
||||
local pending_development_opens = {}
|
||||
local phone_open_handler
|
||||
local pending_phone_opens = {}
|
||||
local server_started = false
|
||||
|
||||
local function flush_pending_phone_opens()
|
||||
if not server_started or not phone_open_handler then
|
||||
return
|
||||
end
|
||||
|
||||
for player_source in pairs(pending_phone_opens) do
|
||||
pending_phone_opens[player_source] = nil
|
||||
phone_open_handler(player_source, nil)
|
||||
end
|
||||
end
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:device:open-request", function(source)
|
||||
if not server_started or not phone_open_handler then
|
||||
pending_phone_opens[source] = true
|
||||
return { success = true, data = { queued = true } }
|
||||
end
|
||||
|
||||
return { success = phone_open_handler(source, nil) }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:device:development-open", function(source)
|
||||
if not Config.Phone.DevelopmentCommand then
|
||||
return { success = false, error = "disabled" }
|
||||
end
|
||||
if not server_started or not development_open_handler then
|
||||
pending_development_opens[source] = true
|
||||
if not server_started or not phone_open_handler then
|
||||
pending_phone_opens[source] = true
|
||||
return { success = true, data = { queued = true } }
|
||||
end
|
||||
return { success = development_open_handler(source, nil) }
|
||||
|
||||
return { success = phone_open_handler(source, nil) }
|
||||
end)
|
||||
|
||||
AddEventHandler("playerDropped", function()
|
||||
pending_development_opens[source] = nil
|
||||
pending_phone_opens[source] = nil
|
||||
end)
|
||||
|
||||
AddEventHandler("onServerResourceStart", function(resource_name)
|
||||
@@ -23,23 +44,7 @@ AddEventHandler("onServerResourceStart", function(resource_name)
|
||||
end
|
||||
|
||||
server_started = true
|
||||
if not development_open_handler then
|
||||
Bridge.Debug("error", "[sky_phone] Server initialization did not register the development phone handler.")
|
||||
return
|
||||
end
|
||||
|
||||
for player_source in pairs(pending_development_opens) do
|
||||
pending_development_opens[player_source] = nil
|
||||
local success, opened = pcall(development_open_handler, player_source, nil)
|
||||
if not success then
|
||||
Bridge.Debug(
|
||||
"error",
|
||||
"[sky_phone] Queued development phone open failed for source %s: %s",
|
||||
tostring(player_source),
|
||||
tostring(opened)
|
||||
)
|
||||
end
|
||||
end
|
||||
flush_pending_phone_opens()
|
||||
end)
|
||||
|
||||
Bridge.Database.AfterMigration("sky_phone", function()
|
||||
@@ -50,6 +55,7 @@ SkyPhone = {}
|
||||
local unique_phones = Config.Phone.Unique ~= false
|
||||
local sim_cards_enabled = Config.Sim.Enabled ~= false
|
||||
local sessions = {}
|
||||
local preferred_device_imeis = {}
|
||||
local auth_attempts = {}
|
||||
local operation_attempts = {}
|
||||
local character_device_cache = {}
|
||||
@@ -311,6 +317,14 @@ local function resolve_used_slot(source, used_item)
|
||||
if slot and slot.name == Config.Phone.Item then
|
||||
return slot
|
||||
end
|
||||
|
||||
Bridge.Debug(
|
||||
"warn",
|
||||
"[sky_phone] Usable item callback reported invalid phone slot %s for source %s.",
|
||||
tostring(slot_id),
|
||||
tostring(source)
|
||||
)
|
||||
return nil, "phone_slot_missing"
|
||||
end
|
||||
|
||||
local slots = Bridge.Inventory.GetSlotsWithItem(source, Config.Phone.Item)
|
||||
@@ -332,17 +346,40 @@ local function resolve_used_slot(source, used_item)
|
||||
{ always = true }
|
||||
)
|
||||
end
|
||||
if #slots == 1 or (not unique_phones and #slots > 0) then
|
||||
|
||||
if not unique_phones and #slots > 0 then
|
||||
return slots[1]
|
||||
end
|
||||
|
||||
local preferred_imei = preferred_device_imeis[source]
|
||||
if preferred_imei then
|
||||
for _, candidate in ipairs(slots) do
|
||||
if candidate.metadata and candidate.metadata.imei == preferred_imei then
|
||||
return candidate
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
table.sort(slots, function(left, right)
|
||||
local left_slot = tonumber(left.slot)
|
||||
local right_slot = tonumber(right.slot)
|
||||
if left_slot and right_slot and left_slot ~= right_slot then
|
||||
return left_slot < right_slot
|
||||
end
|
||||
return tostring(left.slot) < tostring(right.slot)
|
||||
end)
|
||||
|
||||
if slots[1] then
|
||||
return slots[1]
|
||||
end
|
||||
|
||||
Bridge.Debug(
|
||||
"warn",
|
||||
"[sky_phone] Usable item callback did not identify an exact phone slot for source %s (%s candidates).",
|
||||
"[sky_phone] No phone item slot was available for source %s (%s candidates).",
|
||||
tostring(source),
|
||||
tostring(#slots)
|
||||
)
|
||||
return nil
|
||||
return nil, "phone_required"
|
||||
end
|
||||
|
||||
local function ensure_device(source, slot)
|
||||
@@ -947,15 +984,16 @@ local function open_phone(source, used_item)
|
||||
tostring(source),
|
||||
{ always = true }
|
||||
)
|
||||
local slot = resolve_used_slot(source, used_item)
|
||||
local slot, slot_error = resolve_used_slot(source, used_item)
|
||||
if not slot then
|
||||
Bridge.Debug(
|
||||
"debug",
|
||||
"[sky_phone] Phone open rejected for source %s: no exact inventory slot.",
|
||||
"[sky_phone] Phone open rejected for source %s: %s.",
|
||||
tostring(source),
|
||||
tostring(slot_error),
|
||||
{ always = true }
|
||||
)
|
||||
TriggerClientEvent("sky_phone:device:error", source, "phone_slot_missing")
|
||||
TriggerClientEvent("sky_phone:device:error", source, slot_error)
|
||||
return false
|
||||
end
|
||||
|
||||
@@ -995,6 +1033,7 @@ local function open_phone(source, used_item)
|
||||
token = ("%s:%s:%s"):format(imei, tostring(source), tostring(GetGameTimer())),
|
||||
unlocked = security == nil,
|
||||
}
|
||||
preferred_device_imeis[source] = imei
|
||||
local payload = bootstrap(source, security, true)
|
||||
Bridge.Debug(
|
||||
"debug",
|
||||
@@ -1010,7 +1049,8 @@ local function open_phone(source, used_item)
|
||||
return true
|
||||
end
|
||||
|
||||
development_open_handler = open_phone
|
||||
phone_open_handler = open_phone
|
||||
flush_pending_phone_opens()
|
||||
|
||||
function SkyPhone.OpenDeviceForCall(source, imei)
|
||||
local matches = find_device_slots(source, imei)
|
||||
@@ -1033,6 +1073,7 @@ function SkyPhone.OpenDeviceForCall(source, imei)
|
||||
unlocked = security == nil,
|
||||
}
|
||||
end
|
||||
preferred_device_imeis[source] = imei
|
||||
TriggerClientEvent("sky_phone:device:open", source, bootstrap(source))
|
||||
return true
|
||||
end
|
||||
@@ -1045,6 +1086,12 @@ Bridge.Debug(
|
||||
{ always = true }
|
||||
)
|
||||
local usable_registered = Bridge.Inventory.RegisterUsableItem(Config.Phone.Item, open_phone)
|
||||
if not usable_registered then
|
||||
error(("[sky_phone] Inventory '%s' did not register phone item '%s' as usable."):format(
|
||||
tostring(Bridge.Inventory.GetResourceName()),
|
||||
tostring(Config.Phone.Item)
|
||||
))
|
||||
end
|
||||
Bridge.Debug(
|
||||
"debug",
|
||||
"[sky_phone] Usable item registration returned: %s.",
|
||||
@@ -1427,6 +1474,7 @@ AddEventHandler("playerDropped", function()
|
||||
auth_attempts[source] = nil
|
||||
operation_attempts[source] = nil
|
||||
character_device_cache[source] = nil
|
||||
preferred_device_imeis[source] = nil
|
||||
end)
|
||||
|
||||
AddEventHandler("onResourceStop", function(resource_name)
|
||||
@@ -1435,6 +1483,7 @@ AddEventHandler("onResourceStop", function(resource_name)
|
||||
auth_attempts = {}
|
||||
operation_attempts = {}
|
||||
character_device_cache = {}
|
||||
preferred_device_imeis = {}
|
||||
end
|
||||
end)
|
||||
end)
|
||||
|
||||
Reference in New Issue
Block a user