Files
sky_phone-sky-systems/tests/custom_app_compat_server.lua
Leon.Schmidt 30559048fd FIX - complete LB custom app compatibility (#11)
* FIX - preserve LB custom app lifecycle

* FIX - reset LB export aliases before startup

* FIX - report competing custom app providers

* FIX - identify LB app frames as live NUI

* FIX - complete LB custom app compatibility

* FIX - provide LB PicChat runtime exports

* FIX - route LB PicChat through Sky Phone

* FIX - return cached LB equipped phone number

* FIX - harden PicChat compatibility migration

* ENH - complete modular phone provider and Creator APIs

* DOC - document the Sky Phone Creator API

---------

Co-authored-by: DerEchteAlec <bycraky@gmail.com>
Co-authored-by: DerEchteAlec <alec.schitzkat@luwan.io>
2026-08-20 18:19:16 +02:00

464 lines
17 KiB
Lua

local event_handlers = {}
local migration_callbacks = {}
local registered_event_handlers = {}
local registered_exports = {}
local sent_events = {}
local triggered_events = {}
local invoking_resource = nil
local seam_calls = {
equipped_number = {},
formatted_number = {},
source_from_number = {},
}
local directory_calls = {
online_by_identifier = {},
online_by_imei = {},
online_by_source = {},
stored_by_imei = {},
stored_by_phone_number = {},
}
exports = setmetatable({}, {
__call = function(_, export_name, handler)
registered_exports[export_name] = handler
end,
})
function GetCurrentResourceName()
return "sky_phone"
end
function GetInvokingResource()
return invoking_resource
end
function GetGameTimer()
return 5000
end
function RegisterNetEvent(event_name, handler)
event_handlers[event_name] = handler
end
function AddEventHandler(event_name, handler)
local handlers = registered_event_handlers[event_name] or {}
handlers[#handlers + 1] = handler
registered_event_handlers[event_name] = handlers
end
function TriggerClientEvent(event_name, target, ...)
sent_events[#sent_events + 1] = {
arguments = { ... },
event_name = event_name,
target = target,
}
end
function TriggerEvent(event_name, ...)
triggered_events[#triggered_events + 1] = {
arguments = { ... },
event_name = event_name,
}
end
dofile("sky_phone/source/shared/imei.lua")
dofile("sky_phone/source/bridge/shared.lua")
dofile("sky_phone/source/bridge/phones/shared.lua")
dofile("sky_phone/source/bridge/phones/shared/lb.lua")
dofile("sky_phone/source/bridge/phones/shared/seventeen.lua")
dofile("sky_phone/source/bridge/phones/shared/high.lua")
dofile("sky_phone/source/bridge/phones/shared/quasar.lua")
dofile("sky_phone/source/bridge/phones/shared/yseries.lua")
Bridge.Database = {
AfterMigration = function(_, callback)
migration_callbacks[#migration_callbacks + 1] = callback
end,
}
local phone_core = {
FormatNumber = function(value)
seam_calls.formatted_number[#seam_calls.formatted_number + 1] = value
return "formatted:" .. value
end,
GetEquippedPhoneNumber = function(player)
seam_calls.equipped_number[#seam_calls.equipped_number + 1] = player
if player == 42 then
return "5550000042"
end
if player == 73 then
return "5550000073"
end
if player == "char1:phone-owner" then
return "5550000099"
end
return nil
end,
GetSourceFromNumber = function(phone_number)
seam_calls.source_from_number[#seam_calls.source_from_number + 1] = phone_number
if tostring(phone_number) == "5550000073" then
return 73
end
return nil
end,
}
SkyPhoneDeviceDirectory = {
GetOnlineByIdentifier = function(identifier)
directory_calls.online_by_identifier[#directory_calls.online_by_identifier + 1] = identifier
if identifier == "char1:directory-owner" then
return { source = 85 }
end
return nil, "player_unavailable"
end,
GetOnlineByImei = function(phone_imei)
directory_calls.online_by_imei[#directory_calls.online_by_imei + 1] = phone_imei
if phone_imei == "123456789012347" then
return { source = 84 }
end
return nil, "player_unavailable"
end,
GetOnlineBySource = function(player_source)
directory_calls.online_by_source[#directory_calls.online_by_source + 1] = player_source
if player_source == 88 then
return { imei = "523456789012343" }
end
return nil, "player_unavailable"
end,
GetStoredDeviceByImei = function(phone_imei)
directory_calls.stored_by_imei[#directory_calls.stored_by_imei + 1] = phone_imei
if phone_imei == "223456789012346" then
return { phoneNumber = "5550000086" }
end
return nil, "device_not_found"
end,
GetStoredDeviceByPhoneNumber = function(phone_number)
directory_calls.stored_by_phone_number[#directory_calls.stored_by_phone_number + 1]
= phone_number
if phone_number == "5550000087" then
return { imei = "423456789012344" }
end
return nil, "device_not_found"
end,
}
dofile("sky_phone/source/bridge/phones/server/core.lua")
dofile("sky_phone/source/bridge/phones/server/lb.lua")
dofile("sky_phone/source/bridge/phones/server/seventeen.lua")
dofile("sky_phone/source/bridge/phones/server/high.lua")
dofile("sky_phone/source/bridge/phones/server/quasar.lua")
dofile("sky_phone/source/bridge/phones/server/yseries.lua")
dofile("sky_phone/source/bridge/phones/server/lifecycle.lua")
assert(SkyPhone == nil, "all provider adapters must load before the phone core is ready")
assert(
SkyPhoneCompatibilityServer.Phone == nil,
"provider adapters must not cache the phone core before migration"
)
SkyPhone = phone_core
for index = 1, #migration_callbacks do
migration_callbacks[index]()
end
assert(
SkyPhoneCompatibilityServer.Phone == phone_core,
"all provider adapters must bind the migrated phone core"
)
local function get_alias_export(resource_name, export_name)
local alias_handlers = registered_event_handlers[
("__cfx_export_%s_%s"):format(resource_name, export_name)
]
assert(alias_handlers and #alias_handlers == 1, ("Missing %s:%s export alias"):format(
resource_name,
export_name
))
local export_handler
local export_callback = setmetatable({
__cfx_functionReference = "test-export-callback",
}, {
__call = function(_, handler)
export_handler = handler
end,
})
alias_handlers[1](export_callback)
assert(type(export_handler) == "function", ("Invalid %s:%s export alias"):format(
resource_name,
export_name
))
return export_handler
end
local function invoke_event_handlers(event_name, ...)
local handlers = registered_event_handlers[event_name]
assert(handlers and #handlers > 0, ("Missing %s event handler"):format(event_name))
for index = 1, #handlers do
handlers[index](...)
end
end
assert(triggered_events[1].event_name == "onServerResourceStop", "LB server export caches must be invalidated")
assert(triggered_events[1].arguments[1] == "lb-phone", "LB server stop must use the provided resource name")
assert(triggered_events[2].event_name == "onResourceStop", "LB generic stop listeners must be notified")
assert(triggered_events[3].event_name == "onServerResourceStart", "LB server start listeners must be notified")
assert(triggered_events[4].event_name == "onResourceStart", "LB generic start listeners must be notified")
local lifecycle_event_offset = #triggered_events
invoke_event_handlers("sky_phone:server:phoneNumberChanged", 42, "5550000042")
invoke_event_handlers("sky_phone:server:phoneNumberGenerated", 42, "5550000043")
invoke_event_handlers("sky_phone:server:factoryReset", 42, "5550000044")
invoke_event_handlers(
"sky_phone:server:galleryMediaDeleted",
42,
"5550000042",
"https://media.example/deleted.png"
)
assert(
triggered_events[lifecycle_event_offset + 1].event_name == "lb-phone:numberChanged",
"LB number-change observer event must be preserved"
)
assert(
triggered_events[lifecycle_event_offset + 2].event_name == "lb-phone:phoneNumberGenerated",
"LB number-generation observer event must be preserved"
)
assert(
triggered_events[lifecycle_event_offset + 3].event_name == "lb-phone:factoryReset",
"LB factory-reset observer event must be preserved"
)
assert(
triggered_events[lifecycle_event_offset + 4].event_name == "lb-phone:deletedFromGallery",
"LB gallery-deletion observer event must be preserved"
)
assert(
triggered_events[lifecycle_event_offset + 4].arguments[3]
== "https://media.example/deleted.png",
"LB gallery observer must preserve the deleted link"
)
local lb_get_equipped_number = get_alias_export("lb-phone", "GetEquippedPhoneNumber")
assert(
lb_get_equipped_number(42) == "5550000042",
"LB source lookup must use the equipped-number seam"
)
assert(
lb_get_equipped_number("char1:phone-owner") == "5550000099",
"LB identifier lookup must preserve its documented string contract"
)
assert(next(registered_exports) == nil, "Vendor exports must only be registered as provider aliases")
local seventeen_get_number_from_player = get_alias_export("17mov_Phone", "GetNumberFromPlayer")
local seventeen_get_number_from_identifier = get_alias_export(
"17mov_Phone",
"GetNumberFromIdentifier"
)
local seventeen_get_source_from_number = get_alias_export(
"17mov_Phone",
"GetPlayerSrcFromActiveNumber"
)
assert(
seventeen_get_number_from_player(42) == "5550000042",
"17Movement source lookup must use the equipped-number seam"
)
assert(
seventeen_get_number_from_identifier("char1:phone-owner") == "5550000099",
"17Movement identifier lookup must use the equipped-number seam"
)
assert(
seventeen_get_source_from_number("5550000073") == 73,
"17Movement active-number lookup must use the source seam"
)
local equipped_calls_before_invalid = #seam_calls.equipped_number
assert(seventeen_get_number_from_player("42") == nil, "17Movement must reject string sources")
assert(
seventeen_get_number_from_identifier(" ") == nil,
"17Movement must reject blank identifiers"
)
assert(
#seam_calls.equipped_number == equipped_calls_before_invalid,
"Invalid 17Movement identity arguments must not reach the phone core"
)
local source_calls_before_invalid = #seam_calls.source_from_number
assert(
seventeen_get_source_from_number({}) == nil,
"17Movement must reject non-scalar phone numbers"
)
assert(
#seam_calls.source_from_number == source_calls_before_invalid,
"Invalid 17Movement phone numbers must not reach the phone core"
)
local high_get_player_phone_number = get_alias_export("high-phone", "getPlayerPhoneNumber")
local high_format_number = get_alias_export("high-phone", "formatNumber")
assert(
high_get_player_phone_number(42) == "5550000042",
"High source lookup must use the equipped-number seam"
)
assert(
high_format_number("5550000042") == "formatted:5550000042",
"High number formatting must use the configured formatting seam"
)
local formatted_calls_before_invalid = #seam_calls.formatted_number
assert(high_format_number(5550000042) == nil, "High formatNumber must require a string")
assert(
#seam_calls.formatted_number == formatted_calls_before_invalid,
"Invalid High numbers must not reach the formatter"
)
local quasar_get_current_phone_number = get_alias_export(
"qs-smartphone",
"GetCurrentPhoneNumber"
)
assert(
quasar_get_current_phone_number(42) == "5550000042",
"Quasar source lookup must use the equipped-number seam"
)
assert(quasar_get_current_phone_number(0) == nil, "Quasar must reject invalid sources")
local yseries_get_source = get_alias_export("yseries", "GetPlayerSourceIdByPhoneNumber")
local yseries_get_number = get_alias_export("yseries", "GetPhoneNumberBySourceId")
local yseries_get_source_by_imei = get_alias_export(
"yseries",
"GetPlayerSourceIdByPhoneImei"
)
local yseries_get_source_by_identifier = get_alias_export(
"yseries",
"GetPlayerSourceIdByIdentifier"
)
local yseries_get_number_by_imei = get_alias_export("yseries", "GetPhoneNumberByImei")
local yseries_get_imei_by_number = get_alias_export(
"yseries",
"GetPhoneImeiByPhoneNumber"
)
local yseries_get_imei_by_source = get_alias_export("yseries", "GetPhoneImeiBySourceId")
assert(
yseries_get_source(5550000073) == 73,
"YSeries number lookup must accept its documented scalar number form"
)
assert(
yseries_get_number(73) == "5550000073",
"YSeries source lookup must use the equipped-number seam"
)
assert(yseries_get_source(math.huge) == nil, "YSeries must reject non-finite phone numbers")
assert(yseries_get_number(73.5) == nil, "YSeries must reject fractional sources")
assert(
yseries_get_source_by_imei("123456789012347") == 84,
"YSeries IMEI-to-source lookup must use the online device directory"
)
assert(
yseries_get_source_by_identifier(" char1:directory-owner ") == 85,
"YSeries identifier-to-source lookup must use the normalized online directory identity"
)
assert(
yseries_get_number_by_imei("223456789012346") == "5550000086",
"YSeries IMEI-to-number lookup must use the stored device directory"
)
assert(
yseries_get_imei_by_number("5550000087") == "423456789012344",
"YSeries number-to-IMEI lookup must use the stored device directory"
)
assert(
yseries_get_imei_by_source(88) == "523456789012343",
"YSeries source-to-IMEI lookup must use the online device directory"
)
assert(
yseries_get_source_by_imei("999999999999994") == nil,
"YSeries IMEI-to-source lookup must preserve a missing directory result as nil"
)
assert(
yseries_get_source_by_identifier("char1:missing") == nil,
"YSeries identifier-to-source lookup must preserve a missing directory result as nil"
)
assert(
yseries_get_number_by_imei("888888888888885") == nil,
"YSeries IMEI-to-number lookup must preserve a missing directory result as nil"
)
assert(
yseries_get_imei_by_number("5550000999") == nil,
"YSeries number-to-IMEI lookup must preserve a missing directory result as nil"
)
assert(
yseries_get_imei_by_source(999) == nil,
"YSeries source-to-IMEI lookup must preserve a missing directory result as nil"
)
local directory_calls_before_invalid = {
online_by_identifier = #directory_calls.online_by_identifier,
online_by_imei = #directory_calls.online_by_imei,
online_by_source = #directory_calls.online_by_source,
stored_by_imei = #directory_calls.stored_by_imei,
stored_by_phone_number = #directory_calls.stored_by_phone_number,
}
assert(yseries_get_source_by_imei(123456789012345) == nil, "YSeries must reject numeric IMEIs")
assert(
yseries_get_source_by_identifier(string.rep("x", 81)) == nil,
"YSeries must reject overlong identifiers"
)
assert(yseries_get_number_by_imei("invalid-imei") == nil, "YSeries must reject invalid IMEIs")
assert(
yseries_get_imei_by_number(5550000087) == nil,
"YSeries number-to-IMEI must require its documented string argument"
)
assert(yseries_get_imei_by_source(88.5) == nil, "YSeries must reject fractional sources")
assert(
#directory_calls.online_by_imei == directory_calls_before_invalid.online_by_imei
and #directory_calls.online_by_identifier
== directory_calls_before_invalid.online_by_identifier
and #directory_calls.stored_by_imei == directory_calls_before_invalid.stored_by_imei
and #directory_calls.stored_by_phone_number
== directory_calls_before_invalid.stored_by_phone_number
and #directory_calls.online_by_source == directory_calls_before_invalid.online_by_source,
"Invalid YSeries identity arguments must not reach the device directory"
)
local high_add_application = get_alias_export("high-phone", "addApplication")
invoking_resource = "high_server_app"
local add_success, add_error = high_add_application("bankingv2", {
externalUrl = "@high_server_app/ui/index.html",
}, {
en = {
label = "Banking",
description = "Banking app",
},
})
assert(add_success and add_error == nil, "High server addApplication must register")
assert(#sent_events == 1, "High server registration must broadcast an update")
assert(sent_events[1].event_name == "sky_phone:compat:high:client:syncApplication", "High sync event must be used")
assert(sent_events[1].arguments[1] == "high_server_app", "High sync must preserve the owner")
invoking_resource = "other_server_app"
local duplicate_success, duplicate_error = high_add_application("bankingv2", {
externalUrl = "@other_server_app/ui/index.html",
})
assert(not duplicate_success and duplicate_error == "duplicate_app_id", "High cross-owner replacement must fail")
source = 42
event_handlers["sky_phone:compat:high:server:requestSnapshot"]()
assert(#sent_events == 2, "High snapshot request must emit one response")
assert(sent_events[2].event_name == "sky_phone:compat:high:client:replaceSnapshot", "High snapshot event must be used")
assert(sent_events[2].target == 42, "High snapshot must target only the requester")
assert(#sent_events[2].arguments[1] == 1, "High snapshot must include the registered app")
event_handlers["sky_phone:compat:high:server:requestSnapshot"]()
assert(#sent_events == 2, "High snapshot rate limiting must remain unchanged")
invoke_event_handlers("onResourceStop", "high_server_app")
assert(#sent_events == 3, "Stopping a High app owner must broadcast one removal")
assert(
sent_events[3].event_name == "sky_phone:compat:high:client:removeApplication",
"High owner cleanup must preserve the removal event"
)
assert(sent_events[3].arguments[2] == "bankingv2", "High owner cleanup must remove its app")
local shutdown_event_offset = #triggered_events
invoke_event_handlers("onResourceStop", "sky_phone")
assert(
triggered_events[shutdown_event_offset + 1].event_name == "onServerResourceStop",
"Sky Phone shutdown must invalidate the LB server cache"
)
assert(
triggered_events[shutdown_event_offset + 2].event_name == "onResourceStop",
"Sky Phone shutdown must notify LB generic stop listeners"
)
print("Custom app compatibility server tests passed")