2159 lines
81 KiB
Luau
2159 lines
81 KiB
Luau
--!nonstrict
|
|
-- Transactional keybind writer for Keymap.
|
|
-- New shortcuts are written into the configured source. Legacy managed files
|
|
-- are inlined and removed only after validation and reload succeed.
|
|
|
|
local CREATE_REQUEST_KEY = "keymap.create_request"
|
|
local CREATE_RESULT_KEY = "keymap.create_result"
|
|
local UPDATE_REQUEST_KEY = "keymap.update_request"
|
|
local UPDATE_RESULT_KEY = "keymap.update_result"
|
|
local MIGRATION_RESULT_KEY = "keymap.migration_result"
|
|
local REFRESH_REQUEST_KEY = "keymap.refresh_request"
|
|
local LAST_HANDLED_REQUEST_KEY = "keymap.last_handled_create_request"
|
|
local LAST_HANDLED_UPDATE_KEY = "keymap.last_handled_update_request"
|
|
local SNAPSHOT_KEY = "keymap.snapshot"
|
|
local EXACT_SOURCE_FINGERPRINT = "exact-v1"
|
|
|
|
local MAX_ROOT_BYTES = 2 * 1024 * 1024
|
|
local MAX_MANAGED_BYTES = 1024 * 1024
|
|
local MAX_COMMAND_BYTES = 16 * 1024
|
|
local RELOAD_TIMEOUT_MS = 5000
|
|
local VERIFY_TIMEOUT_MS = 8000
|
|
local HIDDEN_BLOCK_VERSION = "v1"
|
|
local HIDDEN_DATA_CHUNK_BYTES = 48
|
|
|
|
local busy = false
|
|
local temporaryCounter = 0
|
|
local automaticMigrationAttempt = ""
|
|
|
|
local FORMATS = {
|
|
Hyprland = {
|
|
managedName = "keymap.lua",
|
|
comment = "--",
|
|
includeLine = 'require("keymap")',
|
|
reloadCommand = "hyprctl reload",
|
|
validator = "Hyprland --verify-config -c ",
|
|
},
|
|
Niri = {
|
|
managedName = "keymap.kdl",
|
|
comment = "//",
|
|
includeLine = 'include "keymap.kdl"',
|
|
reloadCommand = "niri msg action load-config-file",
|
|
validator = "niri validate -c ",
|
|
},
|
|
MangoWC = {
|
|
managedName = "keymap.conf",
|
|
comment = "#",
|
|
includeLine = "source=./keymap.conf",
|
|
reloadCommand = "mmsg dispatch reload_config",
|
|
validator = "mango -c ",
|
|
validatorSuffix = " -p",
|
|
},
|
|
}
|
|
|
|
local VALID_MODIFIERS = { SUPER = true, CTRL = true, SHIFT = true, ALT = true }
|
|
|
|
local COMMAND_LIBRARY_ENTRIES = {}
|
|
do
|
|
local encoded = noctalia.readFile("command_library.json")
|
|
if type(encoded) == "string" and type(noctalia.json) == "table"
|
|
and type(noctalia.json.decode) == "function" then
|
|
local ok, decoded = pcall(noctalia.json.decode, encoded)
|
|
if ok and type(decoded) == "table" and decoded.schema == 1
|
|
and type(decoded.entries) == "table" then
|
|
for _, entry in ipairs(decoded.entries) do
|
|
if type(entry) == "table" and type(entry.id) == "string" then
|
|
COMMAND_LIBRARY_ENTRIES[entry.id] = entry
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
local function trim(value)
|
|
if type(value) ~= "string" then return "" end
|
|
return value:match("^%s*(.-)%s*$") or ""
|
|
end
|
|
|
|
local function dirname(path)
|
|
local directory = path:match("^(.*)/[^/]*$")
|
|
return directory ~= nil and directory ~= "" and directory or "/"
|
|
end
|
|
|
|
local function hasUnsafeLineCharacters(value)
|
|
return value:find("%c") ~= nil
|
|
end
|
|
|
|
local function validateText(value, name, allowEmpty, maxBytes)
|
|
if type(value) ~= "string" then return nil, "invalid_" .. name end
|
|
if hasUnsafeLineCharacters(value) then return nil, "invalid_" .. name end
|
|
if not allowEmpty and trim(value) == "" then return nil, "empty_" .. name end
|
|
if maxBytes ~= nil and #value > maxBytes then return nil, name .. "_too_large" end
|
|
return value, nil
|
|
end
|
|
|
|
local function validateCategory(value)
|
|
local category, errorCode = validateText(value, "category", false, 80)
|
|
if errorCode ~= nil then return nil, errorCode end
|
|
category = trim(category)
|
|
if category:find('"', 1, true) ~= nil
|
|
or category:find("Keymap managed", 1, true) ~= nil then
|
|
return nil, "category_invalid"
|
|
end
|
|
return category, nil
|
|
end
|
|
|
|
local function escapePattern(value)
|
|
return (value:gsub("([^%w])", "%%%1"))
|
|
end
|
|
|
|
local function matchesLibraryTemplate(template, value)
|
|
if type(template) ~= "string" then return false end
|
|
local pattern = "^"
|
|
local cursor = 1
|
|
while true do
|
|
local first, last = template:find("{{[^}]+}}", cursor)
|
|
if first == nil then
|
|
pattern = pattern .. escapePattern(template:sub(cursor))
|
|
break
|
|
end
|
|
pattern = pattern .. escapePattern(template:sub(cursor, first - 1)) .. "(.+)"
|
|
cursor = last + 1
|
|
end
|
|
return value:match(pattern .. "$") ~= nil
|
|
end
|
|
|
|
local function validateCommandLibrary(raw, compositor, command)
|
|
local commandKind = raw.command_kind == nil and "shell" or raw.command_kind
|
|
if commandKind ~= "shell" and commandKind ~= "native" then
|
|
return nil, nil, "invalid_command_kind"
|
|
end
|
|
local libraryEntryId = type(raw.library_entry_id) == "string" and raw.library_entry_id or ""
|
|
if commandKind == "shell" then return commandKind, libraryEntryId, nil end
|
|
if libraryEntryId == "" then return nil, nil, "library_entry_invalid" end
|
|
local entry = COMMAND_LIBRARY_ENTRIES[libraryEntryId]
|
|
local expectedSource = ({ Hyprland = "hyprland", Niri = "niri", MangoWC = "mangowc" })[compositor]
|
|
if type(entry) ~= "table" or entry.kind ~= "native" or entry.source ~= expectedSource
|
|
or command:find("{{", 1, true) ~= nil
|
|
or not matchesLibraryTemplate(entry.template, command) then
|
|
return nil, nil, command:find("{{", 1, true) ~= nil
|
|
and "library_arguments_required" or "library_entry_invalid"
|
|
end
|
|
return commandKind, libraryEntryId, nil
|
|
end
|
|
|
|
local function validateRequest(raw)
|
|
if type(raw) ~= "table" then return nil, "invalid_request" end
|
|
|
|
local rawRequestId = raw.request_id
|
|
if type(rawRequestId) == "number" then rawRequestId = tostring(rawRequestId) end
|
|
local requestId, errorCode = validateText(rawRequestId, "request_id", false, 256)
|
|
if errorCode ~= nil then return nil, errorCode end
|
|
local compositor = raw.compositor
|
|
local format = FORMATS[compositor]
|
|
if format == nil then return nil, "invalid_compositor" end
|
|
local source
|
|
source, errorCode = validateText(raw.source, "source", false, 4096)
|
|
if errorCode ~= nil then return nil, errorCode end
|
|
if source:sub(1, 1) ~= "/" then return nil, "source_not_absolute" end
|
|
if source:match("([^/]+)$") == format.managedName then
|
|
return nil, "source_is_managed_file"
|
|
end
|
|
|
|
local activation = raw.activation
|
|
if activation ~= "press" and activation ~= "release" then return nil, "invalid_activation" end
|
|
if compositor == "Niri" and activation ~= "press" then return nil, "niri_release_unsupported" end
|
|
|
|
local command
|
|
command, errorCode = validateText(raw.command, "command", false, MAX_COMMAND_BYTES)
|
|
if errorCode ~= nil then return nil, errorCode end
|
|
local commandKind, libraryEntryId
|
|
commandKind, libraryEntryId, errorCode = validateCommandLibrary(raw, compositor, command)
|
|
if errorCode ~= nil then return nil, errorCode end
|
|
local description
|
|
description, errorCode = validateText(raw.description, "description", false, 1024)
|
|
if errorCode ~= nil then return nil, errorCode end
|
|
local category
|
|
category, errorCode = validateCategory(raw.category)
|
|
if errorCode ~= nil then return nil, errorCode end
|
|
if compositor == "MangoWC" and command:find("#", 1, true) ~= nil then
|
|
return nil, "mango_command_hash_unsupported"
|
|
end
|
|
|
|
if type(raw.modifiers) ~= "table" then return nil, "invalid_modifiers" end
|
|
local modifiers = {}
|
|
local seenModifiers = {}
|
|
for _, modifier in ipairs(raw.modifiers) do
|
|
if type(modifier) ~= "string" or not VALID_MODIFIERS[modifier] or seenModifiers[modifier] then
|
|
return nil, "invalid_modifiers"
|
|
end
|
|
seenModifiers[modifier] = true
|
|
modifiers[#modifiers + 1] = modifier
|
|
end
|
|
|
|
if type(raw.keys) ~= "table" or #raw.keys == 0 then return nil, "empty_keys" end
|
|
if compositor ~= "Hyprland" and #raw.keys ~= 1 then return nil, "multiple_keys_unsupported" end
|
|
if #raw.keys > 16 then return nil, "too_many_keys" end
|
|
local keys = {}
|
|
local seenKeys = {}
|
|
for _, key in ipairs(raw.keys) do
|
|
local checked
|
|
checked, errorCode = validateText(key, "key", false, 128)
|
|
if errorCode ~= nil then return nil, errorCode end
|
|
-- Target key names are tokens in all three compositor grammars. Reject
|
|
-- delimiters instead of attempting to quote compositor syntax itself.
|
|
if not checked:match("^[%w_:%-]+$") then return nil, "invalid_key" end
|
|
if seenKeys[checked] then return nil, "duplicate_key" end
|
|
seenKeys[checked] = true
|
|
keys[#keys + 1] = checked
|
|
end
|
|
|
|
return {
|
|
request_id = requestId,
|
|
compositor = compositor,
|
|
format = format,
|
|
source = source,
|
|
modifiers = modifiers,
|
|
keys = keys,
|
|
activation = activation,
|
|
command = command,
|
|
command_kind = commandKind,
|
|
library_entry_id = libraryEntryId,
|
|
description = description,
|
|
category = category,
|
|
}, nil
|
|
end
|
|
|
|
local function validateCurrentContext(request)
|
|
local snapshot = noctalia.state.get(SNAPSHOT_KEY)
|
|
if type(snapshot) ~= "table" or snapshot.status ~= "ready" then return false end
|
|
return snapshot.compositor == request.compositor and snapshot.source == request.source
|
|
end
|
|
|
|
local KEY_EQUIVALENTS = {
|
|
RETURN = "ENTER", ESCAPE = "ESC", PRINT = "PRTSC", SCROLL_LOCK = "SCROLL LOCK",
|
|
PRIOR = "PGUP", NEXT = "PGDN", KP_ENTER = "NUM ENTER", KP_ADD = "NUM +",
|
|
KP_SUBTRACT = "NUM -", KP_MULTIPLY = "NUM *", KP_DIVIDE = "NUM /", KP_DECIMAL = "NUM .",
|
|
}
|
|
|
|
local function comparableKey(value)
|
|
local key = trim(tostring(value or "")):upper():gsub("%s+", " ")
|
|
return KEY_EQUIVALENTS[key] or key
|
|
end
|
|
|
|
local function comparableModifiers(modifiers)
|
|
local result = {}
|
|
for _, modifier in ipairs(type(modifiers) == "table" and modifiers or {}) do
|
|
local value = tostring(modifier):upper()
|
|
if value == "MOD" or value == "META" or value == "WIN" or value == "LOGO" then value = "SUPER" end
|
|
if value == "CONTROL" then value = "CTRL" end
|
|
result[#result + 1] = value
|
|
end
|
|
table.sort(result)
|
|
return table.concat(result, "+")
|
|
end
|
|
|
|
local function comparableKeys(values)
|
|
local result = {}
|
|
for _, value in ipairs(type(values) == "table" and values or {}) do
|
|
result[#result + 1] = comparableKey(value)
|
|
end
|
|
return result
|
|
end
|
|
|
|
local function conflictsWithSnapshot(request, excludedId)
|
|
local snapshot = noctalia.state.get(SNAPSHOT_KEY)
|
|
local modifiers = comparableModifiers(request.modifiers)
|
|
local keys = comparableKeys(request.keys)
|
|
for _, category in ipairs(type(snapshot) == "table" and snapshot.categories or {}) do
|
|
for _, bind in ipairs(type(category.binds) == "table" and category.binds or {}) do
|
|
local bindKeys = comparableKeys(type(bind.keys) == "table" and bind.keys or { bind.key })
|
|
local sameKeys = #bindKeys == #keys
|
|
if sameKeys then
|
|
for index, key in ipairs(keys) do
|
|
if bindKeys[index] ~= key then sameKeys = false break end
|
|
end
|
|
end
|
|
if tostring(bind.id or "") ~= tostring(excludedId or "") and sameKeys
|
|
and comparableModifiers(bind.modifiers) == modifiers
|
|
and tostring(bind.activation or "press") == request.activation then
|
|
return true
|
|
end
|
|
end
|
|
end
|
|
return false
|
|
end
|
|
|
|
local function publishResult(requestId, ok, errorCode, managedPath)
|
|
noctalia.state.set(CREATE_RESULT_KEY, {
|
|
request_id = requestId or "",
|
|
ok = ok == true,
|
|
error = errorCode or "",
|
|
managed_path = managedPath or "",
|
|
})
|
|
end
|
|
|
|
local function publishUpdateResult(requestId, ok, errorCode, targetPath)
|
|
noctalia.state.set(UPDATE_RESULT_KEY, {
|
|
request_id = requestId or "", ok = ok == true, error = errorCode or "", target_path = targetPath or "",
|
|
})
|
|
end
|
|
|
|
local function publishOperationResult(request, ok, errorCode, path)
|
|
if request.silentMigration == true then
|
|
noctalia.state.set(MIGRATION_RESULT_KEY, {
|
|
ok = ok == true, error = errorCode or "", source = request.source or "",
|
|
legacy_path = path or "",
|
|
})
|
|
if ok ~= true then automaticMigrationAttempt = "" end
|
|
return
|
|
end
|
|
if request.operation ~= nil then
|
|
publishUpdateResult(request.request_id, ok, errorCode, path)
|
|
else
|
|
publishResult(request.request_id, ok, errorCode, path)
|
|
end
|
|
end
|
|
|
|
local function luaLongString(value)
|
|
local equals = ""
|
|
while value:find("]" .. equals .. "]", 1, true) ~= nil do
|
|
equals = equals .. "="
|
|
end
|
|
return "[" .. equals .. "[" .. value .. "]" .. equals .. "]"
|
|
end
|
|
|
|
local function quoted(value)
|
|
return '"' .. value:gsub("\\", "\\\\"):gsub('"', '\\"'):gsub("\t", "\\t") .. '"'
|
|
end
|
|
|
|
local function hyprCombo(request)
|
|
local parts = {}
|
|
for _, modifier in ipairs(request.modifiers) do parts[#parts + 1] = modifier end
|
|
for _, key in ipairs(request.keys) do parts[#parts + 1] = key end
|
|
return table.concat(parts, " + ")
|
|
end
|
|
|
|
local function niriCombo(request)
|
|
local parts = {}
|
|
local aliases = { SUPER = "Mod", CTRL = "Ctrl", SHIFT = "Shift", ALT = "Alt" }
|
|
for _, modifier in ipairs(request.modifiers) do parts[#parts + 1] = aliases[modifier] end
|
|
parts[#parts + 1] = request.keys[1]
|
|
return table.concat(parts, "+")
|
|
end
|
|
|
|
local function mangoCombo(request)
|
|
return #request.modifiers > 0 and table.concat(request.modifiers, "+") or "NONE"
|
|
end
|
|
|
|
local function managedHeader(compositor)
|
|
local prefix = FORMATS[compositor].comment
|
|
return prefix .. " Managed by Noctalia Keymap.\n"
|
|
.. prefix .. " Existing entries are preserved; new entries are appended.\n"
|
|
end
|
|
|
|
local function ownsManagedFile(compositor, content)
|
|
return content:sub(1, #managedHeader(compositor)) == managedHeader(compositor)
|
|
end
|
|
|
|
local function generatedEntry(request)
|
|
if request.compositor == "Hyprland" then
|
|
local options = request.activation == "release"
|
|
and ("{ release = true, description = " .. quoted(request.description) .. " }")
|
|
or ("{ description = " .. quoted(request.description) .. " }")
|
|
local dispatcher = request.command_kind == "native"
|
|
and request.command or ("hl.dsp.exec_cmd(" .. luaLongString(request.command) .. ")")
|
|
return "-- 1. " .. request.category .. "\n"
|
|
.. "hl.bind(" .. quoted(hyprCombo(request)) .. ", " .. dispatcher
|
|
.. ", " .. options .. ")\n"
|
|
end
|
|
if request.compositor == "Niri" then
|
|
local action = request.command_kind == "native"
|
|
and request.command or ("spawn-sh " .. quoted(request.command))
|
|
return " //\"" .. request.category .. "\"\n"
|
|
.. " " .. niriCombo(request) .. " repeat=false hotkey-overlay-title=" .. quoted(request.description)
|
|
.. " { " .. action .. "; }\n"
|
|
end
|
|
local directive = request.activation == "release" and "bindr" or "bind"
|
|
local description = request.description:gsub("\\", "\\\\"):gsub('"', '\\"')
|
|
local dispatcher = request.command_kind == "native"
|
|
and request.command or ("spawn_shell," .. request.command)
|
|
return "# Keymap category: " .. request.category .. "\n"
|
|
.. directive .. "=" .. mangoCombo(request) .. "," .. request.keys[1]
|
|
.. "," .. dispatcher .. " #\"" .. description .. "\"\n"
|
|
end
|
|
|
|
local function includeBlock(format)
|
|
local beginMarker = format.comment .. " BEGIN Keymap managed include"
|
|
local endMarker = format.comment .. " END Keymap managed include"
|
|
return beginMarker, endMarker, beginMarker .. "\n" .. format.includeLine .. "\n" .. endMarker
|
|
end
|
|
|
|
local function replaceExactLine(content, expected, replacement)
|
|
local cursor = 1
|
|
while cursor <= #content + 1 do
|
|
local newline = content:find("\n", cursor, true)
|
|
local lineEnd = newline or (#content + 1)
|
|
if trim(content:sub(cursor, lineEnd - 1)) == expected then
|
|
local suffixStart = newline ~= nil and newline + 1 or lineEnd
|
|
return content:sub(1, cursor - 1) .. replacement .. content:sub(suffixStart), true
|
|
end
|
|
if newline == nil then break end
|
|
cursor = newline + 1
|
|
end
|
|
return content, false
|
|
end
|
|
|
|
local function managedPayload(compositor, content)
|
|
if not ownsManagedFile(compositor, content) then return nil, "managed_file_collision" end
|
|
if #content > MAX_MANAGED_BYTES then return nil, "managed_file_too_large" end
|
|
local payload = content:sub(#managedHeader(compositor) + 1)
|
|
if payload:sub(1, 1) == "\n" then payload = payload:sub(2) end
|
|
return payload, nil
|
|
end
|
|
|
|
local function inlineManagedContent(root, format, payload)
|
|
local beginMarker, endMarker, block = includeBlock(format)
|
|
local blockStart, blockEnd = root:find(block, 1, true)
|
|
if blockStart ~= nil then
|
|
local suffixStart = blockEnd + 1
|
|
if payload:sub(-1) == "\n" and root:sub(suffixStart, suffixStart) == "\n" then
|
|
suffixStart = suffixStart + 1
|
|
end
|
|
return root:sub(1, blockStart - 1) .. payload .. root:sub(suffixStart), nil
|
|
end
|
|
if root:find(beginMarker, 1, true) ~= nil or root:find(endMarker, 1, true) ~= nil then
|
|
return nil, "managed_include_invalid"
|
|
end
|
|
local replaced, found = replaceExactLine(root, format.includeLine, payload)
|
|
if found then return replaced, nil end
|
|
local separator = root == "" and "" or (root:sub(-1) == "\n" and "\n" or "\n\n")
|
|
return root .. separator .. payload, nil
|
|
end
|
|
|
|
local function niriBindsClose(content)
|
|
local index = 1
|
|
local depth = 0
|
|
local quote = nil
|
|
local escaped = false
|
|
local lineComment = false
|
|
local blockComment = false
|
|
local bindsDepth = nil
|
|
while index <= #content do
|
|
local char = content:sub(index, index)
|
|
local pair = content:sub(index, index + 1)
|
|
if lineComment then
|
|
if char == "\n" then lineComment = false end
|
|
index = index + 1
|
|
elseif blockComment then
|
|
if pair == "*/" then
|
|
blockComment = false
|
|
index = index + 2
|
|
else
|
|
index = index + 1
|
|
end
|
|
elseif quote ~= nil then
|
|
if escaped then escaped = false
|
|
elseif char == "\\" then escaped = true
|
|
elseif char == quote then quote = nil end
|
|
index = index + 1
|
|
elseif pair == "//" then
|
|
lineComment = true
|
|
index = index + 2
|
|
elseif pair == "/*" then
|
|
blockComment = true
|
|
index = index + 2
|
|
elseif char == '"' or char == "'" then
|
|
quote = char
|
|
index = index + 1
|
|
elseif bindsDepth == nil and depth == 0 and content:sub(index, index + 4) == "binds"
|
|
and (index == 1 or not content:sub(index - 1, index - 1):match("[%w_%-]"))
|
|
and not content:sub(index + 5, index + 5):match("[%w_%-]") then
|
|
local open = index + 5
|
|
while content:sub(open, open):match("%s") do open = open + 1 end
|
|
if content:sub(open, open) == "{" then
|
|
depth = depth + 1
|
|
bindsDepth = depth
|
|
index = open + 1
|
|
else
|
|
index = index + 5
|
|
end
|
|
elseif char == "{" then
|
|
depth = depth + 1
|
|
index = index + 1
|
|
elseif char == "}" then
|
|
if bindsDepth ~= nil and depth == bindsDepth then return index end
|
|
depth = depth - 1
|
|
index = index + 1
|
|
else
|
|
index = index + 1
|
|
end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
local function appendEntryToRoot(compositor, root, entry)
|
|
if root:find(entry, 1, true) ~= nil then return root, nil end
|
|
if compositor == "Niri" then
|
|
local closeAt = niriBindsClose(root)
|
|
if closeAt == nil then
|
|
local separator = root == "" and "" or (root:sub(-1) == "\n" and "\n" or "\n\n")
|
|
return root .. separator .. "binds {\n" .. entry .. "}\n", nil
|
|
end
|
|
local lineStart = (root:sub(1, closeAt - 1):match(".*\n()") or 1)
|
|
if root:sub(lineStart, closeAt - 1):match("^%s*$") then
|
|
return root:sub(1, lineStart - 1) .. entry .. root:sub(lineStart), nil
|
|
end
|
|
return root:sub(1, closeAt - 1) .. "\n" .. entry .. root:sub(closeAt), nil
|
|
end
|
|
if compositor == "MangoWC" then
|
|
local activeKeymode = nil
|
|
for line in (root .. "\n"):gmatch("([^\n]*)\n") do
|
|
local mode = line:match("^%s*keymode%s*=%s*([^%s#]+)")
|
|
if mode ~= nil then activeKeymode = mode end
|
|
end
|
|
if activeKeymode ~= "default" then entry = "keymode=default\n\n" .. entry end
|
|
end
|
|
local separator = root == "" and "" or (root:sub(-1) == "\n" and "\n" or "\n\n")
|
|
return root .. separator .. entry, nil
|
|
end
|
|
|
|
local function atomicWrite(path, content)
|
|
temporaryCounter = temporaryCounter + 1
|
|
local temporary = path .. ".keymap-" .. tostring(os.time())
|
|
.. "-" .. tostring(temporaryCounter) .. ".tmp"
|
|
if noctalia.fileExists(temporary) then return false, "temporary_collision" end
|
|
local written = noctalia.writeFile(temporary, content)
|
|
if written ~= true then
|
|
return false, "write_failed"
|
|
end
|
|
local renamed = noctalia.renameFile(temporary, path)
|
|
if renamed ~= true then
|
|
noctalia.removeFile(temporary)
|
|
return false, "rename_failed"
|
|
end
|
|
return true, nil
|
|
end
|
|
|
|
local function shellQuote(value)
|
|
return "'" .. value:gsub("'", "'\\''") .. "'"
|
|
end
|
|
|
|
local function rollbackTransaction(transaction)
|
|
if transaction.rootWritten then
|
|
local current = noctalia.readFile(transaction.source)
|
|
if current ~= transaction.rootNew then
|
|
-- Keep the managed file when an external edit prevents restoring the
|
|
-- root. The external root may still contain our include.
|
|
return false
|
|
end
|
|
local restored = atomicWrite(transaction.source, transaction.rootOld)
|
|
if restored ~= true then return false end
|
|
end
|
|
if transaction.managedWritten then
|
|
local current = noctalia.readFile(transaction.managedPath)
|
|
if current ~= transaction.managedNew then
|
|
return false
|
|
elseif transaction.managedExisted then
|
|
local restored = atomicWrite(transaction.managedPath, transaction.managedOld)
|
|
if restored ~= true then return false end
|
|
elseif noctalia.removeFile(transaction.managedPath) ~= true then
|
|
return false
|
|
end
|
|
end
|
|
return true
|
|
end
|
|
|
|
local function publishFailureAfterRollback(request, transaction, errorCode, reloadOld)
|
|
local rolledBack = rollbackTransaction(transaction)
|
|
if reloadOld and rolledBack then
|
|
-- A failed reload may still have applied part of the candidate. Restore the
|
|
-- previous on-disk state and ask the compositor to read it once more.
|
|
local started = noctalia.runAsync(request.format.reloadCommand, function(result)
|
|
local recovered = result.timedOut ~= true and result.exitCode == 0
|
|
publishOperationResult(
|
|
request, false, recovered and errorCode or "recovery_reload_failed", transaction.managedPath
|
|
)
|
|
busy = false
|
|
end, RELOAD_TIMEOUT_MS)
|
|
if not started then
|
|
publishOperationResult(request, false, "recovery_reload_failed", transaction.managedPath)
|
|
busy = false
|
|
end
|
|
return
|
|
end
|
|
publishOperationResult(request, false, rolledBack and errorCode or "rollback_failed", transaction.managedPath)
|
|
busy = false
|
|
end
|
|
|
|
local function finishSuccess(request, managedPath)
|
|
local current = tonumber(noctalia.state.get(REFRESH_REQUEST_KEY)) or 0
|
|
noctalia.state.set(REFRESH_REQUEST_KEY, current + 1)
|
|
publishOperationResult(request, true, "", managedPath)
|
|
busy = false
|
|
end
|
|
|
|
local function reloadAndFinish(request, transaction)
|
|
local started = noctalia.runAsync(request.format.reloadCommand, function(result)
|
|
if result.timedOut == true then
|
|
publishFailureAfterRollback(request, transaction, "reload_timeout", true)
|
|
return
|
|
end
|
|
if result.exitCode ~= 0 then
|
|
publishFailureAfterRollback(request, transaction, "reload_failed", true)
|
|
return
|
|
end
|
|
if transaction.removeManagedAfterSuccess then
|
|
if noctalia.readFile(transaction.source) ~= transaction.rootNew
|
|
or noctalia.readFile(transaction.managedPath) ~= transaction.managedOld then
|
|
publishOperationResult(request, false, "migration_cleanup_changed", transaction.managedPath)
|
|
busy = false
|
|
return
|
|
end
|
|
if noctalia.removeFile(transaction.managedPath) ~= true then
|
|
publishFailureAfterRollback(request, transaction, "migration_remove_failed", true)
|
|
return
|
|
end
|
|
end
|
|
local successPath = request.operation == nil and transaction.source or transaction.managedPath
|
|
finishSuccess(request, successPath)
|
|
end, RELOAD_TIMEOUT_MS)
|
|
if not started then
|
|
publishFailureAfterRollback(request, transaction, "reload_start_failed", true)
|
|
end
|
|
end
|
|
|
|
local function verifyAndReload(request, transaction)
|
|
local command = request.format.validator .. shellQuote(request.source)
|
|
.. (request.format.validatorSuffix or "")
|
|
local started = noctalia.runAsync(command, function(result)
|
|
if result.timedOut == true then
|
|
publishFailureAfterRollback(request, transaction, "verify_timeout", false)
|
|
return
|
|
end
|
|
if result.exitCode ~= 0 then
|
|
local errorCode = result.exitCode == 127 and "validator_unavailable" or "verify_failed"
|
|
publishFailureAfterRollback(request, transaction, errorCode, false)
|
|
return
|
|
end
|
|
reloadAndFinish(request, transaction)
|
|
end, VERIFY_TIMEOUT_MS)
|
|
if not started then
|
|
publishFailureAfterRollback(request, transaction, "verify_start_failed", false)
|
|
end
|
|
end
|
|
|
|
local function rollbackFileSet(files)
|
|
for index = #files, 1, -1 do
|
|
local file = files[index]
|
|
if file.written then
|
|
if noctalia.readFile(file.path) ~= file.new then return false end
|
|
if atomicWrite(file.path, file.old) ~= true then return false end
|
|
end
|
|
end
|
|
return true
|
|
end
|
|
|
|
local function publishFileSetFailure(request, files, errorCode, reloadOld)
|
|
local rolledBack = rollbackFileSet(files)
|
|
if reloadOld and rolledBack then
|
|
local started = noctalia.runAsync(request.format.reloadCommand, function(result)
|
|
local recovered = result.timedOut ~= true and result.exitCode == 0
|
|
publishUpdateResult(
|
|
request.request_id, false,
|
|
recovered and errorCode or "recovery_reload_failed", request.source
|
|
)
|
|
busy = false
|
|
end, RELOAD_TIMEOUT_MS)
|
|
if not started then
|
|
publishUpdateResult(request.request_id, false, "recovery_reload_failed", request.source)
|
|
busy = false
|
|
end
|
|
return
|
|
end
|
|
publishUpdateResult(
|
|
request.request_id, false, rolledBack and errorCode or "rollback_failed", request.source
|
|
)
|
|
busy = false
|
|
end
|
|
|
|
local function reloadFileSetAndFinish(request, files)
|
|
local started = noctalia.runAsync(request.format.reloadCommand, function(result)
|
|
if result.timedOut == true then
|
|
publishFileSetFailure(request, files, "reload_timeout", true)
|
|
return
|
|
end
|
|
if result.exitCode ~= 0 then
|
|
publishFileSetFailure(request, files, "reload_failed", true)
|
|
return
|
|
end
|
|
finishSuccess(request, request.source)
|
|
end, RELOAD_TIMEOUT_MS)
|
|
if not started then publishFileSetFailure(request, files, "reload_start_failed", true) end
|
|
end
|
|
|
|
local function verifyFileSetAndReload(request, files)
|
|
local command = request.format.validator .. shellQuote(request.source)
|
|
.. (request.format.validatorSuffix or "")
|
|
local started = noctalia.runAsync(command, function(result)
|
|
if result.timedOut == true then
|
|
publishFileSetFailure(request, files, "verify_timeout", false)
|
|
return
|
|
end
|
|
if result.exitCode ~= 0 then
|
|
local errorCode = result.exitCode == 127 and "validator_unavailable" or "verify_failed"
|
|
publishFileSetFailure(request, files, errorCode, false)
|
|
return
|
|
end
|
|
reloadFileSetAndFinish(request, files)
|
|
end, VERIFY_TIMEOUT_MS)
|
|
if not started then publishFileSetFailure(request, files, "verify_start_failed", false) end
|
|
end
|
|
|
|
local processValidatedRequest
|
|
|
|
local function processRequest(raw)
|
|
local fallbackId = ""
|
|
if type(raw) == "table" then
|
|
local rawId = raw.request_id
|
|
if type(rawId) == "number" then rawId = tostring(rawId) end
|
|
if type(rawId) == "string" and #rawId <= 256 and not hasUnsafeLineCharacters(rawId) then
|
|
fallbackId = rawId
|
|
end
|
|
end
|
|
if busy then
|
|
publishResult(fallbackId, false, "busy", "")
|
|
return
|
|
end
|
|
local request, errorCode = validateRequest(raw)
|
|
if request == nil then
|
|
publishResult(fallbackId, false, errorCode, "")
|
|
return
|
|
end
|
|
if not validateCurrentContext(request) then
|
|
publishResult(request.request_id, false, "stale_context", "")
|
|
return
|
|
end
|
|
busy = true
|
|
local managedPath = dirname(request.source) .. "/" .. request.format.managedName
|
|
local preflight = "[ ! -L " .. shellQuote(request.source) .. " ] && [ ! -L "
|
|
.. shellQuote(managedPath) .. " ]"
|
|
local started = noctalia.runAsync(preflight, function(result)
|
|
if result.timedOut == true or result.exitCode ~= 0 then
|
|
publishResult(request.request_id, false, "symlink_unsupported", managedPath)
|
|
busy = false
|
|
return
|
|
end
|
|
processValidatedRequest(request)
|
|
end, 2000)
|
|
if not started then
|
|
publishResult(request.request_id, false, "preflight_failed", managedPath)
|
|
busy = false
|
|
end
|
|
end
|
|
|
|
processValidatedRequest = function(request)
|
|
local root = noctalia.readFile(request.source)
|
|
if type(root) ~= "string" then
|
|
publishResult(request.request_id, false, "source_unreadable", "")
|
|
busy = false
|
|
return
|
|
end
|
|
if #root > MAX_ROOT_BYTES then
|
|
publishResult(request.request_id, false, "source_too_large", "")
|
|
busy = false
|
|
return
|
|
end
|
|
|
|
local managedPath = dirname(request.source) .. "/" .. request.format.managedName
|
|
local existing = nil
|
|
if noctalia.fileExists(managedPath) then
|
|
existing = noctalia.readFile(managedPath)
|
|
if type(existing) ~= "string" then
|
|
publishResult(request.request_id, false, "managed_file_unreadable", managedPath)
|
|
busy = false
|
|
return
|
|
end
|
|
end
|
|
local entry = generatedEntry(request)
|
|
local existingExactEntry = root:find(entry, 1, true) ~= nil
|
|
or (existing ~= nil and existing:find(entry, 1, true) ~= nil)
|
|
if conflictsWithSnapshot(request) and not existingExactEntry then
|
|
publishResult(request.request_id, false, "conflict_blocked", managedPath)
|
|
busy = false
|
|
return
|
|
end
|
|
|
|
local migratedRoot = root
|
|
if existing ~= nil then
|
|
local payload, payloadError = managedPayload(request.compositor, existing)
|
|
if payload == nil then
|
|
publishResult(request.request_id, false, payloadError, managedPath)
|
|
busy = false
|
|
return
|
|
end
|
|
local migrationError
|
|
migratedRoot, migrationError = inlineManagedContent(root, request.format, payload)
|
|
if migratedRoot == nil then
|
|
publishResult(request.request_id, false, migrationError, managedPath)
|
|
busy = false
|
|
return
|
|
end
|
|
end
|
|
local newRoot, rootError = appendEntryToRoot(request.compositor, migratedRoot, entry)
|
|
if newRoot == nil then
|
|
publishResult(request.request_id, false, rootError, managedPath)
|
|
busy = false
|
|
return
|
|
end
|
|
if #newRoot > MAX_ROOT_BYTES then
|
|
publishResult(request.request_id, false, "source_too_large", managedPath)
|
|
busy = false
|
|
return
|
|
end
|
|
|
|
-- Refuse to overwrite an edit made after the initial read. This check is
|
|
-- intentionally immediately before the first rename.
|
|
if noctalia.readFile(request.source) ~= root then
|
|
publishResult(request.request_id, false, "source_changed", managedPath)
|
|
busy = false
|
|
return
|
|
end
|
|
if existing ~= nil then
|
|
if noctalia.readFile(managedPath) ~= existing then
|
|
publishResult(request.request_id, false, "managed_file_changed", managedPath)
|
|
busy = false
|
|
return
|
|
end
|
|
elseif noctalia.fileExists(managedPath) then
|
|
publishResult(request.request_id, false, "managed_file_changed", managedPath)
|
|
busy = false
|
|
return
|
|
end
|
|
|
|
local transaction = {
|
|
source = request.source,
|
|
rootOld = root,
|
|
rootNew = newRoot,
|
|
rootWritten = false,
|
|
managedPath = managedPath,
|
|
managedOld = existing,
|
|
managedNew = existing,
|
|
managedExisted = existing ~= nil,
|
|
managedWritten = false,
|
|
removeManagedAfterSuccess = existing ~= nil,
|
|
}
|
|
if root ~= newRoot then
|
|
if noctalia.readFile(request.source) ~= root then
|
|
publishFailureAfterRollback(request, transaction, "source_changed", false)
|
|
return
|
|
end
|
|
local ok, writeError = atomicWrite(request.source, newRoot)
|
|
if not ok then
|
|
publishFailureAfterRollback(request, transaction, "source_" .. writeError, false)
|
|
return
|
|
end
|
|
transaction.rootWritten = true
|
|
end
|
|
if not transaction.managedWritten and not transaction.rootWritten then
|
|
if transaction.removeManagedAfterSuccess then
|
|
if noctalia.readFile(managedPath) ~= existing then
|
|
publishResult(request.request_id, false, "migration_cleanup_changed", managedPath)
|
|
busy = false
|
|
return
|
|
end
|
|
if noctalia.removeFile(managedPath) ~= true then
|
|
publishResult(request.request_id, false, "migration_remove_failed", managedPath)
|
|
busy = false
|
|
return
|
|
end
|
|
end
|
|
finishSuccess(request, request.source)
|
|
return
|
|
end
|
|
verifyAndReload(request, transaction)
|
|
end
|
|
|
|
local function processAutomaticMigration(snapshotValue)
|
|
if busy or type(snapshotValue) ~= "table" or snapshotValue.status ~= "ready" then return end
|
|
local compositor = snapshotValue.compositor
|
|
local format = FORMATS[compositor]
|
|
local source = snapshotValue.source
|
|
if format == nil or type(source) ~= "string" or source:sub(1, 1) ~= "/"
|
|
or source:match("([^/]+)$") == format.managedName then return end
|
|
local managedPath = dirname(source) .. "/" .. format.managedName
|
|
if not noctalia.fileExists(managedPath) then return end
|
|
local signature = compositor .. "\n" .. source
|
|
if automaticMigrationAttempt == signature then return end
|
|
automaticMigrationAttempt = signature
|
|
busy = true
|
|
local request = {
|
|
request_id = "automatic-migration", compositor = compositor, source = source,
|
|
format = format, silentMigration = true,
|
|
}
|
|
local function fail(errorCode)
|
|
publishOperationResult(request, false, errorCode, managedPath)
|
|
busy = false
|
|
end
|
|
local preflight = "[ ! -L " .. shellQuote(source) .. " ] && [ ! -L "
|
|
.. shellQuote(managedPath) .. " ]"
|
|
local started = noctalia.runAsync(preflight, function(result)
|
|
if result.timedOut == true or result.exitCode ~= 0 then
|
|
fail("symlink_unsupported")
|
|
return
|
|
end
|
|
local root = noctalia.readFile(source)
|
|
local existing = noctalia.readFile(managedPath)
|
|
if type(root) ~= "string" then fail("source_unreadable") return end
|
|
if type(existing) ~= "string" then fail("managed_file_unreadable") return end
|
|
if #root > MAX_ROOT_BYTES then fail("source_too_large") return end
|
|
local payload, payloadError = managedPayload(compositor, existing)
|
|
if payload == nil then fail(payloadError) return end
|
|
local newRoot, migrationError = inlineManagedContent(root, format, payload)
|
|
if newRoot == nil then fail(migrationError) return end
|
|
if #newRoot > MAX_ROOT_BYTES then fail("source_too_large") return end
|
|
if noctalia.readFile(source) ~= root or noctalia.readFile(managedPath) ~= existing then
|
|
fail("source_changed")
|
|
return
|
|
end
|
|
local transaction = {
|
|
source = source, rootOld = root, rootNew = newRoot, rootWritten = false,
|
|
managedPath = managedPath, managedOld = existing, managedNew = existing,
|
|
managedExisted = true, managedWritten = false, removeManagedAfterSuccess = true,
|
|
}
|
|
if root ~= newRoot then
|
|
local ok, writeError = atomicWrite(source, newRoot)
|
|
if not ok then fail("source_" .. writeError) return end
|
|
transaction.rootWritten = true
|
|
end
|
|
verifyAndReload(request, transaction)
|
|
end, 2000)
|
|
if not started then fail("preflight_failed") end
|
|
end
|
|
|
|
local function xorNibbleSlow(left, right)
|
|
local result, place = 0, 1
|
|
for _ = 1, 4 do
|
|
if left % 2 ~= right % 2 then result = result + place end
|
|
left = math.floor(left / 2)
|
|
right = math.floor(right / 2)
|
|
place = place * 2
|
|
end
|
|
return result
|
|
end
|
|
|
|
local xorNibbles = {}
|
|
for left = 0, 15 do
|
|
xorNibbles[left] = {}
|
|
for right = 0, 15 do
|
|
xorNibbles[left][right] = xorNibbleSlow(left, right)
|
|
end
|
|
end
|
|
|
|
local function xorByte(left, right)
|
|
return xorNibbles[left % 16][right % 16]
|
|
+ xorNibbles[math.floor(left / 16)][math.floor(right / 16)] * 16
|
|
end
|
|
|
|
local xorByteFast = type(bit32) == "table" and type(bit32.bxor) == "function" and bit32.bxor or xorByte
|
|
|
|
local function contentFingerprint(value)
|
|
local hash = 2166136261
|
|
for index = 1, #value do
|
|
local low = hash % 256
|
|
hash = hash - low + xorByteFast(low, value:byte(index))
|
|
hash = (hash * 403 + (hash % 256) * 16777216) % 4294967296
|
|
end
|
|
return string.format("%08x", hash)
|
|
end
|
|
|
|
local function sourceFingerprintMatches(value, expected)
|
|
return expected == EXACT_SOURCE_FINGERPRINT or contentFingerprint(value) == expected
|
|
end
|
|
|
|
local function splitLines(content)
|
|
local lines, cursor = {}, 1
|
|
while cursor <= #content do
|
|
local newline = content:find("\n", cursor, true)
|
|
if newline == nil then
|
|
lines[#lines + 1] = content:sub(cursor)
|
|
break
|
|
end
|
|
lines[#lines + 1] = content:sub(cursor, newline - 1)
|
|
cursor = newline + 1
|
|
end
|
|
return lines, content:sub(-1) == "\n"
|
|
end
|
|
|
|
local function replaceLineRange(content, firstLine, lastLine, expected, replacement)
|
|
local lines, trailingNewline = splitLines(content)
|
|
if firstLine < 1 or lastLine < firstLine or lastLine > #lines then return nil, "target_range_invalid" end
|
|
local current = table.concat(lines, "\n", firstLine, lastLine)
|
|
if current ~= expected then
|
|
return nil, "target_changed"
|
|
end
|
|
local replacementLines = {}
|
|
if replacement ~= "" then
|
|
for line in (replacement .. "\n"):gmatch("([^\n]*)\n") do replacementLines[#replacementLines + 1] = line end
|
|
end
|
|
local output = {}
|
|
for index = 1, firstLine - 1 do output[#output + 1] = lines[index] end
|
|
for _, line in ipairs(replacementLines) do output[#output + 1] = line end
|
|
for index = lastLine + 1, #lines do output[#output + 1] = lines[index] end
|
|
return table.concat(output, "\n") .. (trailingNewline and "\n" or ""), nil
|
|
end
|
|
|
|
local function reorderLineRanges(content, target, anchor, placement, replacementRaw)
|
|
local lines, trailingNewline = splitLines(content)
|
|
local targetFirst, targetLast = tonumber(target.start_line), tonumber(target.end_line)
|
|
local anchorFirst, anchorLast = tonumber(anchor.start_line), tonumber(anchor.end_line)
|
|
if targetFirst < 1 or targetLast < targetFirst or targetLast > #lines then
|
|
return nil, "target_range_invalid"
|
|
end
|
|
if anchorFirst < 1 or anchorLast < anchorFirst or anchorLast > #lines then
|
|
return nil, "anchor_range_invalid"
|
|
end
|
|
if not (targetLast < anchorFirst or anchorLast < targetFirst) then
|
|
return nil, "target_anchor_overlap"
|
|
end
|
|
|
|
local targetRaw = tostring(target.raw_snippet or "")
|
|
replacementRaw = replacementRaw == nil and targetRaw or tostring(replacementRaw)
|
|
local anchorRaw = tostring(anchor.raw_snippet or "")
|
|
local currentTarget = table.concat(lines, "\n", targetFirst, targetLast)
|
|
local currentAnchor = table.concat(lines, "\n", anchorFirst, anchorLast)
|
|
if currentTarget ~= targetRaw
|
|
or not sourceFingerprintMatches(currentTarget, tostring(target.fingerprint or "")) then
|
|
return nil, "target_changed"
|
|
end
|
|
if currentAnchor ~= anchorRaw
|
|
or not sourceFingerprintMatches(currentAnchor, tostring(anchor.fingerprint or "")) then
|
|
return nil, "anchor_changed"
|
|
end
|
|
|
|
if replacementRaw == targetRaw and (placement == "before" and targetLast + 1 == anchorFirst
|
|
or placement == "after" and anchorLast + 1 == targetFirst) then
|
|
return content, nil
|
|
end
|
|
|
|
local targetLines = splitLines(replacementRaw)
|
|
local remaining = {}
|
|
for index, line in ipairs(lines) do
|
|
if index < targetFirst or index > targetLast then remaining[#remaining + 1] = line end
|
|
end
|
|
local targetLength = targetLast - targetFirst + 1
|
|
if targetFirst < anchorFirst then
|
|
anchorFirst = anchorFirst - targetLength
|
|
anchorLast = anchorLast - targetLength
|
|
end
|
|
local insertionIndex = placement == "before" and anchorFirst or anchorLast + 1
|
|
local output = {}
|
|
for index = 1, insertionIndex - 1 do output[#output + 1] = remaining[index] end
|
|
for _, line in ipairs(targetLines) do output[#output + 1] = line end
|
|
for index = insertionIndex, #remaining do output[#output + 1] = remaining[index] end
|
|
return table.concat(output, "\n") .. (trailingNewline and "\n" or ""), nil
|
|
end
|
|
|
|
local function hexEncode(value)
|
|
local output = {}
|
|
for index = 1, #value do output[#output + 1] = string.format("%02x", value:byte(index)) end
|
|
return table.concat(output)
|
|
end
|
|
|
|
local function hexDecode(value)
|
|
if value == "" or #value % 2 ~= 0 or value:find("[^0-9a-f]") ~= nil then return nil end
|
|
local output = {}
|
|
for index = 1, #value, 2 do
|
|
local byte = tonumber(value:sub(index, index + 1), 16)
|
|
if byte == nil then return nil end
|
|
output[#output + 1] = string.char(byte)
|
|
end
|
|
return table.concat(output)
|
|
end
|
|
|
|
-- Hidden blocks are deliberately recognizable only when written by this
|
|
-- plugin. Ordinary commented-out bindings remain ordinary user comments and
|
|
-- are never offered as restorable targets. The original snippet is hex encoded
|
|
-- as bytes so multiline entries, indentation, CRLF and arbitrary command text
|
|
-- can be restored without interpreting or normalizing compositor syntax.
|
|
local function hiddenSnippet(compositor, snippet)
|
|
local prefix = FORMATS[compositor].comment
|
|
local indent = snippet:match("^([\t ]*)") or ""
|
|
local originalFingerprint = contentFingerprint(snippet)
|
|
local blockId = contentFingerprint(compositor .. "\0" .. snippet)
|
|
local output = {}
|
|
local marker = indent .. prefix .. " Keymap hidden " .. HIDDEN_BLOCK_VERSION
|
|
output[#output + 1] = marker .. " begin " .. blockId .. " " .. originalFingerprint
|
|
for offset = 1, #snippet, HIDDEN_DATA_CHUNK_BYTES do
|
|
output[#output + 1] = marker .. " data " .. hexEncode(snippet:sub(offset, offset + HIDDEN_DATA_CHUNK_BYTES - 1))
|
|
end
|
|
output[#output + 1] = marker .. " end " .. blockId
|
|
return table.concat(output, "\n")
|
|
end
|
|
|
|
local function parseHiddenSnippet(compositor, snippet)
|
|
local format = FORMATS[compositor]
|
|
if format == nil or type(snippet) ~= "string" or snippet == "" then return nil, "hidden_block_invalid" end
|
|
local lines, trailingNewline = splitLines(snippet)
|
|
if trailingNewline or #lines < 3 then return nil, "hidden_block_invalid" end
|
|
local escapedPrefix = format.comment:gsub("(%W)", "%%%1")
|
|
local markerPattern = "^([\t ]*)" .. escapedPrefix .. " Keymap hidden "
|
|
.. HIDDEN_BLOCK_VERSION
|
|
local indent, blockId, expectedFingerprint = lines[1]:match(
|
|
markerPattern .. " begin ([0-9a-f]+) ([0-9a-f]+)$"
|
|
)
|
|
if indent == nil or #blockId ~= 8 or #expectedFingerprint ~= 8 then
|
|
return nil, "hidden_block_invalid"
|
|
end
|
|
local marker = indent .. format.comment .. " Keymap hidden " .. HIDDEN_BLOCK_VERSION
|
|
if lines[#lines] ~= marker .. " end " .. blockId then return nil, "hidden_block_invalid" end
|
|
local encoded = {}
|
|
for index = 2, #lines - 1 do
|
|
local chunk = lines[index]:match("^" .. marker:gsub("(%W)", "%%%1") .. " data ([0-9a-f]+)$")
|
|
if chunk == nil or #chunk > HIDDEN_DATA_CHUNK_BYTES * 2 or #chunk % 2 ~= 0 then
|
|
return nil, "hidden_block_invalid"
|
|
end
|
|
encoded[#encoded + 1] = chunk
|
|
end
|
|
local original = hexDecode(table.concat(encoded))
|
|
if original == nil or original == "" or #original > MAX_ROOT_BYTES then return nil, "hidden_block_invalid" end
|
|
if contentFingerprint(original) ~= expectedFingerprint
|
|
or contentFingerprint(compositor .. "\0" .. original) ~= blockId then
|
|
return nil, "hidden_block_invalid"
|
|
end
|
|
return {
|
|
original = original, original_fingerprint = expectedFingerprint, block_id = blockId,
|
|
}, nil
|
|
end
|
|
|
|
local function replaceLiteralAfter(text, prefixPattern, value, useLongString)
|
|
local _, prefixEnd = text:find(prefixPattern)
|
|
if prefixEnd == nil then return nil end
|
|
local startIndex = prefixEnd + 1
|
|
while text:sub(startIndex, startIndex):match("%s") do startIndex = startIndex + 1 end
|
|
local first = text:sub(startIndex, startIndex)
|
|
local endIndex
|
|
if first == '"' or first == "'" then
|
|
local escaped = false
|
|
for index = startIndex + 1, #text do
|
|
local char = text:sub(index, index)
|
|
if escaped then escaped = false
|
|
elseif char == "\\" then escaped = true
|
|
elseif char == first then endIndex = index break end
|
|
end
|
|
elseif first == "[" then
|
|
local equals = text:sub(startIndex):match("^%[(=*)%[")
|
|
if equals ~= nil then
|
|
local close = "]" .. equals .. "]"
|
|
local closeStart = text:find(close, startIndex + #equals + 2, true)
|
|
if closeStart ~= nil then endIndex = closeStart + #close - 1 end
|
|
end
|
|
end
|
|
if endIndex == nil then return nil end
|
|
local replacement = useLongString and luaLongString(value) or quoted(value)
|
|
return text:sub(1, startIndex - 1) .. replacement .. text:sub(endIndex + 1)
|
|
end
|
|
|
|
local function splitBindCategoryMarker(compositor, snippet)
|
|
local prefix = FORMATS[compositor].comment
|
|
local first, rest = snippet:match("^([^\n]*)\n(.*)$")
|
|
if first == nil then return snippet, false end
|
|
local escapedPrefix = prefix:gsub("(%W)", "%%%1")
|
|
if first:match("^%s*" .. escapedPrefix .. "%s*Keymap bind%-category:%s*.+$") then
|
|
return rest, true
|
|
end
|
|
return snippet, false
|
|
end
|
|
|
|
local function withBindCategoryMarker(compositor, snippet, category, forceMarker)
|
|
if not forceMarker then return snippet end
|
|
local prefix = FORMATS[compositor].comment
|
|
local indent = snippet:match("^(%s*)") or ""
|
|
return indent .. prefix .. " Keymap bind-category: " .. category .. "\n" .. snippet
|
|
end
|
|
|
|
local function renderHyprUpdate(target, request)
|
|
local line = replaceLiteralAfter(target.raw_snippet, "hl%.bind%s*%(%s*", hyprCombo(request), false)
|
|
if line == nil then return nil, "target_not_editable" end
|
|
line = replaceLiteralAfter(line, "description%s*=%s*", request.description, false)
|
|
if line == nil then return nil, "description_not_editable" end
|
|
if target.capabilities.command == true then
|
|
line = replaceLiteralAfter(line, "hl%.dsp%.exec_cmd%s*%(%s*", request.command, true)
|
|
if line == nil then return nil, "command_not_editable" end
|
|
end
|
|
line = line:gsub("release%s*=%s*true%s*,%s*", "", 1)
|
|
line = line:gsub(",%s*release%s*=%s*true%s*", "", 1)
|
|
if request.activation == "release" then
|
|
local prefix, suffix = line:match("^(.*,%s*{%s*)(.*)$")
|
|
if prefix == nil then return nil, "activation_not_editable" end
|
|
line = prefix .. "release = true, " .. suffix
|
|
end
|
|
return line, nil
|
|
end
|
|
|
|
local function renderNiriUpdate(target, request)
|
|
local snippet, count = target.raw_snippet:gsub("^(%s*)%S+", "%1" .. niriCombo(request), 1)
|
|
if count ~= 1 then return nil, "target_not_editable" end
|
|
local described = replaceLiteralAfter(snippet, "hotkey%-overlay%-title%s*=%s*", request.description, false)
|
|
if described == nil then
|
|
described = snippet:gsub("^(%s*%S+)", "%1 hotkey-overlay-title=" .. quoted(request.description), 1)
|
|
end
|
|
snippet = described
|
|
if target.capabilities.command == true then
|
|
snippet = replaceLiteralAfter(snippet, "spawn%-sh%s+", request.command, false)
|
|
if snippet == nil then return nil, "command_not_editable" end
|
|
end
|
|
return snippet, nil
|
|
end
|
|
|
|
local function renderMangoUpdate(target, request)
|
|
local indent, flags, _mods, _key, tail = target.raw_snippet:match("^(%s*)bind([lsrp]*)=([^,]*),([^,]*),(.*)$")
|
|
if indent == nil then return nil, "target_not_editable" end
|
|
flags = flags:gsub("r", "")
|
|
if request.activation == "release" then flags = flags .. "r" end
|
|
local action = tail:gsub('%s+#".*"%s*$', "")
|
|
if target.capabilities.command == true then action = "spawn_shell," .. request.command end
|
|
local description = request.description:gsub("\\", "\\\\"):gsub('"', '\\"')
|
|
return indent .. "bind" .. flags .. "=" .. mangoCombo(request) .. "," .. request.keys[1]
|
|
.. "," .. action .. ' #"' .. description .. '"', nil
|
|
end
|
|
|
|
local function findSnapshotTarget(targetId, hiddenOnly)
|
|
local snapshot = noctalia.state.get(SNAPSHOT_KEY)
|
|
if hiddenOnly ~= true then
|
|
for _, category in ipairs(type(snapshot) == "table" and snapshot.categories or {}) do
|
|
for _, bind in ipairs(type(category.binds) == "table" and category.binds or {}) do
|
|
if tostring(bind.id or "") == tostring(targetId or "") then return bind, category end
|
|
end
|
|
end
|
|
end
|
|
for _, bind in ipairs(type(snapshot) == "table" and type(snapshot.hidden) == "table" and snapshot.hidden or {}) do
|
|
if tostring(bind.id or "") == tostring(targetId or "") then return bind, nil end
|
|
end
|
|
return nil, nil
|
|
end
|
|
|
|
local function findActiveSnapshotTarget(targetId)
|
|
local snapshot = noctalia.state.get(SNAPSHOT_KEY)
|
|
local found, foundCategory
|
|
for _, category in ipairs(type(snapshot) == "table" and snapshot.categories or {}) do
|
|
for _, bind in ipairs(type(category.binds) == "table" and category.binds or {}) do
|
|
if tostring(bind.id or "") == tostring(targetId or "") then
|
|
if found ~= nil then return nil, nil, true end
|
|
found, foundCategory = bind, category
|
|
end
|
|
end
|
|
end
|
|
return found, foundCategory, false
|
|
end
|
|
|
|
local function findSnapshotCategory(categoryId)
|
|
local snapshot = noctalia.state.get(SNAPSHOT_KEY)
|
|
local found
|
|
for _, category in ipairs(type(snapshot) == "table" and snapshot.categories or {}) do
|
|
if tostring(category.id or "") == tostring(categoryId or "") then
|
|
if found ~= nil then return nil, true end
|
|
found = category
|
|
end
|
|
end
|
|
return found, false
|
|
end
|
|
|
|
local function snapshotHasOtherCategoryName(categoryId, name)
|
|
local snapshot = noctalia.state.get(SNAPSHOT_KEY)
|
|
for _, category in ipairs(type(snapshot) == "table" and snapshot.categories or {}) do
|
|
if tostring(category.id or "") ~= tostring(categoryId or "")
|
|
and tostring(category.name or "") == name then return true end
|
|
end
|
|
return false
|
|
end
|
|
|
|
local function processValidatedUpdate(request, target, currentCategory)
|
|
local targetContent = noctalia.readFile(target.source)
|
|
if type(targetContent) ~= "string" then
|
|
publishUpdateResult(request.request_id, false, "source_unreadable", target.source)
|
|
busy = false
|
|
return
|
|
end
|
|
if #targetContent > MAX_ROOT_BYTES then
|
|
publishUpdateResult(request.request_id, false, "source_too_large", target.source)
|
|
busy = false
|
|
return
|
|
end
|
|
if not sourceFingerprintMatches(
|
|
tostring(target.raw_snippet or ""), tostring(target.fingerprint or "")
|
|
) then
|
|
publishUpdateResult(request.request_id, false, "target_changed", target.source)
|
|
busy = false
|
|
return
|
|
end
|
|
local editableSnippet, hadCategoryMarker = splitBindCategoryMarker(
|
|
request.compositor, tostring(target.raw_snippet or "")
|
|
)
|
|
local renderTarget = {}
|
|
for key, value in pairs(target) do renderTarget[key] = value end
|
|
renderTarget.raw_snippet = editableSnippet
|
|
local replacement, renderError
|
|
if request.compositor == "Hyprland" then replacement, renderError = renderHyprUpdate(renderTarget, request)
|
|
elseif request.compositor == "Niri" then replacement, renderError = renderNiriUpdate(renderTarget, request)
|
|
else replacement, renderError = renderMangoUpdate(renderTarget, request) end
|
|
if replacement == nil then
|
|
publishUpdateResult(request.request_id, false, renderError, target.source)
|
|
busy = false
|
|
return
|
|
end
|
|
replacement = withBindCategoryMarker(
|
|
request.compositor, replacement, request.category,
|
|
hadCategoryMarker or request.category ~= tostring(currentCategory or "")
|
|
)
|
|
local updated, rangeError = replaceLineRange(
|
|
targetContent, tonumber(target.start_line) or 0, tonumber(target.end_line) or 0,
|
|
tostring(target.raw_snippet or ""), replacement
|
|
)
|
|
if updated == nil then
|
|
publishUpdateResult(request.request_id, false, rangeError, target.source)
|
|
busy = false
|
|
return
|
|
end
|
|
if #updated > MAX_ROOT_BYTES then
|
|
publishUpdateResult(request.request_id, false, "source_too_large", target.source)
|
|
busy = false
|
|
return
|
|
end
|
|
if noctalia.readFile(target.source) ~= targetContent then
|
|
publishUpdateResult(request.request_id, false, "target_changed", target.source)
|
|
busy = false
|
|
return
|
|
end
|
|
local rootOld = noctalia.readFile(request.source)
|
|
if type(rootOld) ~= "string" then
|
|
publishUpdateResult(request.request_id, false, "source_unreadable", request.source)
|
|
busy = false
|
|
return
|
|
end
|
|
local transaction = {
|
|
source = request.source, rootOld = rootOld, rootNew = rootOld, rootWritten = false,
|
|
managedPath = target.source, managedOld = targetContent, managedNew = updated,
|
|
managedExisted = true, managedWritten = false,
|
|
}
|
|
if target.source == request.source then
|
|
transaction.rootNew = updated
|
|
local ok = atomicWrite(target.source, updated)
|
|
if not ok then
|
|
publishUpdateResult(request.request_id, false, "source_write_failed", target.source)
|
|
busy = false
|
|
return
|
|
end
|
|
transaction.rootWritten = true
|
|
else
|
|
local ok = atomicWrite(target.source, updated)
|
|
if not ok then
|
|
publishUpdateResult(request.request_id, false, "source_write_failed", target.source)
|
|
busy = false
|
|
return
|
|
end
|
|
transaction.managedWritten = true
|
|
end
|
|
verifyAndReload(request, transaction)
|
|
end
|
|
|
|
local function processValidatedMove(request, target, currentCategory)
|
|
local targetContent = noctalia.readFile(target.source)
|
|
if type(targetContent) ~= "string" then
|
|
publishUpdateResult(request.request_id, false, "source_unreadable", target.source)
|
|
busy = false
|
|
return
|
|
end
|
|
if #targetContent > MAX_ROOT_BYTES then
|
|
publishUpdateResult(request.request_id, false, "source_too_large", target.source)
|
|
busy = false
|
|
return
|
|
end
|
|
local rawSnippet = tostring(target.raw_snippet or "")
|
|
if not sourceFingerprintMatches(rawSnippet, tostring(target.fingerprint or "")) then
|
|
publishUpdateResult(request.request_id, false, "target_changed", target.source)
|
|
busy = false
|
|
return
|
|
end
|
|
local bindSnippet = splitBindCategoryMarker(request.compositor, rawSnippet)
|
|
local replacement = withBindCategoryMarker(request.compositor, bindSnippet, request.category, true)
|
|
local updated, rangeError = replaceLineRange(
|
|
targetContent, tonumber(target.start_line) or 0, tonumber(target.end_line) or 0,
|
|
rawSnippet, replacement
|
|
)
|
|
if updated == nil then
|
|
publishUpdateResult(request.request_id, false, rangeError, target.source)
|
|
busy = false
|
|
return
|
|
end
|
|
if #updated > MAX_ROOT_BYTES then
|
|
publishUpdateResult(request.request_id, false, "source_too_large", target.source)
|
|
busy = false
|
|
return
|
|
end
|
|
if noctalia.readFile(target.source) ~= targetContent then
|
|
publishUpdateResult(request.request_id, false, "target_changed", target.source)
|
|
busy = false
|
|
return
|
|
end
|
|
if request.category == currentCategory or updated == targetContent then
|
|
finishSuccess(request, target.source)
|
|
return
|
|
end
|
|
local rootOld = noctalia.readFile(request.source)
|
|
if type(rootOld) ~= "string" then
|
|
publishUpdateResult(request.request_id, false, "source_unreadable", request.source)
|
|
busy = false
|
|
return
|
|
end
|
|
local transaction = {
|
|
source = request.source, rootOld = rootOld, rootNew = rootOld, rootWritten = false,
|
|
managedPath = target.source, managedOld = targetContent, managedNew = updated,
|
|
managedExisted = true, managedWritten = false,
|
|
}
|
|
if noctalia.readFile(target.source) ~= targetContent then
|
|
publishUpdateResult(request.request_id, false, "target_changed", target.source)
|
|
busy = false
|
|
return
|
|
end
|
|
local ok = atomicWrite(target.source, updated)
|
|
if target.source == request.source then
|
|
transaction.rootNew = updated
|
|
transaction.rootWritten = ok == true
|
|
else
|
|
transaction.managedWritten = ok == true
|
|
end
|
|
if ok ~= true then
|
|
publishUpdateResult(request.request_id, false, "source_write_failed", target.source)
|
|
busy = false
|
|
return
|
|
end
|
|
verifyAndReload(request, transaction)
|
|
end
|
|
|
|
local function processValidatedReorder(request, target, anchor)
|
|
local targetContent = noctalia.readFile(target.source)
|
|
if type(targetContent) ~= "string" then
|
|
publishUpdateResult(request.request_id, false, "source_unreadable", target.source)
|
|
busy = false
|
|
return
|
|
end
|
|
if #targetContent > MAX_ROOT_BYTES then
|
|
publishUpdateResult(request.request_id, false, "source_too_large", target.source)
|
|
busy = false
|
|
return
|
|
end
|
|
if not sourceFingerprintMatches(
|
|
tostring(target.raw_snippet or ""), tostring(target.fingerprint or "")
|
|
) then
|
|
publishUpdateResult(request.request_id, false, "target_changed", target.source)
|
|
busy = false
|
|
return
|
|
end
|
|
if not sourceFingerprintMatches(
|
|
tostring(anchor.raw_snippet or ""), tostring(anchor.fingerprint or "")
|
|
) then
|
|
publishUpdateResult(request.request_id, false, "anchor_changed", anchor.source)
|
|
busy = false
|
|
return
|
|
end
|
|
local replacementRaw = tostring(target.raw_snippet or "")
|
|
if request.category ~= request.current_category then
|
|
local bindSnippet = splitBindCategoryMarker(request.compositor, replacementRaw)
|
|
replacementRaw = withBindCategoryMarker(request.compositor, bindSnippet, request.category, true)
|
|
end
|
|
local updated, reorderError = reorderLineRanges(
|
|
targetContent, target, anchor, request.placement, replacementRaw
|
|
)
|
|
if updated == nil then
|
|
publishUpdateResult(request.request_id, false, reorderError, target.source)
|
|
busy = false
|
|
return
|
|
end
|
|
if updated == targetContent then
|
|
finishSuccess(request, target.source)
|
|
return
|
|
end
|
|
if noctalia.readFile(target.source) ~= targetContent then
|
|
publishUpdateResult(request.request_id, false, "target_changed", target.source)
|
|
busy = false
|
|
return
|
|
end
|
|
local rootOld = noctalia.readFile(request.source)
|
|
if type(rootOld) ~= "string" then
|
|
publishUpdateResult(request.request_id, false, "source_unreadable", request.source)
|
|
busy = false
|
|
return
|
|
end
|
|
local transaction = {
|
|
source = request.source, rootOld = rootOld, rootNew = rootOld, rootWritten = false,
|
|
managedPath = target.source, managedOld = targetContent, managedNew = updated,
|
|
managedExisted = true, managedWritten = false,
|
|
}
|
|
if noctalia.readFile(target.source) ~= targetContent then
|
|
publishUpdateResult(request.request_id, false, "target_changed", target.source)
|
|
busy = false
|
|
return
|
|
end
|
|
local ok = atomicWrite(target.source, updated)
|
|
if target.source == request.source then
|
|
transaction.rootNew = updated
|
|
transaction.rootWritten = ok == true
|
|
else
|
|
transaction.managedWritten = ok == true
|
|
end
|
|
if ok ~= true then
|
|
publishUpdateResult(request.request_id, false, "source_write_failed", target.source)
|
|
busy = false
|
|
return
|
|
end
|
|
verifyAndReload(request, transaction)
|
|
end
|
|
|
|
local function processValidatedCategoryRename(request, category)
|
|
local grouped = {}
|
|
local paths = {}
|
|
for _, bind in ipairs(category.binds) do
|
|
local group = grouped[bind.source]
|
|
if group == nil then
|
|
group = { path = bind.source, entries = {} }
|
|
grouped[bind.source] = group
|
|
paths[#paths + 1] = bind.source
|
|
end
|
|
group.entries[#group.entries + 1] = bind
|
|
end
|
|
table.sort(paths)
|
|
|
|
local files = {}
|
|
for _, path in ipairs(paths) do
|
|
local group = grouped[path]
|
|
local content = noctalia.readFile(path)
|
|
if type(content) ~= "string" then
|
|
publishUpdateResult(request.request_id, false, "source_unreadable", path)
|
|
busy = false
|
|
return
|
|
end
|
|
if #content > MAX_ROOT_BYTES then
|
|
publishUpdateResult(request.request_id, false, "source_too_large", path)
|
|
busy = false
|
|
return
|
|
end
|
|
local lines = splitLines(content)
|
|
table.sort(group.entries, function(left, right)
|
|
return left.start_line < right.start_line
|
|
end)
|
|
local previousLast = 0
|
|
for _, bind in ipairs(group.entries) do
|
|
local rawSnippet = tostring(bind.raw_snippet or "")
|
|
if bind.start_line <= previousLast then
|
|
publishUpdateResult(request.request_id, false, "category_ranges_overlap", path)
|
|
busy = false
|
|
return
|
|
end
|
|
previousLast = bind.end_line
|
|
if bind.end_line > #lines then
|
|
publishUpdateResult(request.request_id, false, "target_range_invalid", path)
|
|
busy = false
|
|
return
|
|
end
|
|
local current = table.concat(lines, "\n", bind.start_line, bind.end_line)
|
|
if not sourceFingerprintMatches(rawSnippet, tostring(bind.fingerprint or ""))
|
|
or current ~= rawSnippet
|
|
or not sourceFingerprintMatches(current, tostring(bind.fingerprint or "")) then
|
|
publishUpdateResult(request.request_id, false, "target_changed", path)
|
|
busy = false
|
|
return
|
|
end
|
|
end
|
|
|
|
local updated = content
|
|
if request.new_category ~= request.old_category then
|
|
for index = #group.entries, 1, -1 do
|
|
local bind = group.entries[index]
|
|
local bindSnippet = splitBindCategoryMarker(request.compositor, bind.raw_snippet)
|
|
local replacement = withBindCategoryMarker(
|
|
request.compositor, bindSnippet, request.new_category, true
|
|
)
|
|
local rangeError
|
|
updated, rangeError = replaceLineRange(
|
|
updated, bind.start_line, bind.end_line, bind.raw_snippet, replacement
|
|
)
|
|
if updated == nil then
|
|
publishUpdateResult(request.request_id, false, rangeError, path)
|
|
busy = false
|
|
return
|
|
end
|
|
end
|
|
end
|
|
if #updated > MAX_ROOT_BYTES then
|
|
publishUpdateResult(request.request_id, false, "source_too_large", path)
|
|
busy = false
|
|
return
|
|
end
|
|
files[#files + 1] = { path = path, old = content, new = updated, written = false }
|
|
end
|
|
|
|
local rootContent = noctalia.readFile(request.source)
|
|
if type(rootContent) ~= "string" then
|
|
publishUpdateResult(request.request_id, false, "source_unreadable", request.source)
|
|
busy = false
|
|
return
|
|
end
|
|
local changed = false
|
|
for _, file in ipairs(files) do
|
|
if file.old ~= file.new then changed = true end
|
|
if noctalia.readFile(file.path) ~= file.old then
|
|
publishUpdateResult(request.request_id, false, "target_changed", file.path)
|
|
busy = false
|
|
return
|
|
end
|
|
end
|
|
if not changed then
|
|
finishSuccess(request, request.source)
|
|
return
|
|
end
|
|
for _, file in ipairs(files) do
|
|
if file.old ~= file.new then
|
|
local ok = atomicWrite(file.path, file.new)
|
|
if ok ~= true then
|
|
local rolledBack = rollbackFileSet(files)
|
|
publishUpdateResult(
|
|
request.request_id, false,
|
|
rolledBack and "source_write_failed" or "rollback_failed", file.path
|
|
)
|
|
busy = false
|
|
return
|
|
end
|
|
file.written = true
|
|
end
|
|
end
|
|
verifyFileSetAndReload(request, files)
|
|
end
|
|
|
|
local function processValidatedMutation(request, target)
|
|
local targetContent = noctalia.readFile(target.source)
|
|
if type(targetContent) ~= "string" then
|
|
publishUpdateResult(request.request_id, false, "source_unreadable", target.source)
|
|
busy = false
|
|
return
|
|
end
|
|
if #targetContent > MAX_ROOT_BYTES then
|
|
publishUpdateResult(request.request_id, false, "source_too_large", target.source)
|
|
busy = false
|
|
return
|
|
end
|
|
local rawSnippet = tostring(target.raw_snippet or "")
|
|
if not sourceFingerprintMatches(rawSnippet, tostring(target.fingerprint or "")) then
|
|
publishUpdateResult(request.request_id, false, "target_changed", target.source)
|
|
busy = false
|
|
return
|
|
end
|
|
local replacement = ""
|
|
if request.operation == "hide" then
|
|
replacement = hiddenSnippet(request.compositor, rawSnippet)
|
|
elseif target.hidden == true then
|
|
local hidden, hiddenError = parseHiddenSnippet(request.compositor, rawSnippet)
|
|
if hidden == nil then
|
|
publishUpdateResult(request.request_id, false, hiddenError, target.source)
|
|
busy = false
|
|
return
|
|
end
|
|
if type(target.original_fingerprint) == "string" and target.original_fingerprint ~= ""
|
|
and target.original_fingerprint ~= hidden.original_fingerprint then
|
|
publishUpdateResult(request.request_id, false, "hidden_block_invalid", target.source)
|
|
busy = false
|
|
return
|
|
end
|
|
if request.operation == "restore" then replacement = hidden.original end
|
|
end
|
|
local updated, rangeError = replaceLineRange(
|
|
targetContent, tonumber(target.start_line) or 0, tonumber(target.end_line) or 0,
|
|
rawSnippet, replacement
|
|
)
|
|
if updated == nil then
|
|
publishUpdateResult(request.request_id, false, rangeError, target.source)
|
|
busy = false
|
|
return
|
|
end
|
|
if #updated > MAX_ROOT_BYTES then
|
|
publishUpdateResult(request.request_id, false, "source_too_large", target.source)
|
|
busy = false
|
|
return
|
|
end
|
|
if noctalia.readFile(target.source) ~= targetContent then
|
|
publishUpdateResult(request.request_id, false, "target_changed", target.source)
|
|
busy = false
|
|
return
|
|
end
|
|
local rootOld = noctalia.readFile(request.source)
|
|
if type(rootOld) ~= "string" then
|
|
publishUpdateResult(request.request_id, false, "source_unreadable", request.source)
|
|
busy = false
|
|
return
|
|
end
|
|
local transaction = {
|
|
source = request.source, rootOld = rootOld, rootNew = rootOld, rootWritten = false,
|
|
managedPath = target.source, managedOld = targetContent, managedNew = updated,
|
|
managedExisted = true, managedWritten = false,
|
|
}
|
|
if noctalia.readFile(target.source) ~= targetContent then
|
|
publishUpdateResult(request.request_id, false, "target_changed", target.source)
|
|
busy = false
|
|
return
|
|
end
|
|
local ok
|
|
if target.source == request.source then
|
|
transaction.rootNew = updated
|
|
ok = atomicWrite(target.source, updated)
|
|
transaction.rootWritten = ok == true
|
|
else
|
|
ok = atomicWrite(target.source, updated)
|
|
transaction.managedWritten = ok == true
|
|
end
|
|
if ok ~= true then
|
|
publishUpdateResult(request.request_id, false, "source_write_failed", target.source)
|
|
busy = false
|
|
return
|
|
end
|
|
verifyAndReload(request, transaction)
|
|
end
|
|
|
|
local function processMoveRequest(raw, target, currentCategory)
|
|
local rawId = raw.request_id
|
|
if type(rawId) == "number" then rawId = tostring(rawId) end
|
|
local requestId, errorCode = validateText(rawId, "request_id", false, 256)
|
|
if errorCode ~= nil then publishUpdateResult(rawId, false, errorCode, target.source) return end
|
|
if raw.operation ~= "move" then
|
|
publishUpdateResult(requestId, false, "invalid_operation", target.source)
|
|
return
|
|
end
|
|
local compositor = raw.compositor
|
|
local format = FORMATS[compositor]
|
|
if format == nil then publishUpdateResult(requestId, false, "invalid_compositor", target.source) return end
|
|
local source
|
|
source, errorCode = validateText(raw.source, "source", false, 4096)
|
|
if errorCode ~= nil or source:sub(1, 1) ~= "/" then
|
|
publishUpdateResult(requestId, false, errorCode or "source_not_absolute", target.source)
|
|
return
|
|
end
|
|
local category
|
|
category, errorCode = validateCategory(raw.category)
|
|
if errorCode ~= nil then publishUpdateResult(requestId, false, errorCode, target.source) return end
|
|
local targetId
|
|
targetId, errorCode = validateText(raw.target_id, "target_id", false, 256)
|
|
if errorCode ~= nil then publishUpdateResult(requestId, false, errorCode, target.source) return end
|
|
local targetSource, targetSourceError = validateText(target.source, "target_source", false, 4096)
|
|
if targetSourceError ~= nil then targetSource = "" end
|
|
local firstLine, lastLine = tonumber(target.start_line), tonumber(target.end_line)
|
|
local capabilities = type(target.capabilities) == "table" and target.capabilities or {}
|
|
local currentCategoryName = type(currentCategory) == "table" and tostring(currentCategory.name or "") or nil
|
|
if capabilities.category ~= true or tostring(target.id or "") ~= targetId
|
|
or targetId:match("^range:") or targetSource == "" or targetSource:sub(1, 1) ~= "/"
|
|
or type(target.start_line) ~= "number" or firstLine < 1 or firstLine % 1 ~= 0
|
|
or type(target.end_line) ~= "number" or lastLine < firstLine or lastLine % 1 ~= 0
|
|
or type(target.raw_snippet) ~= "string" or target.raw_snippet == ""
|
|
or type(target.fingerprint) ~= "string" or target.fingerprint == ""
|
|
or currentCategoryName == nil then
|
|
publishUpdateResult(requestId, false, "target_not_editable", targetSource)
|
|
return
|
|
end
|
|
local request = {
|
|
request_id = requestId, operation = "move", compositor = compositor,
|
|
format = format, source = source, target_id = targetId, category = category,
|
|
}
|
|
if not validateCurrentContext(request) then
|
|
publishUpdateResult(requestId, false, "stale_context", targetSource)
|
|
return
|
|
end
|
|
busy = true
|
|
local preflight = "[ ! -L " .. shellQuote(request.source) .. " ] && [ ! -L "
|
|
.. shellQuote(targetSource) .. " ]"
|
|
local started = noctalia.runAsync(preflight, function(result)
|
|
if result.timedOut == true or result.exitCode ~= 0 then
|
|
publishUpdateResult(requestId, false, "symlink_unsupported", targetSource)
|
|
busy = false
|
|
else
|
|
processValidatedMove(request, target, currentCategoryName)
|
|
end
|
|
end, 2000)
|
|
if not started then
|
|
publishUpdateResult(requestId, false, "preflight_failed", targetSource)
|
|
busy = false
|
|
end
|
|
end
|
|
|
|
local function validReorderEndpoint(bind, expectedId)
|
|
local firstLine, lastLine = tonumber(bind.start_line), tonumber(bind.end_line)
|
|
return bind.hidden ~= true
|
|
and tostring(bind.id or "") == expectedId
|
|
and not expectedId:match("^range:")
|
|
and type(bind.source) == "string" and bind.source:sub(1, 1) == "/"
|
|
and type(bind.start_line) == "number" and firstLine >= 1 and firstLine % 1 == 0
|
|
and type(bind.end_line) == "number" and lastLine >= firstLine and lastLine % 1 == 0
|
|
and type(bind.raw_snippet) == "string" and bind.raw_snippet ~= ""
|
|
and type(bind.fingerprint) == "string" and bind.fingerprint ~= ""
|
|
end
|
|
|
|
local function validCategoryRenameBind(bind)
|
|
local firstLine, lastLine = tonumber(bind.start_line), tonumber(bind.end_line)
|
|
local capabilities = type(bind.capabilities) == "table" and bind.capabilities or {}
|
|
local bindId = tostring(bind.id or "")
|
|
return bind.hidden ~= true and bindId ~= "" and not bindId:match("^range:")
|
|
and capabilities.category == true
|
|
and type(bind.source) == "string" and bind.source:sub(1, 1) == "/"
|
|
and type(bind.start_line) == "number" and firstLine >= 1 and firstLine % 1 == 0
|
|
and type(bind.end_line) == "number" and lastLine >= firstLine and lastLine % 1 == 0
|
|
and type(bind.raw_snippet) == "string" and bind.raw_snippet ~= ""
|
|
and type(bind.fingerprint) == "string" and bind.fingerprint ~= ""
|
|
end
|
|
|
|
local function processCategoryRenameRequest(raw, category)
|
|
local rawId = raw.request_id
|
|
if type(rawId) == "number" then rawId = tostring(rawId) end
|
|
local requestId, errorCode = validateText(rawId, "request_id", false, 256)
|
|
if errorCode ~= nil then publishUpdateResult(rawId, false, errorCode, "") return end
|
|
local compositor = raw.compositor
|
|
local format = FORMATS[compositor]
|
|
if format == nil then publishUpdateResult(requestId, false, "invalid_compositor", "") return end
|
|
local source
|
|
source, errorCode = validateText(raw.source, "source", false, 4096)
|
|
if errorCode ~= nil or source:sub(1, 1) ~= "/" then
|
|
publishUpdateResult(requestId, false, errorCode or "source_not_absolute", "")
|
|
return
|
|
end
|
|
local categoryId
|
|
categoryId, errorCode = validateText(raw.category_id, "category_id", false, 256)
|
|
if errorCode ~= nil then publishUpdateResult(requestId, false, errorCode, source) return end
|
|
local oldCategory
|
|
oldCategory, errorCode = validateCategory(raw.old_category)
|
|
if errorCode ~= nil then publishUpdateResult(requestId, false, errorCode, source) return end
|
|
local newCategory
|
|
newCategory, errorCode = validateCategory(raw.new_category)
|
|
if errorCode ~= nil then publishUpdateResult(requestId, false, errorCode, source) return end
|
|
if tostring(category.id or "") ~= categoryId or tostring(category.name or "") ~= oldCategory then
|
|
publishUpdateResult(requestId, false, "stale_category", source)
|
|
return
|
|
end
|
|
if snapshotHasOtherCategoryName(categoryId, newCategory) then
|
|
publishUpdateResult(requestId, false, "category_exists", source)
|
|
return
|
|
end
|
|
if not validateCurrentContext({ compositor = compositor, source = source }) then
|
|
publishUpdateResult(requestId, false, "stale_context", source)
|
|
return
|
|
end
|
|
if type(category.binds) ~= "table" or #category.binds == 0 then
|
|
publishUpdateResult(requestId, false, "category_not_editable", source)
|
|
return
|
|
end
|
|
local bindIds = {}
|
|
local paths, pathSeen = { source }, { [source] = true }
|
|
for _, bind in ipairs(category.binds) do
|
|
local bindId = tostring(bind.id or "")
|
|
if not validCategoryRenameBind(bind) or bindIds[bindId] then
|
|
publishUpdateResult(requestId, false, "category_not_editable", source)
|
|
return
|
|
end
|
|
bindIds[bindId] = true
|
|
if not pathSeen[bind.source] then
|
|
pathSeen[bind.source] = true
|
|
paths[#paths + 1] = bind.source
|
|
end
|
|
end
|
|
table.sort(paths)
|
|
local request = {
|
|
request_id = requestId, operation = "rename_category", compositor = compositor,
|
|
format = format, source = source, category_id = categoryId,
|
|
old_category = oldCategory, new_category = newCategory,
|
|
}
|
|
busy = true
|
|
local checks = {}
|
|
for _, path in ipairs(paths) do checks[#checks + 1] = "[ ! -L " .. shellQuote(path) .. " ]" end
|
|
local started = noctalia.runAsync(table.concat(checks, " && "), function(result)
|
|
if result.timedOut == true or result.exitCode ~= 0 then
|
|
publishUpdateResult(requestId, false, "symlink_unsupported", source)
|
|
busy = false
|
|
else
|
|
processValidatedCategoryRename(request, category)
|
|
end
|
|
end, 2000)
|
|
if not started then
|
|
publishUpdateResult(requestId, false, "preflight_failed", source)
|
|
busy = false
|
|
end
|
|
end
|
|
|
|
local function processReorderRequest(raw, target, anchor, currentCategory, anchorCategory)
|
|
local rawId = raw.request_id
|
|
if type(rawId) == "number" then rawId = tostring(rawId) end
|
|
local requestId, errorCode = validateText(rawId, "request_id", false, 256)
|
|
if errorCode ~= nil then publishUpdateResult(rawId, false, errorCode, "") return end
|
|
local compositor = raw.compositor
|
|
local format = FORMATS[compositor]
|
|
if format == nil then publishUpdateResult(requestId, false, "invalid_compositor", "") return end
|
|
local source
|
|
source, errorCode = validateText(raw.source, "source", false, 4096)
|
|
if errorCode ~= nil or source:sub(1, 1) ~= "/" then
|
|
publishUpdateResult(requestId, false, errorCode or "source_not_absolute", "")
|
|
return
|
|
end
|
|
local targetId
|
|
targetId, errorCode = validateText(raw.target_id, "target_id", false, 256)
|
|
if errorCode ~= nil then publishUpdateResult(requestId, false, errorCode, "") return end
|
|
local anchorId
|
|
anchorId, errorCode = validateText(raw.anchor_id, "anchor_id", false, 256)
|
|
if errorCode ~= nil then publishUpdateResult(requestId, false, errorCode, "") return end
|
|
if targetId == anchorId then
|
|
publishUpdateResult(requestId, false, "anchor_is_target", tostring(target.source or ""))
|
|
return
|
|
end
|
|
if raw.placement ~= "before" and raw.placement ~= "after" then
|
|
publishUpdateResult(requestId, false, "invalid_placement", tostring(target.source or ""))
|
|
return
|
|
end
|
|
if not validReorderEndpoint(target, targetId) or not validReorderEndpoint(anchor, anchorId) then
|
|
publishUpdateResult(requestId, false, "target_not_editable", tostring(target.source or ""))
|
|
return
|
|
end
|
|
if target.source ~= anchor.source then
|
|
publishUpdateResult(requestId, false, "different_source", target.source)
|
|
return
|
|
end
|
|
local currentCategoryName = type(currentCategory) == "table" and tostring(currentCategory.name or "") or ""
|
|
local anchorCategoryName = type(anchorCategory) == "table" and tostring(anchorCategory.name or "") or ""
|
|
local category
|
|
category, errorCode = validateCategory(anchorCategoryName)
|
|
if errorCode ~= nil or currentCategoryName == "" then
|
|
publishUpdateResult(requestId, false, errorCode or "invalid_category", target.source)
|
|
return
|
|
end
|
|
local targetCapabilities = type(target.capabilities) == "table" and target.capabilities or {}
|
|
if currentCategoryName ~= category and targetCapabilities.category ~= true then
|
|
publishUpdateResult(requestId, false, "target_not_editable", target.source)
|
|
return
|
|
end
|
|
local targetFirst, targetLast = tonumber(target.start_line), tonumber(target.end_line)
|
|
local anchorFirst, anchorLast = tonumber(anchor.start_line), tonumber(anchor.end_line)
|
|
if not (targetLast < anchorFirst or anchorLast < targetFirst) then
|
|
publishUpdateResult(requestId, false, "target_anchor_overlap", target.source)
|
|
return
|
|
end
|
|
local request = {
|
|
request_id = requestId, operation = "reorder", compositor = compositor, format = format,
|
|
source = source, target_id = targetId, anchor_id = anchorId, placement = raw.placement,
|
|
category = category, current_category = currentCategoryName,
|
|
}
|
|
if not validateCurrentContext(request) then
|
|
publishUpdateResult(requestId, false, "stale_context", target.source)
|
|
return
|
|
end
|
|
busy = true
|
|
local preflight = "[ ! -L " .. shellQuote(request.source) .. " ] && [ ! -L "
|
|
.. shellQuote(target.source) .. " ]"
|
|
local started = noctalia.runAsync(preflight, function(result)
|
|
if result.timedOut == true or result.exitCode ~= 0 then
|
|
publishUpdateResult(requestId, false, "symlink_unsupported", target.source)
|
|
busy = false
|
|
else
|
|
processValidatedReorder(request, target, anchor)
|
|
end
|
|
end, 2000)
|
|
if not started then
|
|
publishUpdateResult(requestId, false, "preflight_failed", target.source)
|
|
busy = false
|
|
end
|
|
end
|
|
|
|
local function processMutationRequest(raw, target)
|
|
local rawId = raw.request_id
|
|
if type(rawId) == "number" then rawId = tostring(rawId) end
|
|
local requestId, errorCode = validateText(rawId, "request_id", false, 256)
|
|
if errorCode ~= nil then publishUpdateResult(rawId, false, errorCode, target.source) return end
|
|
local operation = raw.operation
|
|
if operation ~= "hide" and operation ~= "restore" and operation ~= "delete" then
|
|
publishUpdateResult(requestId, false, "invalid_operation", target.source)
|
|
return
|
|
end
|
|
local compositor = raw.compositor
|
|
local format = FORMATS[compositor]
|
|
if format == nil then publishUpdateResult(requestId, false, "invalid_compositor", target.source) return end
|
|
local source
|
|
source, errorCode = validateText(raw.source, "source", false, 4096)
|
|
if errorCode ~= nil or source:sub(1, 1) ~= "/" then
|
|
publishUpdateResult(requestId, false, errorCode or "source_not_absolute", target.source)
|
|
return
|
|
end
|
|
local targetId
|
|
targetId, errorCode = validateText(raw.target_id, "target_id", false, 256)
|
|
if errorCode ~= nil then publishUpdateResult(requestId, false, errorCode, target.source) return end
|
|
local targetSource, targetSourceError = validateText(target.source, "target_source", false, 4096)
|
|
if targetSourceError ~= nil then targetSource = "" end
|
|
local firstLine, lastLine = tonumber(target.start_line), tonumber(target.end_line)
|
|
local capabilities = type(target.capabilities) == "table" and target.capabilities or {}
|
|
local hiddenTarget = target.hidden == true
|
|
local allowedByType = hiddenTarget
|
|
and (operation == "restore" or operation == "delete")
|
|
or (not hiddenTarget and operation ~= "restore"
|
|
and capabilities.combo == true and capabilities.description == true)
|
|
if not allowedByType or tostring(target.id or "") ~= targetId
|
|
or targetId:match("^range:") or targetSource == "" or targetSource:sub(1, 1) ~= "/"
|
|
or type(target.start_line) ~= "number" or firstLine < 1 or firstLine % 1 ~= 0
|
|
or type(target.end_line) ~= "number" or lastLine < firstLine or lastLine % 1 ~= 0
|
|
or type(target.raw_snippet) ~= "string" or target.raw_snippet == ""
|
|
or tostring(target.fingerprint or "") == "" then
|
|
publishUpdateResult(requestId, false,
|
|
operation == "restore" and not hiddenTarget and "target_not_hidden" or "target_not_editable",
|
|
targetSource)
|
|
return
|
|
end
|
|
local request = {
|
|
request_id = requestId, operation = operation, compositor = compositor,
|
|
format = format, source = source, target_id = targetId,
|
|
}
|
|
if not validateCurrentContext(request) then
|
|
publishUpdateResult(requestId, false, "stale_context", target.source)
|
|
return
|
|
end
|
|
busy = true
|
|
local preflight = "[ ! -L " .. shellQuote(request.source) .. " ] && [ ! -L "
|
|
.. shellQuote(targetSource) .. " ]"
|
|
local started = noctalia.runAsync(preflight, function(result)
|
|
if result.timedOut == true or result.exitCode ~= 0 then
|
|
publishUpdateResult(requestId, false, "symlink_unsupported", targetSource)
|
|
busy = false
|
|
else
|
|
processValidatedMutation(request, target)
|
|
end
|
|
end, 2000)
|
|
if not started then
|
|
publishUpdateResult(requestId, false, "preflight_failed", targetSource)
|
|
busy = false
|
|
end
|
|
end
|
|
|
|
local function processUpdateRequest(raw)
|
|
local rawId = type(raw) == "table" and raw.request_id or ""
|
|
if type(rawId) == "number" then rawId = tostring(rawId) end
|
|
if busy then publishUpdateResult(rawId, false, "busy", "") return end
|
|
if type(raw) ~= "table" then publishUpdateResult(rawId, false, "invalid_request", "") return end
|
|
if raw.command_kind == "native" then
|
|
publishUpdateResult(rawId, false, "native_update_unsupported", "")
|
|
return
|
|
end
|
|
if raw.operation == "rename_category" then
|
|
local category, ambiguous = findSnapshotCategory(raw.category_id)
|
|
if ambiguous then publishUpdateResult(rawId, false, "category_ambiguous", "") return end
|
|
if category == nil then publishUpdateResult(rawId, false, "category_not_found", "") return end
|
|
processCategoryRenameRequest(raw, category)
|
|
return
|
|
end
|
|
if raw.operation == "reorder" then
|
|
local target, targetCategory, targetAmbiguous = findActiveSnapshotTarget(raw.target_id)
|
|
if targetAmbiguous then publishUpdateResult(rawId, false, "target_ambiguous", "") return end
|
|
if target == nil then publishUpdateResult(rawId, false, "target_not_found", "") return end
|
|
local anchor, anchorCategory, anchorAmbiguous = findActiveSnapshotTarget(raw.anchor_id)
|
|
if anchorAmbiguous then publishUpdateResult(rawId, false, "anchor_ambiguous", "") return end
|
|
if anchor == nil then publishUpdateResult(rawId, false, "anchor_not_found", "") return end
|
|
processReorderRequest(raw, target, anchor, targetCategory, anchorCategory)
|
|
return
|
|
end
|
|
local target, category = findSnapshotTarget(
|
|
raw.target_id, raw.operation == "restore" or (raw.operation == "delete" and raw.hidden == true)
|
|
)
|
|
if target == nil then publishUpdateResult(rawId, false, "target_not_found", "") return end
|
|
if raw.operation == "move" then
|
|
processMoveRequest(raw, target, category)
|
|
return
|
|
end
|
|
if raw.operation == "hide" or raw.operation == "restore" or raw.operation == "delete" then
|
|
processMutationRequest(raw, target)
|
|
return
|
|
end
|
|
local capabilities = type(target.capabilities) == "table" and target.capabilities or {}
|
|
local validationRaw = {}
|
|
for key, value in pairs(raw) do validationRaw[key] = value end
|
|
if trim(tostring(validationRaw.command or "")) == "" and capabilities.command ~= true then
|
|
validationRaw.command = "__preserve_native_action__"
|
|
end
|
|
local request, errorCode = validateRequest(validationRaw)
|
|
if request == nil then publishUpdateResult(rawId, false, errorCode, target.source) return end
|
|
request.operation = "update"
|
|
request.target_id = tostring(raw.target_id or "")
|
|
if capabilities.command ~= true then request.command = tostring(target.command or "") end
|
|
if not validateCurrentContext(request) then
|
|
publishUpdateResult(request.request_id, false, "stale_context", target.source) return
|
|
end
|
|
if capabilities.combo ~= true or capabilities.description ~= true then
|
|
publishUpdateResult(request.request_id, false, "target_not_editable", target.source) return
|
|
end
|
|
if conflictsWithSnapshot(request, request.target_id) then
|
|
publishUpdateResult(request.request_id, false, "conflict_blocked", target.source) return
|
|
end
|
|
busy = true
|
|
local preflight = "[ ! -L " .. shellQuote(request.source) .. " ] && [ ! -L " .. shellQuote(target.source) .. " ]"
|
|
local started = noctalia.runAsync(preflight, function(result)
|
|
if result.timedOut == true or result.exitCode ~= 0 then
|
|
publishUpdateResult(request.request_id, false, "symlink_unsupported", target.source)
|
|
busy = false
|
|
else processValidatedUpdate(request, target, category.name) end
|
|
end, 2000)
|
|
if not started then
|
|
publishUpdateResult(request.request_id, false, "preflight_failed", target.source)
|
|
busy = false
|
|
end
|
|
end
|
|
|
|
function onIpc(event, payload)
|
|
if event ~= "create-json" and event ~= "update-json" then return end
|
|
local process = event == "update-json" and processUpdateRequest or processRequest
|
|
local publish = event == "update-json" and publishUpdateResult or publishResult
|
|
if type(payload) == "table" then
|
|
process(payload)
|
|
return
|
|
end
|
|
if type(payload) ~= "string" then
|
|
publish("", false, "invalid_json", "")
|
|
return
|
|
end
|
|
local ok, decoded = pcall(noctalia.json.decode, payload)
|
|
if not ok or type(decoded) ~= "table" then
|
|
publish("", false, "invalid_json", "")
|
|
return
|
|
end
|
|
process(decoded)
|
|
end
|
|
|
|
-- Writes are transactional and do not read plugin settings. Keeping this VM
|
|
-- alive across appearance/path setting changes lets an in-flight validation,
|
|
-- reload, or rollback finish and publish its result to the panel.
|
|
function onConfigChanged()
|
|
end
|
|
|
|
noctalia.state.watch(CREATE_REQUEST_KEY, function(request)
|
|
local requestId = type(request) == "table" and request.request_id or nil
|
|
if type(requestId) == "number" then requestId = tostring(requestId) end
|
|
if type(requestId) == "string" and requestId ~= "" then
|
|
if tostring(noctalia.state.get(LAST_HANDLED_REQUEST_KEY) or "") == requestId then return end
|
|
-- Mark before processing so a service/config reload cannot replay a
|
|
-- request that is already validating or reloading asynchronously.
|
|
noctalia.state.set(LAST_HANDLED_REQUEST_KEY, requestId)
|
|
end
|
|
processRequest(request)
|
|
end)
|
|
|
|
noctalia.state.watch(UPDATE_REQUEST_KEY, function(request)
|
|
local requestId = type(request) == "table" and request.request_id or nil
|
|
if type(requestId) == "number" then requestId = tostring(requestId) end
|
|
if type(requestId) == "string" and requestId ~= "" then
|
|
if tostring(noctalia.state.get(LAST_HANDLED_UPDATE_KEY) or "") == requestId then return end
|
|
noctalia.state.set(LAST_HANDLED_UPDATE_KEY, requestId)
|
|
end
|
|
processUpdateRequest(request)
|
|
end)
|
|
|
|
noctalia.state.watch(SNAPSHOT_KEY, function(snapshotValue)
|
|
processAutomaticMigration(snapshotValue)
|
|
end)
|
|
|
|
processAutomaticMigration(noctalia.state.get(SNAPSHOT_KEY))
|