feat(keymap): write shortcuts into active config (#299)

This commit is contained in:
blacku
2026-08-08 10:45:44 -04:00
committed by GitHub
parent 31d092f76f
commit 66a1729097
5 changed files with 413 additions and 89 deletions
+16 -11
View File
@@ -91,10 +91,10 @@ exist, Keymap searches safe, compositor-specific locations:
followed by a scored scan of top-level `.conf` files in the MangoWC
configuration directory.
The generated `keymap.lua`, `keymap.kdl`, and `keymap.conf` files and files
whose names contain `backup` are excluded from automatic discovery. If no
usable shortcut file is found, the panel points to settings so the correct path
can be entered manually.
Legacy `keymap.lua`, `keymap.kdl`, and `keymap.conf` files and files whose names
contain `backup` are excluded from automatic discovery. If no usable shortcut
file is found, the panel points to settings so the correct path can be entered
manually.
## Browsing shortcuts
@@ -169,14 +169,19 @@ to another native catalog action by the editor.
![Known command library](screenshots/command-library.webp)
The first created shortcut adds one marked include to the configured root and
creates a sibling managed file:
New shortcuts are written directly to the configured source file. For Niri,
Keymap inserts them into the existing top-level `binds` block or creates that
block when it is absent.
| Compositor | Managed file | Marked include |
| --- | --- | --- |
| Hyprland | `keymap.lua` | `require("keymap")` |
| Niri | `keymap.kdl` | `include "keymap.kdl"` |
| MangoWC | `keymap.conf` | `source=./keymap.conf` |
Older Keymap releases stored created shortcuts in a sibling `keymap.lua`,
`keymap.kdl`, or `keymap.conf`. As soon as the Keymap service receives a valid
configuration snapshot, the writer recognizes a legacy file by its ownership
header and replaces its marked or plain include with the legacy contents. It
validates and reloads the combined configuration, confirms that neither file
changed during the operation, and only then removes the legacy file. A
validation, reload, or removal failure restores the original source and keeps
the legacy file intact. A same-named file without Keymap's ownership header is
never migrated or deleted.
## Editing and organizing
+1 -1
View File
@@ -1,6 +1,6 @@
id = "blackbartblues/keymap"
name = "Keymap"
version = "1.3.6"
version = "1.4.0"
plugin_api = 9
author = "blackbartblues"
license = "MIT"
+149 -12
View File
@@ -6,6 +6,7 @@ local failure = {
preflight = false,
validator = false,
reloadOnce = false,
remove = false,
}
local reloadAttempts = 0
@@ -44,6 +45,7 @@ noctalia = {
return true
end,
removeFile = function(path)
if failure.remove then return false end
files[path] = nil
return true
end,
@@ -139,7 +141,7 @@ local function reset(case)
files = {}
stateValues = {}
commands = {}
failure = { preflight = false, validator = false, reloadOnce = false }
failure = { preflight = false, validator = false, reloadOnce = false, remove = false }
reloadAttempts = 0
local directory = "/tmp/keymap-create-test/" .. case.id
case.rootPath = directory .. "/" .. case.rootName
@@ -188,6 +190,26 @@ local function expectedRoot(case)
return case.root .. "\n" .. includeBlock(case) .. "\n"
end
local function expectedDirectRoot(case, entries)
if case.compositor == "Niri" then
local content = case.root .. "\nbinds {\n"
for _, entry in ipairs(entries) do content = content .. entry end
return content .. "}\n"
end
if case.compositor == "MangoWC" then
local content = case.root .. "\nkeymode=default\n"
for _, entry in ipairs(entries) do content = content .. "\n" .. entry end
return content
end
local content = case.root
for _, entry in ipairs(entries) do content = content .. "\n" .. entry end
return content
end
local function expectedMigratedRoot(case, entries)
return expectedDirectRoot(case, entries)
end
local function managedHeader(case)
return case.comment .. " Managed by Noctalia Keymap.\n"
.. case.comment .. " Existing entries are preserved; new entries are appended.\n"
@@ -233,21 +255,19 @@ local function runHappyPath(case)
reset(case)
local firstResult = submit(case, case.first, case.id .. "-first")
assert(firstResult.ok == true, case.id .. ": first create failed: " .. tostring(firstResult.error))
assert(firstResult.managed_path == case.managedPath, case.id .. ": wrong managed path")
assert(files[case.rootPath] == expectedRoot(case), case.id .. ": marked include differs")
assert(files[case.managedPath] == expectedManaged(case, { case.first.entry }),
case.id .. ": first managed content differs\n" .. tostring(files[case.managedPath]))
assert(firstResult.managed_path == case.rootPath, case.id .. ": wrong result path")
assert(files[case.rootPath] == expectedDirectRoot(case, { case.first.entry }),
case.id .. ": first direct write differs\n" .. tostring(files[case.rootPath]))
assert(files[case.managedPath] == nil, case.id .. ": first create left a managed file")
assertNormalCommandSequence(case, case.id .. " first create")
assertNoTemporaryFiles(case.id .. " first create")
commands = {}
local secondResult = submit(case, case.second, case.id .. "-second")
assert(secondResult.ok == true, case.id .. ": second create failed: " .. tostring(secondResult.error))
assert(files[case.rootPath] == expectedRoot(case), case.id .. ": second create duplicated include")
local _, includeCount = files[case.rootPath]:gsub(case.includeLine:gsub("([^%w])", "%%%1"), "")
assert(includeCount == 1, case.id .. ": include line count is " .. tostring(includeCount))
assert(files[case.managedPath] == expectedManaged(case, { case.first.entry, case.second.entry }),
case.id .. ": second managed content differs\n" .. tostring(files[case.managedPath]))
assert(files[case.rootPath] == expectedDirectRoot(case, { case.first.entry, case.second.entry }),
case.id .. ": second direct write differs\n" .. tostring(files[case.rootPath]))
assert(files[case.managedPath] == nil, case.id .. ": second create left a managed file")
assertNormalCommandSequence(case, case.id .. " second create")
local rootBefore = files[case.rootPath]
@@ -301,12 +321,128 @@ local function runReloadRollbackOnAppend(case)
assertNoTemporaryFiles(case.id .. " reload rollback")
end
local function runManagedMigration(case)
reset(case)
files[case.rootPath] = expectedRoot(case)
files[case.managedPath] = expectedManaged(case, { case.first.entry })
local result = submit(case, case.second, case.id .. "-migration")
assert(result.ok == true, case.id .. ": migration failed: " .. tostring(result.error))
assert(files[case.rootPath] == expectedMigratedRoot(case, { case.first.entry, case.second.entry }),
case.id .. ": migrated root differs\n" .. tostring(files[case.rootPath]))
assert(files[case.managedPath] == nil, case.id .. ": managed file was not removed after migration")
assertNormalCommandSequence(case, case.id .. " migration")
assertNoTemporaryFiles(case.id .. " migration")
end
local function runManagedMigrationRollback(case)
reset(case)
local rootBefore = expectedRoot(case)
local managedBefore = expectedManaged(case, { case.first.entry })
files[case.rootPath] = rootBefore
files[case.managedPath] = managedBefore
failure.validator = true
failure.validatorCommand = validatorCommand(case)
local result = submit(case, case.second, case.id .. "-migration-rollback")
assert(result.ok == false and result.error == "verify_failed",
case.id .. ": migration validation failure was not reported")
assert(files[case.rootPath] == rootBefore, case.id .. ": failed migration did not restore root")
assert(files[case.managedPath] == managedBefore,
case.id .. ": failed migration removed or changed the managed file")
assertNoTemporaryFiles(case.id .. " migration rollback")
end
local function runPlainIncludeMigration(case)
reset(case)
files[case.rootPath] = case.root .. case.includeLine .. "\n"
files[case.managedPath] = expectedManaged(case, { case.first.entry })
local result = submit(case, case.second, case.id .. "-plain-migration")
assert(result.ok == true, case.id .. ": plain-include migration failed: " .. tostring(result.error))
local migrated = files[case.rootPath]
assert(type(migrated) == "string" and not migrated:find(case.includeLine, 1, true),
case.id .. ": plain include remained after migration")
assert(migrated:find(case.first.entry, 1, true) and migrated:find(case.second.entry, 1, true),
case.id .. ": plain-include migration lost shortcuts")
assert(files[case.managedPath] == nil,
case.id .. ": plain-include migration did not remove the managed file")
end
local function runManagedMigrationReloadRollback(case)
reset(case)
local rootBefore = expectedRoot(case)
local managedBefore = expectedManaged(case, { case.first.entry })
files[case.rootPath] = rootBefore
files[case.managedPath] = managedBefore
failure.reloadOnce = true
failure.reloadCommand = case.reloadCommand
local result = submit(case, case.second, case.id .. "-migration-reload-rollback")
assert(result.ok == false and result.error == "reload_failed",
case.id .. ": migration reload failure was not reported")
assert(files[case.rootPath] == rootBefore, case.id .. ": reload rollback did not restore root")
assert(files[case.managedPath] == managedBefore,
case.id .. ": reload rollback removed or changed the managed file")
assert(reloadAttempts == 2, case.id .. ": restored legacy configuration was not reloaded")
assertNoTemporaryFiles(case.id .. " migration reload rollback")
end
local function runManagedMigrationRemoveRollback(case)
reset(case)
local rootBefore = expectedRoot(case)
local managedBefore = expectedManaged(case, { case.first.entry })
files[case.rootPath] = rootBefore
files[case.managedPath] = managedBefore
failure.remove = true
local result = submit(case, case.second, case.id .. "-migration-remove-rollback")
assert(result.ok == false and result.error == "migration_remove_failed",
case.id .. ": migration removal failure was not reported")
assert(files[case.rootPath] == rootBefore, case.id .. ": removal rollback did not restore root")
assert(files[case.managedPath] == managedBefore,
case.id .. ": removal rollback changed the managed file")
assert(#commands == 4 and commands[4].command == case.reloadCommand,
case.id .. ": removal rollback did not reload the restored configuration")
assertNoTemporaryFiles(case.id .. " migration remove rollback")
end
local function runAutomaticMigration(case)
reset(case)
files[case.rootPath] = expectedRoot(case)
files[case.managedPath] = expectedManaged(case, { case.first.entry })
watchers["keymap.snapshot"](stateValues["keymap.snapshot"])
local result = stateValues["keymap.migration_result"]
assert(type(result) == "table" and result.ok == true,
case.id .. ": automatic migration failed: " .. tostring(result and result.error))
assert(files[case.rootPath] == expectedMigratedRoot(case, { case.first.entry }),
case.id .. ": automatic migration changed shortcut content")
assert(files[case.managedPath] == nil,
case.id .. ": automatic migration did not remove the legacy file")
assertNormalCommandSequence(case, case.id .. " automatic migration")
assertNoTemporaryFiles(case.id .. " automatic migration")
end
assert(loadfile("writer_service.luau"))()
for _, case in ipairs(CASES) do
runAutomaticMigration(case)
runHappyPath(case)
runVerifyRollback(case)
runReloadRollbackOnAppend(case)
runManagedMigration(case)
runManagedMigrationRollback(case)
runPlainIncludeMigration(case)
runManagedMigrationReloadRollback(case)
runManagedMigrationRemoveRollback(case)
end
do
local case = CASES[2]
reset(case)
local existing = 'layout "us"\nbinds {\n // a closing brace in a comment: }\n'
.. ' Mod+X { spawn "brace } in a string"; }\n}\n'
files[case.rootPath] = existing
local result = submit(case, case.first, "niri-existing-binds")
assert(result.ok == true, "niri existing binds create failed: " .. tostring(result.error))
local expected = existing:sub(1, -3) .. case.first.entry .. "}\n"
assert(files[case.rootPath] == expected,
"niri entry was not inserted before the matching top-level binds close")
end
local NATIVE_CASES = {
@@ -338,8 +474,9 @@ for _, native in ipairs(NATIVE_CASES) do
}
local result = submit(case, spec, native.id)
assert(result.ok == true, native.id .. ": native create failed: " .. tostring(result.error))
assert(files[case.managedPath] == expectedManaged(case, { native.entry }),
native.id .. ": wrong native managed entry")
assert(files[case.rootPath] == expectedDirectRoot(case, { native.entry }),
native.id .. ": wrong native root entry")
assert(files[case.managedPath] == nil, native.id .. ": native create left a managed file")
end
do
+5 -3
View File
@@ -163,7 +163,7 @@
"description_not_editable": "The description in this shortcut cannot be changed safely.",
"description_required": "Enter a shortcut description.",
"different_source": "These shortcuts are stored in different files and cannot be reordered safely.",
"file_too_large": "The managed keybinding file is too large.",
"file_too_large": "The keybinding file is too large.",
"hidden_block_invalid": "The hidden shortcut block is incomplete or damaged, so it was left unchanged.",
"integrate_failed": "The managed file could not be included from the main configuration.",
"invalid_command_kind": "The selected command type is not supported.",
@@ -182,6 +182,8 @@
"managed_rename_failed": "The managed keybinding file could not be installed atomically.",
"managed_temporary_collision": "A safe temporary filename could not be allocated.",
"managed_write_failed": "The managed keybinding file could not be written.",
"migration_cleanup_changed": "The configuration or legacy Keymap file changed during migration, so automatic cleanup stopped for safety.",
"migration_remove_failed": "The legacy Keymap file could not be removed, so the original configuration was restored.",
"mango_command_hash_unsupported": "MangoWC commands containing # are not supported by this first writer version.",
"native_update_unsupported": "Existing shortcuts cannot be converted to native actions safely.",
"preflight_failed": "The configuration safety check could not be started.",
@@ -197,7 +199,7 @@
"source_rename_failed": "The main configuration could not be updated atomically.",
"source_temporary_collision": "A safe temporary filename could not be allocated.",
"source_unreadable": "The main compositor configuration could not be read.",
"source_write_failed": "The managed file could not be included from the main configuration.",
"source_write_failed": "The main configuration file could not be written.",
"stale_category": "The category changed while its editor was open. Refresh and try again.",
"stale_context": "The compositor configuration changed while the creator was open. Refresh and reopen the creator.",
"symlink_unsupported": "The main or managed configuration is a symbolic link. Saving is blocked to preserve the link safely.",
@@ -212,7 +214,7 @@
"verify_failed": "The compositor rejected the generated configuration.",
"verify_start_failed": "Configuration validation could not be started; no changes were kept.",
"verify_timeout": "Configuration validation timed out; no changes were kept.",
"write_failed": "The managed keybinding file could not be written."
"write_failed": "The keybinding file could not be written."
},
"hyprland_hint": "Hyprland supports one or more ordinary keys. Click selected keys again to remove them.",
"list_hint": "Choose modifiers and enter the key or comma-separated key sequence. You can also create shortcuts from the keyboard view.",
+242 -62
View File
@@ -1,12 +1,13 @@
--!nonstrict
-- Managed keybind writer for Keymap.
-- The Keymap storage namespace is shared by managed files, includes,
-- category markers, and hidden blocks.
-- 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"
@@ -23,6 +24,7 @@ local HIDDEN_DATA_CHUNK_BYTES = 48
local busy = false
local temporaryCounter = 0
local automaticMigrationAttempt = ""
local FORMATS = {
Hyprland = {
@@ -298,6 +300,14 @@ local function publishUpdateResult(requestId, ok, errorCode, targetPath)
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
@@ -373,56 +383,139 @@ local function generatedEntry(request)
.. "," .. dispatcher .. " #\"" .. description .. "\"\n"
end
local function buildManagedContent(request, existing, entry)
if existing == nil then
if request.compositor == "Niri" then
return managedHeader(request.compositor) .. "binds {\n" .. entry .. "}\n", nil
end
if request.compositor == "MangoWC" then
return managedHeader(request.compositor) .. "\nkeymode=default\n\n" .. entry, nil
end
return managedHeader(request.compositor) .. "\n" .. entry, nil
end
if not ownsManagedFile(request.compositor, existing) then return nil, "managed_file_collision" end
if #existing > MAX_MANAGED_BYTES then return nil, "managed_file_too_large" end
if existing:find(entry, 1, true) ~= nil then return existing, nil end
local output
if request.compositor == "Niri" then
local beforeClose = existing:match("^(.*\n)%s*}%s*$")
if beforeClose == nil then return nil, "managed_file_invalid" end
output = beforeClose .. entry .. "}\n"
else
local separator = (existing == "" or existing:sub(-1) == "\n") and "" or "\n"
output = existing .. separator .. entry
end
if #output > MAX_MANAGED_BYTES then return nil, "managed_file_too_large" end
return output, nil
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 hasExactLine(content, expected)
for line in (content .. "\n"):gmatch("([^\n]*)\n") do
if trim(line) == expected then return true 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 false
return content, false
end
local function buildRootContent(root, format)
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)
if root:find(block, 1, true) ~= nil or hasExactLine(root, format.includeLine) then
return root, nil
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 .. block .. "\n", nil
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)
@@ -510,7 +603,20 @@ local function reloadAndFinish(request, transaction)
publishFailureAfterRollback(request, transaction, "reload_failed", true)
return
end
finishSuccess(request, transaction.managedPath)
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)
@@ -670,19 +776,31 @@ processValidatedRequest = function(request)
end
end
local entry = generatedEntry(request)
local existingExactEntry = existing ~= nil and existing:find(entry, 1, true) ~= nil
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 managed, managedError = buildManagedContent(request, existing, entry)
if managed == nil then
publishResult(request.request_id, false, managedError, managedPath)
busy = false
return
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 = buildRootContent(root, request.format)
local newRoot, rootError = appendEntryToRoot(request.compositor, migratedRoot, entry)
if newRoot == nil then
publishResult(request.request_id, false, rootError, managedPath)
busy = false
@@ -720,24 +838,11 @@ processValidatedRequest = function(request)
rootWritten = false,
managedPath = managedPath,
managedOld = existing,
managedNew = managed,
managedNew = existing,
managedExisted = existing ~= nil,
managedWritten = false,
removeManagedAfterSuccess = existing ~= nil,
}
if existing ~= managed then
if existing ~= nil and noctalia.readFile(managedPath) ~= existing then
publishResult(request.request_id, false, "managed_file_changed", managedPath)
busy = false
return
end
local ok, writeError = atomicWrite(managedPath, managed)
if not ok then
publishResult(request.request_id, false, "managed_" .. writeError, managedPath)
busy = false
return
end
transaction.managedWritten = true
end
if root ~= newRoot then
if noctalia.readFile(request.source) ~= root then
publishFailureAfterRollback(request, transaction, "source_changed", false)
@@ -751,12 +856,81 @@ processValidatedRequest = function(request)
transaction.rootWritten = true
end
if not transaction.managedWritten and not transaction.rootWritten then
finishSuccess(request, managedPath)
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
@@ -1976,3 +2150,9 @@ noctalia.state.watch(UPDATE_REQUEST_KEY, function(request)
end
processUpdateRequest(request)
end)
noctalia.state.watch(SNAPSHOT_KEY, function(snapshotValue)
processAutomaticMigration(snapshotValue)
end)
processAutomaticMigration(noctalia.state.get(SNAPSHOT_KEY))