diff --git a/keymap/README.md b/keymap/README.md index 4058541..aa43a5b 100644 --- a/keymap/README.md +++ b/keymap/README.md @@ -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 diff --git a/keymap/plugin.toml b/keymap/plugin.toml index f83d161..3f43dfa 100644 --- a/keymap/plugin.toml +++ b/keymap/plugin.toml @@ -1,6 +1,6 @@ id = "blackbartblues/keymap" name = "Keymap" -version = "1.3.6" +version = "1.4.0" plugin_api = 9 author = "blackbartblues" license = "MIT" diff --git a/keymap/tests/writer_create_test.lua b/keymap/tests/writer_create_test.lua index 2aa02c5..1ec42d7 100644 --- a/keymap/tests/writer_create_test.lua +++ b/keymap/tests/writer_create_test.lua @@ -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 diff --git a/keymap/translations/en.json b/keymap/translations/en.json index 055b855..294bd49 100644 --- a/keymap/translations/en.json +++ b/keymap/translations/en.json @@ -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.", diff --git a/keymap/writer_service.luau b/keymap/writer_service.luau index 004f97c..01a3c23 100644 --- a/keymap/writer_service.luau +++ b/keymap/writer_service.luau @@ -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))