feat(keymap): write shortcuts into active config (#299)
This commit is contained in:
+16
-11
@@ -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.
|
||||
|
||||

|
||||
|
||||
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
@@ -1,6 +1,6 @@
|
||||
id = "blackbartblues/keymap"
|
||||
name = "Keymap"
|
||||
version = "1.3.6"
|
||||
version = "1.4.0"
|
||||
plugin_api = 9
|
||||
author = "blackbartblues"
|
||||
license = "MIT"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user