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
|
followed by a scored scan of top-level `.conf` files in the MangoWC
|
||||||
configuration directory.
|
configuration directory.
|
||||||
|
|
||||||
The generated `keymap.lua`, `keymap.kdl`, and `keymap.conf` files and files
|
Legacy `keymap.lua`, `keymap.kdl`, and `keymap.conf` files and files whose names
|
||||||
whose names contain `backup` are excluded from automatic discovery. If no
|
contain `backup` are excluded from automatic discovery. If no usable shortcut
|
||||||
usable shortcut file is found, the panel points to settings so the correct path
|
file is found, the panel points to settings so the correct path can be entered
|
||||||
can be entered manually.
|
manually.
|
||||||
|
|
||||||
## Browsing shortcuts
|
## 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
|
New shortcuts are written directly to the configured source file. For Niri,
|
||||||
creates a sibling managed file:
|
Keymap inserts them into the existing top-level `binds` block or creates that
|
||||||
|
block when it is absent.
|
||||||
|
|
||||||
| Compositor | Managed file | Marked include |
|
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
|
||||||
| Hyprland | `keymap.lua` | `require("keymap")` |
|
configuration snapshot, the writer recognizes a legacy file by its ownership
|
||||||
| Niri | `keymap.kdl` | `include "keymap.kdl"` |
|
header and replaces its marked or plain include with the legacy contents. It
|
||||||
| MangoWC | `keymap.conf` | `source=./keymap.conf` |
|
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
|
## Editing and organizing
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
id = "blackbartblues/keymap"
|
id = "blackbartblues/keymap"
|
||||||
name = "Keymap"
|
name = "Keymap"
|
||||||
version = "1.3.6"
|
version = "1.4.0"
|
||||||
plugin_api = 9
|
plugin_api = 9
|
||||||
author = "blackbartblues"
|
author = "blackbartblues"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ local failure = {
|
|||||||
preflight = false,
|
preflight = false,
|
||||||
validator = false,
|
validator = false,
|
||||||
reloadOnce = false,
|
reloadOnce = false,
|
||||||
|
remove = false,
|
||||||
}
|
}
|
||||||
local reloadAttempts = 0
|
local reloadAttempts = 0
|
||||||
|
|
||||||
@@ -44,6 +45,7 @@ noctalia = {
|
|||||||
return true
|
return true
|
||||||
end,
|
end,
|
||||||
removeFile = function(path)
|
removeFile = function(path)
|
||||||
|
if failure.remove then return false end
|
||||||
files[path] = nil
|
files[path] = nil
|
||||||
return true
|
return true
|
||||||
end,
|
end,
|
||||||
@@ -139,7 +141,7 @@ local function reset(case)
|
|||||||
files = {}
|
files = {}
|
||||||
stateValues = {}
|
stateValues = {}
|
||||||
commands = {}
|
commands = {}
|
||||||
failure = { preflight = false, validator = false, reloadOnce = false }
|
failure = { preflight = false, validator = false, reloadOnce = false, remove = false }
|
||||||
reloadAttempts = 0
|
reloadAttempts = 0
|
||||||
local directory = "/tmp/keymap-create-test/" .. case.id
|
local directory = "/tmp/keymap-create-test/" .. case.id
|
||||||
case.rootPath = directory .. "/" .. case.rootName
|
case.rootPath = directory .. "/" .. case.rootName
|
||||||
@@ -188,6 +190,26 @@ local function expectedRoot(case)
|
|||||||
return case.root .. "\n" .. includeBlock(case) .. "\n"
|
return case.root .. "\n" .. includeBlock(case) .. "\n"
|
||||||
end
|
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)
|
local function managedHeader(case)
|
||||||
return case.comment .. " Managed by Noctalia Keymap.\n"
|
return case.comment .. " Managed by Noctalia Keymap.\n"
|
||||||
.. case.comment .. " Existing entries are preserved; new entries are appended.\n"
|
.. case.comment .. " Existing entries are preserved; new entries are appended.\n"
|
||||||
@@ -233,21 +255,19 @@ local function runHappyPath(case)
|
|||||||
reset(case)
|
reset(case)
|
||||||
local firstResult = submit(case, case.first, case.id .. "-first")
|
local firstResult = submit(case, case.first, case.id .. "-first")
|
||||||
assert(firstResult.ok == true, case.id .. ": first create failed: " .. tostring(firstResult.error))
|
assert(firstResult.ok == true, case.id .. ": first create failed: " .. tostring(firstResult.error))
|
||||||
assert(firstResult.managed_path == case.managedPath, case.id .. ": wrong managed path")
|
assert(firstResult.managed_path == case.rootPath, case.id .. ": wrong result path")
|
||||||
assert(files[case.rootPath] == expectedRoot(case), case.id .. ": marked include differs")
|
assert(files[case.rootPath] == expectedDirectRoot(case, { case.first.entry }),
|
||||||
assert(files[case.managedPath] == expectedManaged(case, { case.first.entry }),
|
case.id .. ": first direct write differs\n" .. tostring(files[case.rootPath]))
|
||||||
case.id .. ": first managed content differs\n" .. tostring(files[case.managedPath]))
|
assert(files[case.managedPath] == nil, case.id .. ": first create left a managed file")
|
||||||
assertNormalCommandSequence(case, case.id .. " first create")
|
assertNormalCommandSequence(case, case.id .. " first create")
|
||||||
assertNoTemporaryFiles(case.id .. " first create")
|
assertNoTemporaryFiles(case.id .. " first create")
|
||||||
|
|
||||||
commands = {}
|
commands = {}
|
||||||
local secondResult = submit(case, case.second, case.id .. "-second")
|
local secondResult = submit(case, case.second, case.id .. "-second")
|
||||||
assert(secondResult.ok == true, case.id .. ": second create failed: " .. tostring(secondResult.error))
|
assert(secondResult.ok == true, case.id .. ": second create failed: " .. tostring(secondResult.error))
|
||||||
assert(files[case.rootPath] == expectedRoot(case), case.id .. ": second create duplicated include")
|
assert(files[case.rootPath] == expectedDirectRoot(case, { case.first.entry, case.second.entry }),
|
||||||
local _, includeCount = files[case.rootPath]:gsub(case.includeLine:gsub("([^%w])", "%%%1"), "")
|
case.id .. ": second direct write differs\n" .. tostring(files[case.rootPath]))
|
||||||
assert(includeCount == 1, case.id .. ": include line count is " .. tostring(includeCount))
|
assert(files[case.managedPath] == nil, case.id .. ": second create left a managed file")
|
||||||
assert(files[case.managedPath] == expectedManaged(case, { case.first.entry, case.second.entry }),
|
|
||||||
case.id .. ": second managed content differs\n" .. tostring(files[case.managedPath]))
|
|
||||||
assertNormalCommandSequence(case, case.id .. " second create")
|
assertNormalCommandSequence(case, case.id .. " second create")
|
||||||
|
|
||||||
local rootBefore = files[case.rootPath]
|
local rootBefore = files[case.rootPath]
|
||||||
@@ -301,12 +321,128 @@ local function runReloadRollbackOnAppend(case)
|
|||||||
assertNoTemporaryFiles(case.id .. " reload rollback")
|
assertNoTemporaryFiles(case.id .. " reload rollback")
|
||||||
end
|
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"))()
|
assert(loadfile("writer_service.luau"))()
|
||||||
|
|
||||||
for _, case in ipairs(CASES) do
|
for _, case in ipairs(CASES) do
|
||||||
|
runAutomaticMigration(case)
|
||||||
runHappyPath(case)
|
runHappyPath(case)
|
||||||
runVerifyRollback(case)
|
runVerifyRollback(case)
|
||||||
runReloadRollbackOnAppend(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
|
end
|
||||||
|
|
||||||
local NATIVE_CASES = {
|
local NATIVE_CASES = {
|
||||||
@@ -338,8 +474,9 @@ for _, native in ipairs(NATIVE_CASES) do
|
|||||||
}
|
}
|
||||||
local result = submit(case, spec, native.id)
|
local result = submit(case, spec, native.id)
|
||||||
assert(result.ok == true, native.id .. ": native create failed: " .. tostring(result.error))
|
assert(result.ok == true, native.id .. ": native create failed: " .. tostring(result.error))
|
||||||
assert(files[case.managedPath] == expectedManaged(case, { native.entry }),
|
assert(files[case.rootPath] == expectedDirectRoot(case, { native.entry }),
|
||||||
native.id .. ": wrong native managed entry")
|
native.id .. ": wrong native root entry")
|
||||||
|
assert(files[case.managedPath] == nil, native.id .. ": native create left a managed file")
|
||||||
end
|
end
|
||||||
|
|
||||||
do
|
do
|
||||||
|
|||||||
@@ -163,7 +163,7 @@
|
|||||||
"description_not_editable": "The description in this shortcut cannot be changed safely.",
|
"description_not_editable": "The description in this shortcut cannot be changed safely.",
|
||||||
"description_required": "Enter a shortcut description.",
|
"description_required": "Enter a shortcut description.",
|
||||||
"different_source": "These shortcuts are stored in different files and cannot be reordered safely.",
|
"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.",
|
"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.",
|
"integrate_failed": "The managed file could not be included from the main configuration.",
|
||||||
"invalid_command_kind": "The selected command type is not supported.",
|
"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_rename_failed": "The managed keybinding file could not be installed atomically.",
|
||||||
"managed_temporary_collision": "A safe temporary filename could not be allocated.",
|
"managed_temporary_collision": "A safe temporary filename could not be allocated.",
|
||||||
"managed_write_failed": "The managed keybinding file could not be written.",
|
"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.",
|
"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.",
|
"native_update_unsupported": "Existing shortcuts cannot be converted to native actions safely.",
|
||||||
"preflight_failed": "The configuration safety check could not be started.",
|
"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_rename_failed": "The main configuration could not be updated atomically.",
|
||||||
"source_temporary_collision": "A safe temporary filename could not be allocated.",
|
"source_temporary_collision": "A safe temporary filename could not be allocated.",
|
||||||
"source_unreadable": "The main compositor configuration could not be read.",
|
"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_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.",
|
"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.",
|
"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_failed": "The compositor rejected the generated configuration.",
|
||||||
"verify_start_failed": "Configuration validation could not be started; no changes were kept.",
|
"verify_start_failed": "Configuration validation could not be started; no changes were kept.",
|
||||||
"verify_timeout": "Configuration validation timed out; 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.",
|
"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.",
|
"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
|
--!nonstrict
|
||||||
-- Managed keybind writer for Keymap.
|
-- Transactional keybind writer for Keymap.
|
||||||
-- The Keymap storage namespace is shared by managed files, includes,
|
-- New shortcuts are written into the configured source. Legacy managed files
|
||||||
-- category markers, and hidden blocks.
|
-- are inlined and removed only after validation and reload succeed.
|
||||||
|
|
||||||
local CREATE_REQUEST_KEY = "keymap.create_request"
|
local CREATE_REQUEST_KEY = "keymap.create_request"
|
||||||
local CREATE_RESULT_KEY = "keymap.create_result"
|
local CREATE_RESULT_KEY = "keymap.create_result"
|
||||||
local UPDATE_REQUEST_KEY = "keymap.update_request"
|
local UPDATE_REQUEST_KEY = "keymap.update_request"
|
||||||
local UPDATE_RESULT_KEY = "keymap.update_result"
|
local UPDATE_RESULT_KEY = "keymap.update_result"
|
||||||
|
local MIGRATION_RESULT_KEY = "keymap.migration_result"
|
||||||
local REFRESH_REQUEST_KEY = "keymap.refresh_request"
|
local REFRESH_REQUEST_KEY = "keymap.refresh_request"
|
||||||
local LAST_HANDLED_REQUEST_KEY = "keymap.last_handled_create_request"
|
local LAST_HANDLED_REQUEST_KEY = "keymap.last_handled_create_request"
|
||||||
local LAST_HANDLED_UPDATE_KEY = "keymap.last_handled_update_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 busy = false
|
||||||
local temporaryCounter = 0
|
local temporaryCounter = 0
|
||||||
|
local automaticMigrationAttempt = ""
|
||||||
|
|
||||||
local FORMATS = {
|
local FORMATS = {
|
||||||
Hyprland = {
|
Hyprland = {
|
||||||
@@ -298,6 +300,14 @@ local function publishUpdateResult(requestId, ok, errorCode, targetPath)
|
|||||||
end
|
end
|
||||||
|
|
||||||
local function publishOperationResult(request, ok, errorCode, path)
|
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
|
if request.operation ~= nil then
|
||||||
publishUpdateResult(request.request_id, ok, errorCode, path)
|
publishUpdateResult(request.request_id, ok, errorCode, path)
|
||||||
else
|
else
|
||||||
@@ -373,56 +383,139 @@ local function generatedEntry(request)
|
|||||||
.. "," .. dispatcher .. " #\"" .. description .. "\"\n"
|
.. "," .. dispatcher .. " #\"" .. description .. "\"\n"
|
||||||
end
|
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 function includeBlock(format)
|
||||||
local beginMarker = format.comment .. " BEGIN Keymap managed include"
|
local beginMarker = format.comment .. " BEGIN Keymap managed include"
|
||||||
local endMarker = format.comment .. " END Keymap managed include"
|
local endMarker = format.comment .. " END Keymap managed include"
|
||||||
return beginMarker, endMarker, beginMarker .. "\n" .. format.includeLine .. "\n" .. endMarker
|
return beginMarker, endMarker, beginMarker .. "\n" .. format.includeLine .. "\n" .. endMarker
|
||||||
end
|
end
|
||||||
|
|
||||||
local function hasExactLine(content, expected)
|
local function replaceExactLine(content, expected, replacement)
|
||||||
for line in (content .. "\n"):gmatch("([^\n]*)\n") do
|
local cursor = 1
|
||||||
if trim(line) == expected then return true end
|
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
|
end
|
||||||
return false
|
return content, false
|
||||||
end
|
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)
|
local beginMarker, endMarker, block = includeBlock(format)
|
||||||
if root:find(block, 1, true) ~= nil or hasExactLine(root, format.includeLine) then
|
local blockStart, blockEnd = root:find(block, 1, true)
|
||||||
return root, nil
|
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
|
end
|
||||||
if root:find(beginMarker, 1, true) ~= nil or root:find(endMarker, 1, true) ~= nil then
|
if root:find(beginMarker, 1, true) ~= nil or root:find(endMarker, 1, true) ~= nil then
|
||||||
return nil, "managed_include_invalid"
|
return nil, "managed_include_invalid"
|
||||||
end
|
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")
|
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
|
end
|
||||||
|
|
||||||
local function atomicWrite(path, content)
|
local function atomicWrite(path, content)
|
||||||
@@ -510,7 +603,20 @@ local function reloadAndFinish(request, transaction)
|
|||||||
publishFailureAfterRollback(request, transaction, "reload_failed", true)
|
publishFailureAfterRollback(request, transaction, "reload_failed", true)
|
||||||
return
|
return
|
||||||
end
|
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)
|
end, RELOAD_TIMEOUT_MS)
|
||||||
if not started then
|
if not started then
|
||||||
publishFailureAfterRollback(request, transaction, "reload_start_failed", true)
|
publishFailureAfterRollback(request, transaction, "reload_start_failed", true)
|
||||||
@@ -670,19 +776,31 @@ processValidatedRequest = function(request)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
local entry = generatedEntry(request)
|
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
|
if conflictsWithSnapshot(request) and not existingExactEntry then
|
||||||
publishResult(request.request_id, false, "conflict_blocked", managedPath)
|
publishResult(request.request_id, false, "conflict_blocked", managedPath)
|
||||||
busy = false
|
busy = false
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local managed, managedError = buildManagedContent(request, existing, entry)
|
|
||||||
if managed == nil then
|
local migratedRoot = root
|
||||||
publishResult(request.request_id, false, managedError, managedPath)
|
if existing ~= nil then
|
||||||
busy = false
|
local payload, payloadError = managedPayload(request.compositor, existing)
|
||||||
return
|
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
|
end
|
||||||
local newRoot, rootError = buildRootContent(root, request.format)
|
local newRoot, rootError = appendEntryToRoot(request.compositor, migratedRoot, entry)
|
||||||
if newRoot == nil then
|
if newRoot == nil then
|
||||||
publishResult(request.request_id, false, rootError, managedPath)
|
publishResult(request.request_id, false, rootError, managedPath)
|
||||||
busy = false
|
busy = false
|
||||||
@@ -720,24 +838,11 @@ processValidatedRequest = function(request)
|
|||||||
rootWritten = false,
|
rootWritten = false,
|
||||||
managedPath = managedPath,
|
managedPath = managedPath,
|
||||||
managedOld = existing,
|
managedOld = existing,
|
||||||
managedNew = managed,
|
managedNew = existing,
|
||||||
managedExisted = existing ~= nil,
|
managedExisted = existing ~= nil,
|
||||||
managedWritten = false,
|
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 root ~= newRoot then
|
||||||
if noctalia.readFile(request.source) ~= root then
|
if noctalia.readFile(request.source) ~= root then
|
||||||
publishFailureAfterRollback(request, transaction, "source_changed", false)
|
publishFailureAfterRollback(request, transaction, "source_changed", false)
|
||||||
@@ -751,12 +856,81 @@ processValidatedRequest = function(request)
|
|||||||
transaction.rootWritten = true
|
transaction.rootWritten = true
|
||||||
end
|
end
|
||||||
if not transaction.managedWritten and not transaction.rootWritten then
|
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
|
return
|
||||||
end
|
end
|
||||||
verifyAndReload(request, transaction)
|
verifyAndReload(request, transaction)
|
||||||
end
|
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 function xorNibbleSlow(left, right)
|
||||||
local result, place = 0, 1
|
local result, place = 0, 1
|
||||||
for _ = 1, 4 do
|
for _ = 1, 4 do
|
||||||
@@ -1976,3 +2150,9 @@ noctalia.state.watch(UPDATE_REQUEST_KEY, function(request)
|
|||||||
end
|
end
|
||||||
processUpdateRequest(request)
|
processUpdateRequest(request)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
noctalia.state.watch(SNAPSHOT_KEY, function(snapshotValue)
|
||||||
|
processAutomaticMigration(snapshotValue)
|
||||||
|
end)
|
||||||
|
|
||||||
|
processAutomaticMigration(noctalia.state.get(SNAPSHOT_KEY))
|
||||||
|
|||||||
Reference in New Issue
Block a user