mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-01 08:01:48 +00:00
42ac955052
Add the Companies app, resilient call handling, custom app registry, vendor compatibility, storage policies, frontend bridge, tests, documentation, config, locale, and SQL migrations.
1397 lines
42 KiB
Lua
1397 lines
42 KiB
Lua
local COMPATIBILITY_MAX_BYTES = 8192
|
|
local JSON_MAX_DEPTH = 12
|
|
local JSON_MAX_VALUES = 4096
|
|
local MAX_QUEUED_MESSAGES = 64
|
|
local NOTIFICATION_TEXT_MAX_LENGTH = 2000
|
|
local NOTIFICATION_TITLE_MAX_LENGTH = 160
|
|
|
|
local NOTIFICATION_SOUNDS = {
|
|
chime = true,
|
|
signal = true,
|
|
soft = true,
|
|
}
|
|
|
|
local HOOK_NAMES = {
|
|
onClose = true,
|
|
onInstall = true,
|
|
onOpen = true,
|
|
onReady = true,
|
|
}
|
|
|
|
local apps_by_id = {}
|
|
local app_ids_by_adapter = {}
|
|
local app_ids_by_owner = {}
|
|
local current_resource = GetCurrentResourceName()
|
|
local active_app_id = nil
|
|
local active_app_ready = false
|
|
local phone_open = false
|
|
local queued_messages = {}
|
|
local default_icon_url = ("https://cfx-nui-%s/custom_apps/_sdk/default-icon.svg"):format(current_resource)
|
|
|
|
local function trim(value)
|
|
return value:match("^%s*(.-)%s*$")
|
|
end
|
|
|
|
local function validate_owner_resource(owner_resource, allow_stopping)
|
|
if type(owner_resource) ~= "string"
|
|
or #owner_resource == 0
|
|
or #owner_resource > 64
|
|
or not owner_resource:match("^[%w][%w._-]*$")
|
|
then
|
|
return false, "invalid_owner_resource"
|
|
end
|
|
|
|
local state = GetResourceState(owner_resource)
|
|
if state == "started" or state == "starting" or (allow_stopping and state == "stopping") then
|
|
return true
|
|
end
|
|
|
|
return false, "owner_resource_not_running"
|
|
end
|
|
|
|
local function resolve_localized_text(value)
|
|
if type(value) == "string" then
|
|
return value
|
|
end
|
|
|
|
local locale = Config.Bridge.Locale:lower():gsub("_", "-")
|
|
if value[locale] then
|
|
return value[locale]
|
|
end
|
|
|
|
local base_locale = locale:match("^([a-z]+)-")
|
|
if base_locale and value[base_locale] then
|
|
return value[base_locale]
|
|
end
|
|
if value.en then
|
|
return value.en
|
|
end
|
|
if value.de then
|
|
return value.de
|
|
end
|
|
|
|
local locales = {}
|
|
for available_locale in pairs(value) do
|
|
locales[#locales + 1] = available_locale
|
|
end
|
|
table.sort(locales)
|
|
return value[locales[1]]
|
|
end
|
|
|
|
local function is_allowed_remote_origin(url)
|
|
local origin = url:match("^(https://[^/%?#]+)")
|
|
if not origin then
|
|
return false
|
|
end
|
|
|
|
local allowed_origins = Config.CustomApps.AllowRemoteOrigins
|
|
if allowed_origins[origin] then
|
|
return true
|
|
end
|
|
|
|
for index = 1, #allowed_origins do
|
|
if allowed_origins[index] == origin then
|
|
return true
|
|
end
|
|
end
|
|
|
|
return false
|
|
end
|
|
|
|
local function validate_asset_path(path)
|
|
if #path == 0
|
|
or #path > 1024
|
|
or path:find("\\", 1, true)
|
|
or path:find("[%c]")
|
|
or path:lower():find("%2e", 1, true)
|
|
then
|
|
return false
|
|
end
|
|
|
|
local path_only = path:match("^[^?#]+") or path
|
|
for segment in path_only:gmatch("[^/]+") do
|
|
if segment == "." or segment == ".." then
|
|
return false
|
|
end
|
|
end
|
|
|
|
return true
|
|
end
|
|
|
|
local function normalize_asset_url(value, original_owner, adapter_resource, required)
|
|
if value == nil and not required then
|
|
return nil
|
|
end
|
|
if type(value) ~= "string" then
|
|
return nil, "invalid_asset_url"
|
|
end
|
|
|
|
local url = trim(value)
|
|
if not validate_asset_path(url) then
|
|
return nil, "invalid_asset_url"
|
|
end
|
|
|
|
local allowed_owners = {
|
|
[original_owner] = true,
|
|
}
|
|
if adapter_resource then
|
|
allowed_owners[adapter_resource] = true
|
|
end
|
|
|
|
local cfx_owner = url:match("^https://cfx%-nui%-([^/]+)/")
|
|
if cfx_owner then
|
|
if not allowed_owners[cfx_owner] then
|
|
return nil, "asset_owner_mismatch"
|
|
end
|
|
return url
|
|
end
|
|
|
|
local nui_owner, nui_path = url:match("^nui://([^/]+)/(.+)$")
|
|
if nui_owner then
|
|
if not allowed_owners[nui_owner] then
|
|
return nil, "asset_owner_mismatch"
|
|
end
|
|
return ("https://cfx-nui-%s/%s"):format(nui_owner, nui_path)
|
|
end
|
|
|
|
if url:sub(1, 8) == "https://" then
|
|
if not is_allowed_remote_origin(url) then
|
|
return nil, "remote_origin_not_allowed"
|
|
end
|
|
return url
|
|
end
|
|
|
|
if url:match("^[%a][%w+.-]*:") or url:sub(1, 2) == "//" or url:sub(1, 1) == "/" then
|
|
return nil, "invalid_asset_url"
|
|
end
|
|
|
|
local asset_owner = original_owner
|
|
local asset_path = url
|
|
local original_prefix = original_owner .. "/"
|
|
if url:sub(1, #original_prefix) == original_prefix then
|
|
asset_path = url:sub(#original_prefix + 1)
|
|
elseif adapter_resource then
|
|
local adapter_prefix = adapter_resource .. "/"
|
|
if url:sub(1, #adapter_prefix) == adapter_prefix then
|
|
asset_owner = adapter_resource
|
|
asset_path = url:sub(#adapter_prefix + 1)
|
|
end
|
|
end
|
|
|
|
if not validate_asset_path(asset_path) then
|
|
return nil, "invalid_asset_url"
|
|
end
|
|
|
|
return ("https://cfx-nui-%s/%s"):format(asset_owner, asset_path)
|
|
end
|
|
|
|
local function validate_json_value(value, depth, seen, counter)
|
|
local value_type = type(value)
|
|
if value_type == "nil" or value_type == "boolean" then
|
|
return true
|
|
end
|
|
if value_type == "number" then
|
|
return value == value and value ~= math.huge and value ~= -math.huge
|
|
end
|
|
if value_type == "string" then
|
|
return #value <= Config.CustomApps.MaximumMessageBytes
|
|
end
|
|
if value_type ~= "table" or depth >= JSON_MAX_DEPTH or seen[value] then
|
|
return false
|
|
end
|
|
|
|
seen[value] = true
|
|
local key_type = nil
|
|
local numeric_keys = 0
|
|
local value_count = 0
|
|
for key, nested_value in pairs(value) do
|
|
counter.count = counter.count + 1
|
|
value_count = value_count + 1
|
|
if counter.count > JSON_MAX_VALUES then
|
|
seen[value] = nil
|
|
return false
|
|
end
|
|
|
|
local current_key_type = type(key)
|
|
if current_key_type == "number" then
|
|
if key < 1 or key % 1 ~= 0 then
|
|
seen[value] = nil
|
|
return false
|
|
end
|
|
numeric_keys = numeric_keys + 1
|
|
elseif current_key_type ~= "string" or #key > 128 then
|
|
seen[value] = nil
|
|
return false
|
|
end
|
|
|
|
if key_type and key_type ~= current_key_type then
|
|
seen[value] = nil
|
|
return false
|
|
end
|
|
key_type = current_key_type
|
|
|
|
if not validate_json_value(nested_value, depth + 1, seen, counter) then
|
|
seen[value] = nil
|
|
return false
|
|
end
|
|
end
|
|
|
|
if key_type == "number" and (numeric_keys ~= value_count or #value ~= value_count) then
|
|
seen[value] = nil
|
|
return false
|
|
end
|
|
|
|
seen[value] = nil
|
|
return true
|
|
end
|
|
|
|
local function normalize_json_payload(payload, maximum_bytes)
|
|
if not validate_json_value(payload, 0, {}, { count = 0 }) then
|
|
return nil, "invalid_payload"
|
|
end
|
|
|
|
local encoded_success, encoded = pcall(json.encode, payload)
|
|
if not encoded_success or type(encoded) ~= "string" then
|
|
return nil, "invalid_payload"
|
|
end
|
|
if #encoded > maximum_bytes then
|
|
return nil, "payload_too_large"
|
|
end
|
|
|
|
return json.decode(encoded)
|
|
end
|
|
|
|
local function validate_optional_text(value, error_code, maximum_length)
|
|
if value == nil then
|
|
return nil
|
|
end
|
|
if type(value) ~= "string" then
|
|
return nil, error_code
|
|
end
|
|
|
|
local normalized = trim(value)
|
|
if #normalized == 0 or #normalized > maximum_length then
|
|
return nil, error_code
|
|
end
|
|
|
|
return normalized
|
|
end
|
|
|
|
local function normalize_external_definition(owner_resource, adapter_resource, definition)
|
|
if type(definition) ~= "table" then
|
|
return nil, "invalid_definition"
|
|
end
|
|
if definition.schemaVersion ~= nil and definition.schemaVersion ~= SkyPhoneApps.ProtocolVersion then
|
|
return nil, "unsupported_schema_version"
|
|
end
|
|
if definition.onDelete ~= nil then
|
|
return nil, "unsupported_on_delete"
|
|
end
|
|
|
|
local valid_id, id_error = SkyPhoneApps.ValidateAppId(definition.id)
|
|
if not valid_id then
|
|
return nil, id_error
|
|
end
|
|
if SkyPhoneApps.ReservedAppIds[definition.id] then
|
|
return nil, "reserved_app_id"
|
|
end
|
|
|
|
local name, name_error = SkyPhoneApps.ValidateLocalizedText(
|
|
definition.name,
|
|
"invalid_name",
|
|
64,
|
|
true
|
|
)
|
|
if not name then
|
|
return nil, name_error
|
|
end
|
|
|
|
local description, description_error = SkyPhoneApps.ValidateLocalizedText(
|
|
definition.description,
|
|
"invalid_description",
|
|
320,
|
|
false
|
|
)
|
|
if definition.description ~= nil and not description then
|
|
return nil, description_error
|
|
end
|
|
|
|
local developer, developer_error = validate_optional_text(definition.developer, "invalid_developer", 96)
|
|
if definition.developer ~= nil and not developer then
|
|
return nil, developer_error
|
|
end
|
|
|
|
local category = definition.category or "utilities"
|
|
if not SkyPhoneApps.AllowedCategories[category] then
|
|
return nil, "invalid_category"
|
|
end
|
|
|
|
local ui, ui_error = normalize_asset_url(definition.ui, owner_resource, adapter_resource, true)
|
|
if not ui then
|
|
return nil, ui_error
|
|
end
|
|
|
|
local icon, icon_error = normalize_asset_url(definition.icon, owner_resource, adapter_resource, false)
|
|
if definition.icon ~= nil and not icon then
|
|
return nil, icon_error
|
|
end
|
|
|
|
local permissions, permissions_error = SkyPhoneApps.ValidatePermissions(definition.permissions)
|
|
if not permissions then
|
|
return nil, permissions_error
|
|
end
|
|
|
|
local orientation = definition.orientation or "portrait"
|
|
if not SkyPhoneApps.AllowedOrientations[orientation] then
|
|
return nil, "invalid_orientation"
|
|
end
|
|
|
|
if definition.defaultInstalled ~= nil and type(definition.defaultInstalled) ~= "boolean" then
|
|
return nil, "invalid_default_installed"
|
|
end
|
|
if definition.removable ~= nil and type(definition.removable) ~= "boolean" then
|
|
return nil, "invalid_removable"
|
|
end
|
|
|
|
local grid_order = definition.gridOrder
|
|
if grid_order ~= nil
|
|
and (type(grid_order) ~= "number" or grid_order ~= math.floor(grid_order) or grid_order < 0 or grid_order > 9999)
|
|
then
|
|
return nil, "invalid_grid_order"
|
|
end
|
|
|
|
local icon_background = validate_optional_text(definition.iconBackground, "invalid_icon_background", 64)
|
|
if definition.iconBackground ~= nil and not icon_background then
|
|
return nil, "invalid_icon_background"
|
|
end
|
|
if icon_background and icon_background:find("[\r\n;{}]") then
|
|
return nil, "invalid_icon_background"
|
|
end
|
|
|
|
local bridge_mode = definition.bridgeMode or "legacy"
|
|
if bridge_mode ~= "sky" and bridge_mode ~= "legacy" then
|
|
return nil, "invalid_bridge_mode"
|
|
end
|
|
|
|
local compatibility = nil
|
|
if definition.compatibility ~= nil then
|
|
if type(definition.compatibility) ~= "table" or next(definition.compatibility) == nil then
|
|
return nil, "invalid_compatibility"
|
|
end
|
|
compatibility, permissions_error = normalize_json_payload(definition.compatibility, COMPATIBILITY_MAX_BYTES)
|
|
if not compatibility then
|
|
return nil, permissions_error
|
|
end
|
|
end
|
|
|
|
local hooks = {}
|
|
for hook_name in pairs(HOOK_NAMES) do
|
|
local hook = definition[hook_name]
|
|
if hook ~= nil and type(hook) ~= "function" then
|
|
return nil, "invalid_" .. hook_name
|
|
end
|
|
hooks[hook_name] = hook
|
|
end
|
|
|
|
return {
|
|
adapterResource = adapter_resource,
|
|
catalog = {
|
|
bridgeMode = bridge_mode,
|
|
bundled = false,
|
|
category = category,
|
|
compatibility = compatibility,
|
|
defaultInstalled = definition.defaultInstalled == true,
|
|
description = description and resolve_localized_text(description) or nil,
|
|
developer = developer,
|
|
gridOrder = grid_order,
|
|
icon = icon or default_icon_url,
|
|
iconBackground = icon_background,
|
|
id = definition.id,
|
|
kind = "external",
|
|
name = resolve_localized_text(name),
|
|
orientation = orientation,
|
|
ownerResource = owner_resource,
|
|
permissions = permissions,
|
|
readyTimeoutMs = Config.CustomApps.ReadyTimeoutMs,
|
|
removable = definition.removable ~= false,
|
|
ui = ui,
|
|
},
|
|
hooks = hooks,
|
|
ownerResource = owner_resource,
|
|
}
|
|
end
|
|
|
|
local function normalize_bundled_manifest(manifest)
|
|
local resource_base_path = ("custom_apps/%s/"):format(manifest.folder)
|
|
local base_url = ("https://cfx-nui-%s/%s"):format(current_resource, resource_base_path)
|
|
if LoadResourceFile(current_resource, resource_base_path .. manifest.entry) == nil then
|
|
error(("[sky_phone] Bundled custom app '%s' entry file is missing: %s"):format(
|
|
manifest.id,
|
|
manifest.entry
|
|
))
|
|
end
|
|
if manifest.icon and LoadResourceFile(current_resource, resource_base_path .. manifest.icon) == nil then
|
|
error(("[sky_phone] Bundled custom app '%s' icon file is missing: %s"):format(
|
|
manifest.id,
|
|
manifest.icon
|
|
))
|
|
end
|
|
for index = 1, #manifest.screenshots do
|
|
if LoadResourceFile(current_resource, resource_base_path .. manifest.screenshots[index]) == nil then
|
|
error(("[sky_phone] Bundled custom app '%s' screenshot file is missing: %s"):format(
|
|
manifest.id,
|
|
manifest.screenshots[index]
|
|
))
|
|
end
|
|
end
|
|
|
|
return {
|
|
adapterResource = nil,
|
|
catalog = {
|
|
bridgeMode = manifest.bridgeMode,
|
|
bundled = true,
|
|
category = manifest.category,
|
|
defaultInstalled = manifest.defaultInstalled,
|
|
description = resolve_localized_text(manifest.description),
|
|
developer = manifest.developer,
|
|
gridOrder = manifest.gridOrder,
|
|
icon = manifest.icon and (base_url .. manifest.icon) or default_icon_url,
|
|
iconBackground = manifest.iconBackground,
|
|
id = manifest.id,
|
|
kind = "external",
|
|
name = resolve_localized_text(manifest.name),
|
|
orientation = manifest.orientation,
|
|
ownerResource = current_resource,
|
|
permissions = manifest.permissions,
|
|
readyTimeoutMs = Config.CustomApps.ReadyTimeoutMs,
|
|
removable = manifest.removable,
|
|
ui = base_url .. manifest.entry,
|
|
},
|
|
hooks = {},
|
|
ownerResource = current_resource,
|
|
}
|
|
end
|
|
|
|
local function get_catalog()
|
|
local catalog = {}
|
|
for _, app in pairs(apps_by_id) do
|
|
catalog[#catalog + 1] = app.catalog
|
|
end
|
|
table.sort(catalog, function(left, right)
|
|
return left.id < right.id
|
|
end)
|
|
return catalog
|
|
end
|
|
|
|
local function send_catalog()
|
|
SendNUIMessage({
|
|
type = "custom-apps:catalog",
|
|
data = {
|
|
apps = get_catalog(),
|
|
},
|
|
})
|
|
end
|
|
|
|
local function sync_catalog_if_open()
|
|
if phone_open then
|
|
send_catalog()
|
|
end
|
|
end
|
|
|
|
local function add_owner_index(index, resource_name, app_id)
|
|
local app_ids = index[resource_name]
|
|
if not app_ids then
|
|
app_ids = {}
|
|
index[resource_name] = app_ids
|
|
end
|
|
app_ids[app_id] = true
|
|
end
|
|
|
|
local function remove_owner_index(index, resource_name, app_id)
|
|
local app_ids = index[resource_name]
|
|
if not app_ids then
|
|
return
|
|
end
|
|
|
|
app_ids[app_id] = nil
|
|
if not next(app_ids) then
|
|
index[resource_name] = nil
|
|
end
|
|
end
|
|
|
|
local function register_app(app)
|
|
local app_id = app.catalog.id
|
|
local existing = apps_by_id[app_id]
|
|
if existing then
|
|
print((
|
|
"[sky_phone] Rejected duplicate custom app '%s' from '%s'; it is already owned by '%s'."
|
|
):format(app_id, app.ownerResource, existing.ownerResource))
|
|
return false, "duplicate_app_id"
|
|
end
|
|
|
|
apps_by_id[app_id] = app
|
|
add_owner_index(app_ids_by_owner, app.ownerResource, app_id)
|
|
if app.adapterResource then
|
|
add_owner_index(app_ids_by_adapter, app.adapterResource, app_id)
|
|
end
|
|
sync_catalog_if_open()
|
|
return true
|
|
end
|
|
|
|
local function invoke_hook(app, hook_name, payload)
|
|
local hook = app.hooks[hook_name]
|
|
if not hook then
|
|
return true
|
|
end
|
|
|
|
local success, hook_error = xpcall(function()
|
|
hook(payload)
|
|
end, debug.traceback)
|
|
if success then
|
|
return true
|
|
end
|
|
|
|
print(("[sky_phone] Custom app '%s' hook '%s' failed: %s"):format(
|
|
app.catalog.id,
|
|
hook_name,
|
|
tostring(hook_error)
|
|
))
|
|
return false
|
|
end
|
|
|
|
local function clear_active_app(invoke_close_hook)
|
|
if not active_app_id then
|
|
return
|
|
end
|
|
|
|
local app = apps_by_id[active_app_id]
|
|
if app and invoke_close_hook then
|
|
invoke_hook(app, "onClose")
|
|
end
|
|
queued_messages[active_app_id] = nil
|
|
active_app_id = nil
|
|
active_app_ready = false
|
|
end
|
|
|
|
local function remove_registered_app(app_id, invoke_close_hook, synchronize)
|
|
local app = apps_by_id[app_id]
|
|
if not app then
|
|
return false, "app_not_found"
|
|
end
|
|
|
|
if active_app_id == app_id then
|
|
SendNUIMessage({ type = "custom-app:close", data = { appId = app_id } })
|
|
clear_active_app(invoke_close_hook)
|
|
end
|
|
|
|
apps_by_id[app_id] = nil
|
|
remove_owner_index(app_ids_by_owner, app.ownerResource, app_id)
|
|
if app.adapterResource then
|
|
remove_owner_index(app_ids_by_adapter, app.adapterResource, app_id)
|
|
end
|
|
if SkyPhoneApps.OnCompatibilityAppRemoved then
|
|
SkyPhoneApps.OnCompatibilityAppRemoved(app.ownerResource, app_id)
|
|
end
|
|
if synchronize then
|
|
sync_catalog_if_open()
|
|
end
|
|
return true
|
|
end
|
|
|
|
local function get_direct_owner()
|
|
local owner_resource = GetInvokingResource()
|
|
if not owner_resource then
|
|
return nil, "missing_invoking_resource"
|
|
end
|
|
|
|
local valid_owner, owner_error = validate_owner_resource(owner_resource, false)
|
|
if not valid_owner then
|
|
return nil, owner_error
|
|
end
|
|
|
|
return owner_resource
|
|
end
|
|
|
|
local function get_trusted_adapter()
|
|
local adapter_resource = GetInvokingResource()
|
|
if not adapter_resource or not Config.CustomApps.TrustedAdapters[adapter_resource] then
|
|
return nil, "untrusted_adapter"
|
|
end
|
|
|
|
local valid_adapter, adapter_error = validate_owner_resource(adapter_resource, false)
|
|
if not valid_adapter then
|
|
return nil, adapter_error
|
|
end
|
|
|
|
return adapter_resource
|
|
end
|
|
|
|
local function verify_owned_app(owner_resource, app_id, adapter_resource)
|
|
local valid_id, id_error = SkyPhoneApps.ValidateAppId(app_id)
|
|
if not valid_id then
|
|
return nil, id_error
|
|
end
|
|
|
|
local app = apps_by_id[app_id]
|
|
if not app then
|
|
return nil, "app_not_found"
|
|
end
|
|
if app.ownerResource ~= owner_resource then
|
|
return nil, "app_owner_mismatch"
|
|
end
|
|
if adapter_resource and app.adapterResource ~= adapter_resource then
|
|
return nil, "app_adapter_mismatch"
|
|
end
|
|
|
|
return app
|
|
end
|
|
|
|
local function add_custom_app(definition)
|
|
if not Config.CustomApps.Enabled or not Config.CustomApps.ExternalApps then
|
|
return false, "external_apps_disabled"
|
|
end
|
|
|
|
local owner_resource, owner_error = get_direct_owner()
|
|
if not owner_resource then
|
|
return false, owner_error
|
|
end
|
|
|
|
local app, definition_error = normalize_external_definition(owner_resource, nil, definition)
|
|
if not app then
|
|
return false, definition_error
|
|
end
|
|
|
|
return register_app(app)
|
|
end
|
|
|
|
local function add_custom_app_from_adapter(owner_resource, definition)
|
|
if not Config.CustomApps.Enabled or not Config.CustomApps.ExternalApps then
|
|
return false, "external_apps_disabled"
|
|
end
|
|
|
|
local adapter_resource, adapter_error = get_trusted_adapter()
|
|
if not adapter_resource then
|
|
return false, adapter_error
|
|
end
|
|
local valid_owner, owner_error = validate_owner_resource(owner_resource, false)
|
|
if not valid_owner then
|
|
return false, owner_error
|
|
end
|
|
|
|
local app, definition_error = normalize_external_definition(owner_resource, adapter_resource, definition)
|
|
if not app then
|
|
return false, definition_error
|
|
end
|
|
|
|
return register_app(app)
|
|
end
|
|
|
|
local function add_compatibility_app(owner_resource, definition)
|
|
if not Config.CustomApps.Enabled or not Config.CustomApps.ExternalApps then
|
|
return false, "external_apps_disabled"
|
|
end
|
|
|
|
local valid_owner, owner_error = validate_owner_resource(owner_resource, false)
|
|
if not valid_owner then
|
|
return false, owner_error
|
|
end
|
|
|
|
local app, definition_error = normalize_external_definition(owner_resource, nil, definition)
|
|
if not app then
|
|
return false, definition_error
|
|
end
|
|
|
|
return register_app(app)
|
|
end
|
|
|
|
local function replace_registered_app(app, adapter_resource)
|
|
local app_id = app.catalog.id
|
|
local existing = apps_by_id[app_id]
|
|
if not existing then
|
|
return false, "app_not_found"
|
|
end
|
|
if existing.catalog.bundled then
|
|
return false, "bundled_app"
|
|
end
|
|
if existing.ownerResource ~= app.ownerResource then
|
|
return false, "app_owner_mismatch"
|
|
end
|
|
if existing.adapterResource ~= adapter_resource then
|
|
return false, "app_adapter_mismatch"
|
|
end
|
|
|
|
apps_by_id[app_id] = app
|
|
sync_catalog_if_open()
|
|
return true
|
|
end
|
|
|
|
local function update_custom_app(definition)
|
|
if not Config.CustomApps.Enabled or not Config.CustomApps.ExternalApps then
|
|
return false, "external_apps_disabled"
|
|
end
|
|
|
|
local owner_resource, owner_error = get_direct_owner()
|
|
if not owner_resource then
|
|
return false, owner_error
|
|
end
|
|
|
|
local app, definition_error = normalize_external_definition(owner_resource, nil, definition)
|
|
if not app then
|
|
return false, definition_error
|
|
end
|
|
return replace_registered_app(app, nil)
|
|
end
|
|
|
|
local function update_custom_app_from_adapter(owner_resource, definition)
|
|
if not Config.CustomApps.Enabled or not Config.CustomApps.ExternalApps then
|
|
return false, "external_apps_disabled"
|
|
end
|
|
|
|
local adapter_resource, adapter_error = get_trusted_adapter()
|
|
if not adapter_resource then
|
|
return false, adapter_error
|
|
end
|
|
local valid_owner, owner_error = validate_owner_resource(owner_resource, false)
|
|
if not valid_owner then
|
|
return false, owner_error
|
|
end
|
|
|
|
local app, definition_error = normalize_external_definition(owner_resource, adapter_resource, definition)
|
|
if not app then
|
|
return false, definition_error
|
|
end
|
|
return replace_registered_app(app, adapter_resource)
|
|
end
|
|
|
|
local function update_compatibility_app(owner_resource, definition)
|
|
if not Config.CustomApps.Enabled or not Config.CustomApps.ExternalApps then
|
|
return false, "external_apps_disabled"
|
|
end
|
|
|
|
local valid_owner, owner_error = validate_owner_resource(owner_resource, false)
|
|
if not valid_owner then
|
|
return false, owner_error
|
|
end
|
|
|
|
local app, definition_error = normalize_external_definition(owner_resource, nil, definition)
|
|
if not app then
|
|
return false, definition_error
|
|
end
|
|
return replace_registered_app(app, nil)
|
|
end
|
|
|
|
local function remove_custom_app(app_id)
|
|
local owner_resource, owner_error = get_direct_owner()
|
|
if not owner_resource then
|
|
return false, owner_error
|
|
end
|
|
|
|
local app, app_error = verify_owned_app(owner_resource, app_id)
|
|
if not app then
|
|
return false, app_error
|
|
end
|
|
if app.catalog.bundled then
|
|
return false, "bundled_app"
|
|
end
|
|
|
|
return remove_registered_app(app_id, true, true)
|
|
end
|
|
|
|
local function remove_custom_app_from_adapter(owner_resource, app_id)
|
|
local adapter_resource, adapter_error = get_trusted_adapter()
|
|
if not adapter_resource then
|
|
return false, adapter_error
|
|
end
|
|
|
|
local app, app_error = verify_owned_app(owner_resource, app_id, adapter_resource)
|
|
if not app then
|
|
return false, app_error
|
|
end
|
|
|
|
return remove_registered_app(app_id, true, true)
|
|
end
|
|
|
|
local function remove_compatibility_app(owner_resource, app_id)
|
|
local valid_owner, owner_error = validate_owner_resource(owner_resource, false)
|
|
if not valid_owner then
|
|
return false, owner_error
|
|
end
|
|
|
|
local app, app_error = verify_owned_app(owner_resource, app_id)
|
|
if not app then
|
|
return false, app_error
|
|
end
|
|
if app.catalog.bundled then
|
|
return false, "bundled_app"
|
|
end
|
|
|
|
return remove_registered_app(app_id, true, true)
|
|
end
|
|
|
|
local function deliver_custom_app_message(app_id, payload)
|
|
SendNUIMessage({
|
|
type = "custom-app:message",
|
|
data = {
|
|
appId = app_id,
|
|
payload = payload,
|
|
},
|
|
})
|
|
end
|
|
|
|
local function send_owned_custom_app_message(owner_resource, app_id, payload, adapter_resource)
|
|
local app, app_error = verify_owned_app(owner_resource, app_id, adapter_resource)
|
|
if not app then
|
|
return false, app_error
|
|
end
|
|
if not phone_open or active_app_id ~= app_id then
|
|
return false, "app_not_active"
|
|
end
|
|
|
|
local normalized_payload, payload_error = normalize_json_payload(
|
|
payload,
|
|
Config.CustomApps.MaximumMessageBytes
|
|
)
|
|
if payload_error then
|
|
return false, payload_error
|
|
end
|
|
|
|
if active_app_ready then
|
|
deliver_custom_app_message(app_id, normalized_payload)
|
|
return true
|
|
end
|
|
|
|
local pending = queued_messages[app_id] or {}
|
|
if #pending >= MAX_QUEUED_MESSAGES then
|
|
return false, "message_queue_full"
|
|
end
|
|
pending[#pending + 1] = { payload = normalized_payload }
|
|
queued_messages[app_id] = pending
|
|
return true
|
|
end
|
|
|
|
local function send_custom_app_message(app_id, payload)
|
|
local owner_resource, owner_error = get_direct_owner()
|
|
if not owner_resource then
|
|
return false, owner_error
|
|
end
|
|
return send_owned_custom_app_message(owner_resource, app_id, payload)
|
|
end
|
|
|
|
local function send_custom_app_message_from_adapter(owner_resource, app_id, payload)
|
|
local adapter_resource, adapter_error = get_trusted_adapter()
|
|
if not adapter_resource then
|
|
return false, adapter_error
|
|
end
|
|
return send_owned_custom_app_message(owner_resource, app_id, payload, adapter_resource)
|
|
end
|
|
|
|
local function send_compatibility_app_message(owner_resource, app_id, payload)
|
|
local valid_owner, owner_error = validate_owner_resource(owner_resource, false)
|
|
if not valid_owner then
|
|
return false, owner_error
|
|
end
|
|
return send_owned_custom_app_message(owner_resource, app_id, payload)
|
|
end
|
|
|
|
local function open_owned_custom_app(owner_resource, app_id, payload, adapter_resource)
|
|
local app, app_error = verify_owned_app(owner_resource, app_id, adapter_resource)
|
|
if not app then
|
|
return false, app_error
|
|
end
|
|
if not phone_open then
|
|
return false, "phone_closed"
|
|
end
|
|
|
|
local normalized_payload, payload_error = normalize_json_payload(
|
|
payload,
|
|
Config.CustomApps.MaximumMessageBytes
|
|
)
|
|
if payload_error then
|
|
return false, payload_error
|
|
end
|
|
|
|
SendNUIMessage({
|
|
type = "custom-app:open",
|
|
data = {
|
|
appId = app_id,
|
|
data = normalized_payload,
|
|
},
|
|
})
|
|
return true
|
|
end
|
|
|
|
local function open_custom_app(app_id, payload)
|
|
local owner_resource, owner_error = get_direct_owner()
|
|
if not owner_resource then
|
|
return false, owner_error
|
|
end
|
|
return open_owned_custom_app(owner_resource, app_id, payload)
|
|
end
|
|
|
|
local function open_custom_app_from_adapter(owner_resource, app_id, payload)
|
|
local adapter_resource, adapter_error = get_trusted_adapter()
|
|
if not adapter_resource then
|
|
return false, adapter_error
|
|
end
|
|
return open_owned_custom_app(owner_resource, app_id, payload, adapter_resource)
|
|
end
|
|
|
|
local function open_compatibility_app(owner_resource, app_id, payload)
|
|
local valid_owner, owner_error = validate_owner_resource(owner_resource, false)
|
|
if not valid_owner then
|
|
return false, owner_error
|
|
end
|
|
return open_owned_custom_app(owner_resource, app_id, payload)
|
|
end
|
|
|
|
local function close_owned_custom_app(owner_resource, app_id, adapter_resource)
|
|
local app, app_error = verify_owned_app(owner_resource, app_id, adapter_resource)
|
|
if not app then
|
|
return false, app_error
|
|
end
|
|
if active_app_id ~= app_id then
|
|
return false, "app_not_active"
|
|
end
|
|
|
|
SendNUIMessage({ type = "custom-app:close", data = { appId = app_id } })
|
|
return true
|
|
end
|
|
|
|
local function close_custom_app(app_id)
|
|
local owner_resource, owner_error = get_direct_owner()
|
|
if not owner_resource then
|
|
return false, owner_error
|
|
end
|
|
return close_owned_custom_app(owner_resource, app_id)
|
|
end
|
|
|
|
local function close_custom_app_from_adapter(owner_resource, app_id)
|
|
local adapter_resource, adapter_error = get_trusted_adapter()
|
|
if not adapter_resource then
|
|
return false, adapter_error
|
|
end
|
|
return close_owned_custom_app(owner_resource, app_id, adapter_resource)
|
|
end
|
|
|
|
local function close_active_custom_app_from_adapter(owner_resource)
|
|
local adapter_resource, adapter_error = get_trusted_adapter()
|
|
if not adapter_resource then
|
|
return false, adapter_error
|
|
end
|
|
if not active_app_id then
|
|
return false, "app_not_active"
|
|
end
|
|
|
|
local app, app_error = verify_owned_app(owner_resource, active_app_id, adapter_resource)
|
|
if not app then
|
|
return false, app_error
|
|
end
|
|
|
|
SendNUIMessage({ type = "custom-app:close", data = { appId = active_app_id } })
|
|
return true
|
|
end
|
|
|
|
local function close_compatibility_app(owner_resource, app_id)
|
|
local valid_owner, owner_error = validate_owner_resource(owner_resource, false)
|
|
if not valid_owner then
|
|
return false, owner_error
|
|
end
|
|
return close_owned_custom_app(owner_resource, app_id)
|
|
end
|
|
|
|
local function close_active_compatibility_app(owner_resource)
|
|
local valid_owner, owner_error = validate_owner_resource(owner_resource, false)
|
|
if not valid_owner then
|
|
return false, owner_error
|
|
end
|
|
if not active_app_id then
|
|
return false, "app_not_active"
|
|
end
|
|
|
|
local app, app_error = verify_owned_app(owner_resource, active_app_id)
|
|
if not app then
|
|
return false, app_error
|
|
end
|
|
|
|
SendNUIMessage({ type = "custom-app:close", data = { appId = active_app_id } })
|
|
return true
|
|
end
|
|
|
|
local function has_permission(app, permission)
|
|
local permissions = app.catalog.permissions
|
|
for index = 1, #permissions do
|
|
if permissions[index] == permission then
|
|
return true
|
|
end
|
|
end
|
|
return false
|
|
end
|
|
|
|
local function normalize_notification(app, notification)
|
|
if type(notification) ~= "table" then
|
|
return nil, "invalid_notification"
|
|
end
|
|
if not has_permission(app, "notifications") then
|
|
return nil, "permission_denied"
|
|
end
|
|
|
|
local title = notification.title
|
|
if title == nil then
|
|
title = app.catalog.name
|
|
end
|
|
title = validate_optional_text(title, "invalid_notification_title", NOTIFICATION_TITLE_MAX_LENGTH)
|
|
if not title then
|
|
return nil, "invalid_notification_title"
|
|
end
|
|
|
|
local text = validate_optional_text(
|
|
notification.text or notification.content,
|
|
"invalid_notification_text",
|
|
NOTIFICATION_TEXT_MAX_LENGTH
|
|
)
|
|
if not text then
|
|
return nil, "invalid_notification_text"
|
|
end
|
|
|
|
local subtitle = validate_optional_text(notification.subtitle, "invalid_notification_subtitle", 160)
|
|
if notification.subtitle ~= nil and not subtitle then
|
|
return nil, "invalid_notification_subtitle"
|
|
end
|
|
|
|
if notification.critical ~= nil and type(notification.critical) ~= "boolean" then
|
|
return nil, "invalid_notification_critical"
|
|
end
|
|
if notification.critical and not has_permission(app, "notifications.critical") then
|
|
return nil, "permission_denied"
|
|
end
|
|
if notification.persistent ~= nil and type(notification.persistent) ~= "boolean" then
|
|
return nil, "invalid_notification_persistent"
|
|
end
|
|
if notification.sound ~= nil and not NOTIFICATION_SOUNDS[notification.sound] then
|
|
return nil, "invalid_notification_sound"
|
|
end
|
|
|
|
local app_route = "/apps/" .. app.catalog.id
|
|
local route = notification.route or app_route
|
|
if type(route) ~= "string"
|
|
or route ~= app_route
|
|
or route:find("[\r\n]")
|
|
or #route > 512
|
|
then
|
|
return nil, "invalid_notification_route"
|
|
end
|
|
|
|
return {
|
|
appId = app.catalog.id,
|
|
critical = notification.critical == true,
|
|
persistent = notification.persistent == true,
|
|
route = route,
|
|
sound = notification.sound,
|
|
subtitle = subtitle,
|
|
text = text,
|
|
title = title,
|
|
}
|
|
end
|
|
|
|
local function send_owned_custom_app_notification(owner_resource, app_id, notification, adapter_resource)
|
|
local app, app_error = verify_owned_app(owner_resource, app_id, adapter_resource)
|
|
if not app then
|
|
return false, app_error
|
|
end
|
|
|
|
local normalized_notification, notification_error = normalize_notification(app, notification)
|
|
if not normalized_notification then
|
|
return false, notification_error
|
|
end
|
|
|
|
send_catalog()
|
|
SendNUIMessage({ type = "notification:show", data = normalized_notification })
|
|
return true
|
|
end
|
|
|
|
local function send_custom_app_notification(app_id, notification)
|
|
local owner_resource, owner_error = get_direct_owner()
|
|
if not owner_resource then
|
|
return false, owner_error
|
|
end
|
|
return send_owned_custom_app_notification(owner_resource, app_id, notification)
|
|
end
|
|
|
|
local function send_custom_app_notification_from_adapter(owner_resource, app_id, notification)
|
|
local adapter_resource, adapter_error = get_trusted_adapter()
|
|
if not adapter_resource then
|
|
return false, adapter_error
|
|
end
|
|
return send_owned_custom_app_notification(owner_resource, app_id, notification, adapter_resource)
|
|
end
|
|
|
|
local function get_custom_app_capabilities()
|
|
return {
|
|
abiVersion = 1,
|
|
enabled = Config.CustomApps.Enabled,
|
|
bridgeMethods = {
|
|
"app.close",
|
|
"app.open",
|
|
"device.storage.get",
|
|
"device.storage.set",
|
|
"notification.create",
|
|
},
|
|
contextCapabilities = {
|
|
"locale.read",
|
|
"theme.read",
|
|
},
|
|
exports = {
|
|
"AddCustomApp",
|
|
"AddCustomAppFromAdapter",
|
|
"CloseActiveCustomAppFromAdapter",
|
|
"CloseCustomApp",
|
|
"CloseCustomAppFromAdapter",
|
|
"OpenCustomApp",
|
|
"OpenCustomAppFromAdapter",
|
|
"RemoveCustomApp",
|
|
"RemoveCustomAppFromAdapter",
|
|
"SendCustomAppMessage",
|
|
"SendCustomAppMessageFromAdapter",
|
|
"SendCustomAppNotification",
|
|
"SendCustomAppNotificationFromAdapter",
|
|
"UpdateCustomApp",
|
|
"UpdateCustomAppFromAdapter",
|
|
},
|
|
maximumMessageBytes = Config.CustomApps.MaximumMessageBytes,
|
|
maximumStorageBytesPerApp = Config.CustomApps.MaximumStorageBytesPerApp,
|
|
maximumStorageKeyLength = math.min(Config.CustomApps.MaximumStorageKeyLength, 64),
|
|
maximumStorageValueBytes = math.min(Config.CustomApps.MaximumStorageValueBytes, 65536),
|
|
protocolVersion = SkyPhoneApps.ProtocolVersion,
|
|
}
|
|
end
|
|
|
|
local function set_phone_open(is_open)
|
|
phone_open = is_open
|
|
if phone_open then
|
|
send_catalog()
|
|
return
|
|
end
|
|
|
|
clear_active_app(true)
|
|
end
|
|
|
|
local function register_bundled_client_hooks(app_id, hooks)
|
|
local valid_id, id_error = SkyPhoneApps.ValidateAppId(app_id)
|
|
if not valid_id then
|
|
return false, id_error
|
|
end
|
|
|
|
local app = apps_by_id[app_id]
|
|
if not app then
|
|
return false, "app_not_found"
|
|
end
|
|
if not app.catalog.bundled then
|
|
return false, "external_app"
|
|
end
|
|
if type(hooks) ~= "table" then
|
|
return false, "invalid_hooks"
|
|
end
|
|
|
|
local normalized_hooks = {}
|
|
for hook_name, hook in pairs(hooks) do
|
|
if not HOOK_NAMES[hook_name] or type(hook) ~= "function" then
|
|
return false, "invalid_hook"
|
|
end
|
|
normalized_hooks[hook_name] = hook
|
|
end
|
|
|
|
app.hooks = normalized_hooks
|
|
return true
|
|
end
|
|
|
|
SkyPhoneApps.SendCatalog = send_catalog
|
|
SkyPhoneApps.SetPhoneOpen = set_phone_open
|
|
SkyPhoneApps.RegisterClientHooks = register_bundled_client_hooks
|
|
|
|
if Config.CustomApps.Enabled and Config.CustomApps.BundledApps then
|
|
local bundled = SkyPhoneApps.GetBundledManifests()
|
|
for index = 1, #bundled do
|
|
local registered, register_error = register_app(normalize_bundled_manifest(bundled[index]))
|
|
if not registered then
|
|
error(("[sky_phone] Could not register bundled custom app '%s': %s"):format(
|
|
bundled[index].id,
|
|
register_error
|
|
))
|
|
end
|
|
end
|
|
end
|
|
|
|
local function add_custom_app_export(definition)
|
|
if type(definition) ~= "table" then
|
|
return add_custom_app(definition)
|
|
end
|
|
|
|
local provider_markers = 0
|
|
if definition.id ~= nil then
|
|
provider_markers = provider_markers + 1
|
|
end
|
|
if definition.identifier ~= nil then
|
|
provider_markers = provider_markers + 1
|
|
end
|
|
if definition.key ~= nil then
|
|
provider_markers = provider_markers + 1
|
|
end
|
|
if provider_markers > 1 then
|
|
return false, "ambiguous_app_provider"
|
|
end
|
|
if definition.identifier == nil and definition.key == nil then
|
|
return add_custom_app(definition)
|
|
end
|
|
|
|
local owner_resource, owner_error = get_direct_owner()
|
|
if not owner_resource then
|
|
return false, owner_error
|
|
end
|
|
if not SkyPhoneApps.RegisterCompatibilityExport then
|
|
return false, "compatibility_not_ready"
|
|
end
|
|
return SkyPhoneApps.RegisterCompatibilityExport(owner_resource, definition)
|
|
end
|
|
|
|
SkyPhoneApps.CompatibilityCore = {
|
|
Add = add_compatibility_app,
|
|
Close = close_compatibility_app,
|
|
CloseActive = close_active_compatibility_app,
|
|
Open = open_compatibility_app,
|
|
Remove = remove_compatibility_app,
|
|
SendMessage = send_compatibility_app_message,
|
|
Update = update_compatibility_app,
|
|
}
|
|
|
|
exports("AddCustomApp", add_custom_app_export)
|
|
exports("AddCustomAppFromAdapter", add_custom_app_from_adapter)
|
|
exports("CloseActiveCustomAppFromAdapter", close_active_custom_app_from_adapter)
|
|
exports("CloseCustomApp", close_custom_app)
|
|
exports("CloseCustomAppFromAdapter", close_custom_app_from_adapter)
|
|
exports("GetCustomAppCapabilities", get_custom_app_capabilities)
|
|
exports("OpenCustomApp", open_custom_app)
|
|
exports("OpenCustomAppFromAdapter", open_custom_app_from_adapter)
|
|
exports("RemoveCustomApp", remove_custom_app)
|
|
exports("RemoveCustomAppFromAdapter", remove_custom_app_from_adapter)
|
|
exports("SendCustomAppMessage", send_custom_app_message)
|
|
exports("SendCustomAppMessageFromAdapter", send_custom_app_message_from_adapter)
|
|
exports("SendCustomAppNotification", send_custom_app_notification)
|
|
exports("SendCustomAppNotificationFromAdapter", send_custom_app_notification_from_adapter)
|
|
exports("UpdateCustomApp", update_custom_app)
|
|
exports("UpdateCustomAppFromAdapter", update_custom_app_from_adapter)
|
|
|
|
RegisterNUICallback("custom-app:lifecycle", function(data, cb)
|
|
if type(data) ~= "table" then
|
|
cb({ success = false, error = "invalid_lifecycle_request" })
|
|
return
|
|
end
|
|
|
|
local valid_id, id_error = SkyPhoneApps.ValidateAppId(data.appId)
|
|
if not valid_id then
|
|
cb({ success = false, error = id_error })
|
|
return
|
|
end
|
|
|
|
local lifecycle_event = data.event
|
|
if lifecycle_event ~= "install"
|
|
and lifecycle_event ~= "open"
|
|
and lifecycle_event ~= "ready"
|
|
and lifecycle_event ~= "close"
|
|
then
|
|
cb({ success = false, error = "invalid_lifecycle_event" })
|
|
return
|
|
end
|
|
|
|
local app = apps_by_id[data.appId]
|
|
if lifecycle_event == "close"
|
|
and (not phone_open or not app or active_app_id ~= data.appId)
|
|
then
|
|
cb({ success = true })
|
|
return
|
|
end
|
|
if not phone_open then
|
|
cb({ success = false, error = "phone_closed" })
|
|
return
|
|
end
|
|
if not app then
|
|
cb({ success = false, error = "app_not_found" })
|
|
return
|
|
end
|
|
|
|
local lifecycle_payload, payload_error = normalize_json_payload(
|
|
data.data,
|
|
Config.CustomApps.MaximumMessageBytes
|
|
)
|
|
if payload_error then
|
|
cb({ success = false, error = payload_error })
|
|
return
|
|
end
|
|
|
|
local hook_success = true
|
|
if lifecycle_event == "install" then
|
|
hook_success = invoke_hook(app, "onInstall", lifecycle_payload)
|
|
elseif lifecycle_event == "open" then
|
|
if active_app_id and active_app_id ~= data.appId then
|
|
clear_active_app(true)
|
|
end
|
|
active_app_id = data.appId
|
|
active_app_ready = false
|
|
queued_messages[data.appId] = {}
|
|
hook_success = invoke_hook(app, "onOpen", lifecycle_payload)
|
|
elseif active_app_id ~= data.appId then
|
|
cb({ success = false, error = "app_not_active" })
|
|
return
|
|
elseif lifecycle_event == "ready" then
|
|
if active_app_ready then
|
|
cb({ success = true })
|
|
return
|
|
end
|
|
active_app_ready = true
|
|
local pending = queued_messages[data.appId] or {}
|
|
queued_messages[data.appId] = {}
|
|
for index = 1, #pending do
|
|
deliver_custom_app_message(data.appId, pending[index].payload)
|
|
end
|
|
hook_success = invoke_hook(app, "onReady", lifecycle_payload)
|
|
else
|
|
hook_success = invoke_hook(app, "onClose", lifecycle_payload)
|
|
clear_active_app(false)
|
|
end
|
|
|
|
local hook_error = nil
|
|
if not hook_success then
|
|
hook_error = "hook_failed"
|
|
end
|
|
cb({ success = hook_success, error = hook_error })
|
|
end)
|
|
|
|
AddEventHandler("onClientResourceStop", function(resource_name)
|
|
if resource_name == current_resource then
|
|
return
|
|
end
|
|
|
|
local app_ids = {}
|
|
local owner_apps = app_ids_by_owner[resource_name]
|
|
if owner_apps then
|
|
for app_id in pairs(owner_apps) do
|
|
app_ids[app_id] = true
|
|
end
|
|
end
|
|
|
|
local adapter_apps = app_ids_by_adapter[resource_name]
|
|
if adapter_apps then
|
|
for app_id in pairs(adapter_apps) do
|
|
app_ids[app_id] = true
|
|
end
|
|
end
|
|
|
|
local removed = false
|
|
for app_id in pairs(app_ids) do
|
|
local success = remove_registered_app(app_id, false, false)
|
|
removed = success or removed
|
|
end
|
|
if removed then
|
|
sync_catalog_if_open()
|
|
end
|
|
end)
|