mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-28 23:01:37 +00:00
FIX - harden inventory contracts and resolve phone interaction issues (#20)
* FIX - repair inventory metadata and ESX contracts Use the stable core_inventory metadata setter and validate the complete inventory adapter contract after provider bridges load. Preserve the real ESX configuration error instead of cascading into a missing RegisterUsableItem failure. * FIX - clarify phone opening without SIM * FIX - harden inventory and device contracts Validate SIM number configuration before item handling, preserve QB metadata mutation contracts, and propagate phone-open failures to callers. Extend regression coverage for inventory bridges and device bootstrap errors. * FIX - resolve reported phone interaction issues Stop game-input passthrough while NUI text fields are focused and add an independent hold-to-look option. Propagate SIM number formatting to the frontend, migrate configured currency symbols to utf8mb4, and clarify SkyRide, VaultX, and DarkChat behavior with focused contract coverage. * FIX - detect missing packaged phone UI Validate the generated NUI entrypoint, its referenced assets, and required static media when the resource starts. Print an actionable server-console warning for incomplete source archives, package phone sounds through the manifest, and cover the detection contract with Lua regressions.
This commit is contained in:
+31
-6
@@ -6,7 +6,12 @@ local nui_callbacks = {}
|
||||
local nui_focus = nil
|
||||
local nui_keep_input = nil
|
||||
|
||||
Config = { Phone = { AllowMovement = true } }
|
||||
Config = {
|
||||
Phone = {
|
||||
AllowMovement = true,
|
||||
HoldToLook = { Enabled = true, Control = 19 },
|
||||
},
|
||||
}
|
||||
Bridge = { Debug = function() end }
|
||||
|
||||
function CreateThread(callback)
|
||||
@@ -61,6 +66,7 @@ local function resolve(overrides)
|
||||
cursor_disabled = false,
|
||||
external_game_input = nil,
|
||||
is_open = false,
|
||||
look_passthrough = false,
|
||||
notification_focus = false,
|
||||
payphone_focus = false,
|
||||
sim_picker_open = false,
|
||||
@@ -115,10 +121,10 @@ local typing_phone = resolve({
|
||||
assert(
|
||||
typing_phone.cursor
|
||||
and typing_phone.focused
|
||||
and typing_phone.keep_input
|
||||
and typing_phone.game_input
|
||||
and not typing_phone.keep_input
|
||||
and not typing_phone.game_input
|
||||
and typing_phone.block_game,
|
||||
"a focused phone text input must block GTA controls without hiding the NUI cursor"
|
||||
"a focused phone text input must stop game-input passthrough and retain the NUI cursor"
|
||||
)
|
||||
|
||||
local external_movement_phone = resolve({
|
||||
@@ -142,8 +148,8 @@ local external_movement_typing_phone = resolve({
|
||||
assert(
|
||||
external_movement_typing_phone.cursor
|
||||
and external_movement_typing_phone.focused
|
||||
and external_movement_typing_phone.keep_input
|
||||
and external_movement_typing_phone.game_input
|
||||
and not external_movement_typing_phone.keep_input
|
||||
and not external_movement_typing_phone.game_input
|
||||
and external_movement_typing_phone.block_game,
|
||||
"a focused text input must override an external movement claim"
|
||||
)
|
||||
@@ -177,6 +183,21 @@ assert(
|
||||
"LB noFocus must preserve movement and camera look without retaining the NUI cursor"
|
||||
)
|
||||
|
||||
local movable_look_passthrough_phone = resolve({
|
||||
allow_movement = true,
|
||||
is_open = true,
|
||||
look_passthrough = true,
|
||||
})
|
||||
assert(
|
||||
not movable_look_passthrough_phone.cursor
|
||||
and movable_look_passthrough_phone.focused
|
||||
and movable_look_passthrough_phone.keep_input
|
||||
and movable_look_passthrough_phone.game_input
|
||||
and not movable_look_passthrough_phone.block_game
|
||||
and not movable_look_passthrough_phone.block_look,
|
||||
"HoldToLook must temporarily release the NUI cursor without disabling configured movement"
|
||||
)
|
||||
|
||||
local stationary_cursor_disabled_phone = resolve({
|
||||
cursor_disabled = true,
|
||||
is_open = true,
|
||||
@@ -289,6 +310,10 @@ assert(
|
||||
SkyPhoneFocus.SetPhone(false)
|
||||
SkyPhoneFocus.SetPhone(true)
|
||||
assert(nui_focus.cursor, "closing the phone must clear the previous no-focus claim")
|
||||
SkyPhoneFocus.SetTextInputFocused(true)
|
||||
assert(not nui_keep_input, "runtime text focus must stop game-input passthrough")
|
||||
SkyPhoneFocus.SetTextInputFocused(false)
|
||||
assert(nui_keep_input, "leaving a text input must restore configured phone movement")
|
||||
|
||||
local external_success, external_error = SkyPhoneFocus.SetExternalGameInput("custom_app", true)
|
||||
assert(external_success and external_error == nil and nui_keep_input, "external movement claim must apply")
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
local function reset_bridge(inventory_name, unique_phones, sim_cards_enabled)
|
||||
Config = {
|
||||
Bridge = {
|
||||
Inventory = inventory_name,
|
||||
},
|
||||
Phone = {
|
||||
Unique = unique_phones,
|
||||
},
|
||||
Sim = {
|
||||
Enabled = sim_cards_enabled,
|
||||
},
|
||||
}
|
||||
Bridge = {
|
||||
Debug = function()
|
||||
end,
|
||||
Framework = {
|
||||
GetName = function()
|
||||
return inventory_name == "esx" and "esx" or "qb"
|
||||
end,
|
||||
},
|
||||
Inventory = {},
|
||||
}
|
||||
end
|
||||
|
||||
local function load_inventory_contract(adapter_path)
|
||||
dofile("sky_phone/source/bridge/server/inventory.lua")
|
||||
dofile(adapter_path)
|
||||
dofile("sky_phone/source/bridge/server/inventory_contract.lua")
|
||||
end
|
||||
|
||||
local core_items = {
|
||||
{
|
||||
name = "phone",
|
||||
slot = 15,
|
||||
count = 1,
|
||||
metadata = {},
|
||||
},
|
||||
}
|
||||
local metadata_write
|
||||
local core_inventory = {}
|
||||
|
||||
function core_inventory:getInventory(source)
|
||||
assert(source == 7)
|
||||
return core_items
|
||||
end
|
||||
|
||||
function core_inventory:setMetadata(source, slot, metadata)
|
||||
metadata_write = { source = source, slot = slot, metadata = metadata }
|
||||
core_items[1].metadata = metadata
|
||||
end
|
||||
|
||||
function core_inventory:updateMetadata()
|
||||
error("core bridge must use the stable setMetadata contract")
|
||||
end
|
||||
|
||||
reset_bridge("core", true, true)
|
||||
exports = {
|
||||
core_inventory = core_inventory,
|
||||
}
|
||||
GetResourceState = function(resource_name)
|
||||
return resource_name == "core_inventory" and "started" or "missing"
|
||||
end
|
||||
load_inventory_contract("sky_phone/source/bridge/server/inventory/core.lua")
|
||||
|
||||
assert(Bridge.Inventory.SetSlotMetadata(7, "15", { imei = "123456789012345" }))
|
||||
assert(metadata_write.source == 7)
|
||||
assert(metadata_write.slot == 15, "core metadata slot must be numeric")
|
||||
assert(metadata_write.metadata.imei == "123456789012345")
|
||||
assert(Bridge.Inventory.GetSlot(7, 15).metadata.imei == "123456789012345")
|
||||
|
||||
local qb_item = {
|
||||
name = "phone",
|
||||
slot = 2,
|
||||
amount = 1,
|
||||
info = { owner = "kept" },
|
||||
}
|
||||
local qb_write_mode = "persist"
|
||||
local qb_inventory = {}
|
||||
|
||||
function qb_inventory:GetItemBySlot(source, slot)
|
||||
assert(source == 9 and slot == 2)
|
||||
return qb_item
|
||||
end
|
||||
|
||||
function qb_inventory:GetItemsByName(source, item_name)
|
||||
assert(source == 9 and item_name == "phone")
|
||||
return qb_item and { qb_item } or {}
|
||||
end
|
||||
|
||||
function qb_inventory:SetItemData()
|
||||
error("qb bridge must not rely on an unverified direct metadata setter")
|
||||
end
|
||||
|
||||
function qb_inventory:RemoveItem(source, item_name, amount, slot, reason)
|
||||
assert(source == 9 and item_name == "phone" and amount == 1 and slot == 2)
|
||||
assert(reason == "sky_phone:metadata-update")
|
||||
if not qb_item then
|
||||
return false
|
||||
end
|
||||
qb_item = nil
|
||||
return true
|
||||
end
|
||||
|
||||
function qb_inventory:AddItem(source, item_name, amount, slot, info, reason)
|
||||
assert(source == 9 and item_name == "phone" and amount == 1 and slot == 2)
|
||||
assert(reason == "sky_phone:metadata-update")
|
||||
qb_item = {
|
||||
name = item_name,
|
||||
slot = slot,
|
||||
amount = amount,
|
||||
info = qb_write_mode == "drop_metadata" and {} or info,
|
||||
}
|
||||
return true
|
||||
end
|
||||
|
||||
function qb_inventory:CanAddItem()
|
||||
return true
|
||||
end
|
||||
|
||||
reset_bridge("qb", true, true)
|
||||
exports = {
|
||||
["qb-inventory"] = qb_inventory,
|
||||
}
|
||||
GetResourceState = function(resource_name)
|
||||
return resource_name == "qb-inventory" and "started" or "missing"
|
||||
end
|
||||
load_inventory_contract("sky_phone/source/bridge/server/inventory/qb.lua")
|
||||
|
||||
assert(Bridge.Inventory.SetSlotMetadata(9, 2, { owner = "kept", imei = "123456789012345" }))
|
||||
assert(qb_item.info.owner == "kept" and qb_item.info.imei == "123456789012345")
|
||||
|
||||
qb_write_mode = "drop_metadata"
|
||||
assert(not Bridge.Inventory.SetSlotMetadata(9, 2, { owner = "kept", imei = "999999999999999" }))
|
||||
assert(qb_item and qb_item.slot == 2, "QB metadata verification must leave the item in its exact slot")
|
||||
|
||||
local function create_esx()
|
||||
local usable_items = {}
|
||||
local player = {}
|
||||
|
||||
function player.getInventoryItem(item_name)
|
||||
return {
|
||||
name = item_name,
|
||||
count = 1,
|
||||
}
|
||||
end
|
||||
|
||||
local esx = {}
|
||||
|
||||
function esx.GetPlayerFromId(source)
|
||||
assert(source == 11)
|
||||
return player
|
||||
end
|
||||
|
||||
function esx.RegisterUsableItem(item_name, callback)
|
||||
usable_items[item_name] = callback
|
||||
end
|
||||
|
||||
return esx, usable_items
|
||||
end
|
||||
|
||||
local esx, usable_items = create_esx()
|
||||
reset_bridge("esx", false, false)
|
||||
exports = {
|
||||
es_extended = {
|
||||
getSharedObject = function()
|
||||
return esx
|
||||
end,
|
||||
},
|
||||
}
|
||||
GetResourceState = function(resource_name)
|
||||
return resource_name == "es_extended" and "started" or "missing"
|
||||
end
|
||||
load_inventory_contract("sky_phone/source/bridge/server/inventory/esx.lua")
|
||||
|
||||
local used_item
|
||||
assert(Bridge.Inventory.RegisterUsableItem("phone", function(source, item)
|
||||
assert(source == 11)
|
||||
used_item = item
|
||||
end))
|
||||
usable_items.phone(11)
|
||||
assert(used_item.name == "phone")
|
||||
assert(used_item.slot == "phone")
|
||||
assert(used_item.count == 1)
|
||||
|
||||
esx = create_esx()
|
||||
reset_bridge("esx", true, true)
|
||||
exports = {
|
||||
es_extended = {
|
||||
getSharedObject = function()
|
||||
return esx
|
||||
end,
|
||||
},
|
||||
}
|
||||
load_inventory_contract("sky_phone/source/bridge/server/inventory/esx.lua")
|
||||
|
||||
local ok, configuration_error = pcall(Bridge.Inventory.RegisterUsableItem, "phone", function()
|
||||
end)
|
||||
assert(not ok)
|
||||
assert(configuration_error:find("Config.Phone.Unique = false", 1, true))
|
||||
assert(configuration_error:find("Config.Sim.Enabled = false", 1, true))
|
||||
|
||||
local manifest_file = assert(io.open("sky_phone/fxmanifest.lua", "rb"))
|
||||
local manifest = manifest_file:read("*a")
|
||||
manifest_file:close()
|
||||
local adapters = assert(manifest:find("source/bridge/server/inventory/*.lua", 1, true))
|
||||
local contract = assert(manifest:find("source/bridge/server/inventory_contract.lua", 1, true))
|
||||
assert(adapters < contract, "inventory contract must load after provider adapters")
|
||||
|
||||
print("inventory bridge regression checks passed")
|
||||
@@ -0,0 +1,64 @@
|
||||
local source_path = "sky_phone/source/server/nui_build_check.lua"
|
||||
local original_print = print
|
||||
|
||||
local function run_check(files)
|
||||
local output = {}
|
||||
|
||||
print = function(message)
|
||||
output[#output + 1] = tostring(message)
|
||||
end
|
||||
GetCurrentResourceName = function()
|
||||
return "sky_phone"
|
||||
end
|
||||
LoadResourceFile = function(resource_name, path)
|
||||
assert(resource_name == "sky_phone", "NUI build check must inspect its own resource")
|
||||
return files[path]
|
||||
end
|
||||
|
||||
dofile(source_path)
|
||||
return table.concat(output, "\n")
|
||||
end
|
||||
|
||||
local valid_files = {
|
||||
["source/html/index.html"] = [[
|
||||
<link rel="stylesheet" href="./assets/sky-index.css">
|
||||
<script type="module" src="./assets/sky-index.js"></script>
|
||||
]],
|
||||
["source/html/assets/sky-index.css"] = "body{}",
|
||||
["source/html/assets/sky-index.js"] = "console.log('ready')",
|
||||
["source/html/img/custom-app.svg"] = "<svg></svg>",
|
||||
["source/html/sounds/button.mp3"] = "audio",
|
||||
}
|
||||
|
||||
assert(run_check(valid_files) == "", "a complete NUI build must not print a warning")
|
||||
|
||||
local missing_build_output = run_check({})
|
||||
assert(missing_build_output:find("SKY PHONE UI BUILD IS MISSING OR INCOMPLETE", 1, true))
|
||||
assert(missing_build_output:find("source/html/index.html", 1, true))
|
||||
assert(missing_build_output:find("source/html/assets/*", 1, true))
|
||||
assert(missing_build_output:find("source/html/img/custom-app.svg", 1, true))
|
||||
assert(missing_build_output:find("source/html/sounds/button.mp3", 1, true))
|
||||
assert(missing_build_output:find("not GitHub's automatic source archive", 1, true))
|
||||
assert(missing_build_output:find("build_frontend.bat", 1, true))
|
||||
|
||||
local missing_asset_files = {}
|
||||
for path, content in pairs(valid_files) do
|
||||
missing_asset_files[path] = content
|
||||
end
|
||||
missing_asset_files["source/html/assets/sky-index.js"] = nil
|
||||
|
||||
local missing_asset_output = run_check(missing_asset_files)
|
||||
assert(missing_asset_output:find("source/html/assets/sky-index.js", 1, true))
|
||||
assert(not missing_asset_output:find("source/html/assets/sky-index.css", 1, true))
|
||||
|
||||
local query_asset_files = {}
|
||||
for path, content in pairs(valid_files) do
|
||||
query_asset_files[path] = content
|
||||
end
|
||||
query_asset_files["source/html/index.html"] = [[
|
||||
<script type="module" src="./assets/sky-index.js?v=1#entry"></script>
|
||||
]]
|
||||
assert(run_check(query_asset_files) == "", "asset query strings and fragments must be ignored")
|
||||
|
||||
print = original_print
|
||||
io.write("Sky Phone NUI build check tests passed\n")
|
||||
@@ -1,5 +1,6 @@
|
||||
local registered_callbacks = {}
|
||||
local migration_callbacks = {}
|
||||
local event_handlers = {}
|
||||
|
||||
Bridge = {
|
||||
Callbacks = {
|
||||
@@ -75,8 +76,10 @@ json = {
|
||||
end,
|
||||
}
|
||||
|
||||
function AddEventHandler(_, callback)
|
||||
function AddEventHandler(name, callback)
|
||||
assert(type(callback) == "function")
|
||||
event_handlers[name] = event_handlers[name] or {}
|
||||
event_handlers[name][#event_handlers[name] + 1] = callback
|
||||
end
|
||||
|
||||
function TriggerClientEvent()
|
||||
@@ -177,6 +180,106 @@ for _, callback_name in ipairs({
|
||||
assert(response.success == false and response.error == "device_not_open", "callback not bound to core: " .. callback_name)
|
||||
end
|
||||
|
||||
local phone_item = {
|
||||
name = "phone",
|
||||
slot = 4,
|
||||
amount = 1,
|
||||
metadata = { imei = "123456789012345" },
|
||||
}
|
||||
local opened_event
|
||||
local device_error
|
||||
local hide_phone_during_prepare = false
|
||||
|
||||
Bridge.Framework.GetIdentifier = function(source)
|
||||
assert(source == 1)
|
||||
return "license:test-player"
|
||||
end
|
||||
Bridge.Framework.GetFirstname = function()
|
||||
return "Test"
|
||||
end
|
||||
Bridge.Framework.GetLastname = function()
|
||||
return "Player"
|
||||
end
|
||||
Bridge.Inventory.GetSlot = function(source, slot)
|
||||
assert(source == 1 and slot == phone_item.slot)
|
||||
return phone_item
|
||||
end
|
||||
Bridge.Inventory.GetSlotsWithItem = function(source, item_name)
|
||||
assert(source == 1 and item_name == Config.Phone.Item)
|
||||
if hide_phone_during_prepare == true then
|
||||
return {}
|
||||
end
|
||||
return { phone_item }
|
||||
end
|
||||
Bridge.Inventory.SetSlotMetadata = function()
|
||||
error("existing phone metadata must not be rewritten")
|
||||
end
|
||||
Bridge.Database.Query = function(query)
|
||||
if query:find("FROM `sky_phone_devices` d", 1, true) then
|
||||
return {
|
||||
{
|
||||
imei = phone_item.metadata.imei,
|
||||
device_name = Config.Phone.DeviceName,
|
||||
account_id = nil,
|
||||
sim_id = nil,
|
||||
},
|
||||
}
|
||||
end
|
||||
return {}
|
||||
end
|
||||
SkyPhoneImei.IsValid = function(imei)
|
||||
return imei == phone_item.metadata.imei
|
||||
end
|
||||
SkyPhoneSim = {
|
||||
PrepareDevice = function(source, slot, imei)
|
||||
assert(source == 1 and slot == phone_item and imei == phone_item.metadata.imei)
|
||||
if hide_phone_during_prepare == "next" then
|
||||
hide_phone_during_prepare = true
|
||||
end
|
||||
return true
|
||||
end,
|
||||
}
|
||||
SkyPhoneNotes = {
|
||||
List = function()
|
||||
return {}
|
||||
end,
|
||||
}
|
||||
SkyPhoneMemos = {
|
||||
List = function()
|
||||
return {}
|
||||
end,
|
||||
}
|
||||
SkyPhoneCompanies = {
|
||||
ClearCallAvailability = function()
|
||||
end,
|
||||
}
|
||||
TriggerClientEvent = function(event_name, source, payload)
|
||||
if event_name == "sky_phone:device:open" then
|
||||
opened_event = { source = source, payload = payload }
|
||||
elseif event_name == "sky_phone:device:error" then
|
||||
device_error = { source = source, error = payload }
|
||||
end
|
||||
end
|
||||
|
||||
for _, callback in ipairs(event_handlers.onServerResourceStart or {}) do
|
||||
callback("sky_phone")
|
||||
end
|
||||
|
||||
local no_sim_open = registered_callbacks["sky_phone:device:open-request"](1, {})
|
||||
assert(no_sim_open.success == true, "a phone item without a SIM must still open")
|
||||
assert(opened_event and opened_event.source == 1, "no-SIM open must reach the client")
|
||||
assert(opened_event.payload.device.imei == phone_item.metadata.imei)
|
||||
assert(opened_event.payload.device.sim == nil, "no-SIM bootstrap must keep device.sim nullable")
|
||||
|
||||
opened_event = nil
|
||||
device_error = nil
|
||||
hide_phone_during_prepare = "next"
|
||||
local lost_phone_open = registered_callbacks["sky_phone:device:open-request"](1, {})
|
||||
assert(lost_phone_open.success == false, "bootstrap ownership loss must fail the open request")
|
||||
assert(lost_phone_open.error == "device_not_owned", "bootstrap ownership loss must return its error code")
|
||||
assert(opened_event == nil, "bootstrap ownership loss must not open the NUI")
|
||||
assert(device_error and device_error.error == "device_not_owned", "bootstrap ownership loss must notify the client")
|
||||
|
||||
local manifest_file = assert(io.open("sky_phone/fxmanifest.lua", "rb"))
|
||||
local manifest = manifest_file:read("*a")
|
||||
manifest_file:close()
|
||||
|
||||
@@ -9,6 +9,10 @@ assert(SkyPhoneSimNumber.NormalizeService("9-1-1", 10) == "911", "formatted serv
|
||||
assert(SkyPhoneSimNumber.NormalizeService("0", 10) == "0", "single-digit service numbers must normalize")
|
||||
assert(SkyPhoneSimNumber.NormalizeService("", 10) == nil, "empty service numbers must fail")
|
||||
assert(SkyPhoneSimNumber.NormalizeService("12345678901", 10) == nil, "long service numbers must fail")
|
||||
assert(SkyPhoneSimNumber.ValidateConfiguration(9, "555"), "numeric SIM prefixes must be valid")
|
||||
assert(SkyPhoneSimNumber.FromEntropy("550e8400-e29b-41d4-a716-446655440000", 9, "555") == "555550840")
|
||||
assert(not SkyPhoneSimNumber.ValidateConfiguration(9, "555-"), "formatted SIM prefixes must be rejected")
|
||||
assert(SkyPhoneSimNumber.FromEntropy("550e8400-e29b-41d4-a716-446655440000", 9, "555-") == nil)
|
||||
|
||||
local attempts = 0
|
||||
local reserved = SkyPhoneSimNumber.Reserve(function()
|
||||
|
||||
Reference in New Issue
Block a user