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

This commit is contained in:
blacku
2026-08-08 10:45:44 -04:00
committed by GitHub
parent 31d092f76f
commit 66a1729097
5 changed files with 413 additions and 89 deletions
+242 -62
View File
@@ -1,12 +1,13 @@
--!nonstrict
-- Managed keybind writer for Keymap.
-- The Keymap storage namespace is shared by managed files, includes,
-- category markers, and hidden blocks.
-- Transactional keybind writer for Keymap.
-- New shortcuts are written into the configured source. Legacy managed files
-- are inlined and removed only after validation and reload succeed.
local CREATE_REQUEST_KEY = "keymap.create_request"
local CREATE_RESULT_KEY = "keymap.create_result"
local UPDATE_REQUEST_KEY = "keymap.update_request"
local UPDATE_RESULT_KEY = "keymap.update_result"
local MIGRATION_RESULT_KEY = "keymap.migration_result"
local REFRESH_REQUEST_KEY = "keymap.refresh_request"
local LAST_HANDLED_REQUEST_KEY = "keymap.last_handled_create_request"
local LAST_HANDLED_UPDATE_KEY = "keymap.last_handled_update_request"
@@ -23,6 +24,7 @@ local HIDDEN_DATA_CHUNK_BYTES = 48
local busy = false
local temporaryCounter = 0
local automaticMigrationAttempt = ""
local FORMATS = {
Hyprland = {
@@ -298,6 +300,14 @@ local function publishUpdateResult(requestId, ok, errorCode, targetPath)
end
local function publishOperationResult(request, ok, errorCode, path)
if request.silentMigration == true then
noctalia.state.set(MIGRATION_RESULT_KEY, {
ok = ok == true, error = errorCode or "", source = request.source or "",
legacy_path = path or "",
})
if ok ~= true then automaticMigrationAttempt = "" end
return
end
if request.operation ~= nil then
publishUpdateResult(request.request_id, ok, errorCode, path)
else
@@ -373,56 +383,139 @@ local function generatedEntry(request)
.. "," .. dispatcher .. " #\"" .. description .. "\"\n"
end
local function buildManagedContent(request, existing, entry)
if existing == nil then
if request.compositor == "Niri" then
return managedHeader(request.compositor) .. "binds {\n" .. entry .. "}\n", nil
end
if request.compositor == "MangoWC" then
return managedHeader(request.compositor) .. "\nkeymode=default\n\n" .. entry, nil
end
return managedHeader(request.compositor) .. "\n" .. entry, nil
end
if not ownsManagedFile(request.compositor, existing) then return nil, "managed_file_collision" end
if #existing > MAX_MANAGED_BYTES then return nil, "managed_file_too_large" end
if existing:find(entry, 1, true) ~= nil then return existing, nil end
local output
if request.compositor == "Niri" then
local beforeClose = existing:match("^(.*\n)%s*}%s*$")
if beforeClose == nil then return nil, "managed_file_invalid" end
output = beforeClose .. entry .. "}\n"
else
local separator = (existing == "" or existing:sub(-1) == "\n") and "" or "\n"
output = existing .. separator .. entry
end
if #output > MAX_MANAGED_BYTES then return nil, "managed_file_too_large" end
return output, nil
end
local function includeBlock(format)
local beginMarker = format.comment .. " BEGIN Keymap managed include"
local endMarker = format.comment .. " END Keymap managed include"
return beginMarker, endMarker, beginMarker .. "\n" .. format.includeLine .. "\n" .. endMarker
end
local function hasExactLine(content, expected)
for line in (content .. "\n"):gmatch("([^\n]*)\n") do
if trim(line) == expected then return true end
local function replaceExactLine(content, expected, replacement)
local cursor = 1
while cursor <= #content + 1 do
local newline = content:find("\n", cursor, true)
local lineEnd = newline or (#content + 1)
if trim(content:sub(cursor, lineEnd - 1)) == expected then
local suffixStart = newline ~= nil and newline + 1 or lineEnd
return content:sub(1, cursor - 1) .. replacement .. content:sub(suffixStart), true
end
if newline == nil then break end
cursor = newline + 1
end
return false
return content, false
end
local function buildRootContent(root, format)
local function managedPayload(compositor, content)
if not ownsManagedFile(compositor, content) then return nil, "managed_file_collision" end
if #content > MAX_MANAGED_BYTES then return nil, "managed_file_too_large" end
local payload = content:sub(#managedHeader(compositor) + 1)
if payload:sub(1, 1) == "\n" then payload = payload:sub(2) end
return payload, nil
end
local function inlineManagedContent(root, format, payload)
local beginMarker, endMarker, block = includeBlock(format)
if root:find(block, 1, true) ~= nil or hasExactLine(root, format.includeLine) then
return root, nil
local blockStart, blockEnd = root:find(block, 1, true)
if blockStart ~= nil then
local suffixStart = blockEnd + 1
if payload:sub(-1) == "\n" and root:sub(suffixStart, suffixStart) == "\n" then
suffixStart = suffixStart + 1
end
return root:sub(1, blockStart - 1) .. payload .. root:sub(suffixStart), nil
end
if root:find(beginMarker, 1, true) ~= nil or root:find(endMarker, 1, true) ~= nil then
return nil, "managed_include_invalid"
end
local replaced, found = replaceExactLine(root, format.includeLine, payload)
if found then return replaced, nil end
local separator = root == "" and "" or (root:sub(-1) == "\n" and "\n" or "\n\n")
return root .. separator .. block .. "\n", nil
return root .. separator .. payload, nil
end
local function niriBindsClose(content)
local index = 1
local depth = 0
local quote = nil
local escaped = false
local lineComment = false
local blockComment = false
local bindsDepth = nil
while index <= #content do
local char = content:sub(index, index)
local pair = content:sub(index, index + 1)
if lineComment then
if char == "\n" then lineComment = false end
index = index + 1
elseif blockComment then
if pair == "*/" then
blockComment = false
index = index + 2
else
index = index + 1
end
elseif quote ~= nil then
if escaped then escaped = false
elseif char == "\\" then escaped = true
elseif char == quote then quote = nil end
index = index + 1
elseif pair == "//" then
lineComment = true
index = index + 2
elseif pair == "/*" then
blockComment = true
index = index + 2
elseif char == '"' or char == "'" then
quote = char
index = index + 1
elseif bindsDepth == nil and depth == 0 and content:sub(index, index + 4) == "binds"
and (index == 1 or not content:sub(index - 1, index - 1):match("[%w_%-]"))
and not content:sub(index + 5, index + 5):match("[%w_%-]") then
local open = index + 5
while content:sub(open, open):match("%s") do open = open + 1 end
if content:sub(open, open) == "{" then
depth = depth + 1
bindsDepth = depth
index = open + 1
else
index = index + 5
end
elseif char == "{" then
depth = depth + 1
index = index + 1
elseif char == "}" then
if bindsDepth ~= nil and depth == bindsDepth then return index end
depth = depth - 1
index = index + 1
else
index = index + 1
end
end
return nil
end
local function appendEntryToRoot(compositor, root, entry)
if root:find(entry, 1, true) ~= nil then return root, nil end
if compositor == "Niri" then
local closeAt = niriBindsClose(root)
if closeAt == nil then
local separator = root == "" and "" or (root:sub(-1) == "\n" and "\n" or "\n\n")
return root .. separator .. "binds {\n" .. entry .. "}\n", nil
end
local lineStart = (root:sub(1, closeAt - 1):match(".*\n()") or 1)
if root:sub(lineStart, closeAt - 1):match("^%s*$") then
return root:sub(1, lineStart - 1) .. entry .. root:sub(lineStart), nil
end
return root:sub(1, closeAt - 1) .. "\n" .. entry .. root:sub(closeAt), nil
end
if compositor == "MangoWC" then
local activeKeymode = nil
for line in (root .. "\n"):gmatch("([^\n]*)\n") do
local mode = line:match("^%s*keymode%s*=%s*([^%s#]+)")
if mode ~= nil then activeKeymode = mode end
end
if activeKeymode ~= "default" then entry = "keymode=default\n\n" .. entry end
end
local separator = root == "" and "" or (root:sub(-1) == "\n" and "\n" or "\n\n")
return root .. separator .. entry, nil
end
local function atomicWrite(path, content)
@@ -510,7 +603,20 @@ local function reloadAndFinish(request, transaction)
publishFailureAfterRollback(request, transaction, "reload_failed", true)
return
end
finishSuccess(request, transaction.managedPath)
if transaction.removeManagedAfterSuccess then
if noctalia.readFile(transaction.source) ~= transaction.rootNew
or noctalia.readFile(transaction.managedPath) ~= transaction.managedOld then
publishOperationResult(request, false, "migration_cleanup_changed", transaction.managedPath)
busy = false
return
end
if noctalia.removeFile(transaction.managedPath) ~= true then
publishFailureAfterRollback(request, transaction, "migration_remove_failed", true)
return
end
end
local successPath = request.operation == nil and transaction.source or transaction.managedPath
finishSuccess(request, successPath)
end, RELOAD_TIMEOUT_MS)
if not started then
publishFailureAfterRollback(request, transaction, "reload_start_failed", true)
@@ -670,19 +776,31 @@ processValidatedRequest = function(request)
end
end
local entry = generatedEntry(request)
local existingExactEntry = existing ~= nil and existing:find(entry, 1, true) ~= nil
local existingExactEntry = root:find(entry, 1, true) ~= nil
or (existing ~= nil and existing:find(entry, 1, true) ~= nil)
if conflictsWithSnapshot(request) and not existingExactEntry then
publishResult(request.request_id, false, "conflict_blocked", managedPath)
busy = false
return
end
local managed, managedError = buildManagedContent(request, existing, entry)
if managed == nil then
publishResult(request.request_id, false, managedError, managedPath)
busy = false
return
local migratedRoot = root
if existing ~= nil then
local payload, payloadError = managedPayload(request.compositor, existing)
if payload == nil then
publishResult(request.request_id, false, payloadError, managedPath)
busy = false
return
end
local migrationError
migratedRoot, migrationError = inlineManagedContent(root, request.format, payload)
if migratedRoot == nil then
publishResult(request.request_id, false, migrationError, managedPath)
busy = false
return
end
end
local newRoot, rootError = buildRootContent(root, request.format)
local newRoot, rootError = appendEntryToRoot(request.compositor, migratedRoot, entry)
if newRoot == nil then
publishResult(request.request_id, false, rootError, managedPath)
busy = false
@@ -720,24 +838,11 @@ processValidatedRequest = function(request)
rootWritten = false,
managedPath = managedPath,
managedOld = existing,
managedNew = managed,
managedNew = existing,
managedExisted = existing ~= nil,
managedWritten = false,
removeManagedAfterSuccess = existing ~= nil,
}
if existing ~= managed then
if existing ~= nil and noctalia.readFile(managedPath) ~= existing then
publishResult(request.request_id, false, "managed_file_changed", managedPath)
busy = false
return
end
local ok, writeError = atomicWrite(managedPath, managed)
if not ok then
publishResult(request.request_id, false, "managed_" .. writeError, managedPath)
busy = false
return
end
transaction.managedWritten = true
end
if root ~= newRoot then
if noctalia.readFile(request.source) ~= root then
publishFailureAfterRollback(request, transaction, "source_changed", false)
@@ -751,12 +856,81 @@ processValidatedRequest = function(request)
transaction.rootWritten = true
end
if not transaction.managedWritten and not transaction.rootWritten then
finishSuccess(request, managedPath)
if transaction.removeManagedAfterSuccess then
if noctalia.readFile(managedPath) ~= existing then
publishResult(request.request_id, false, "migration_cleanup_changed", managedPath)
busy = false
return
end
if noctalia.removeFile(managedPath) ~= true then
publishResult(request.request_id, false, "migration_remove_failed", managedPath)
busy = false
return
end
end
finishSuccess(request, request.source)
return
end
verifyAndReload(request, transaction)
end
local function processAutomaticMigration(snapshotValue)
if busy or type(snapshotValue) ~= "table" or snapshotValue.status ~= "ready" then return end
local compositor = snapshotValue.compositor
local format = FORMATS[compositor]
local source = snapshotValue.source
if format == nil or type(source) ~= "string" or source:sub(1, 1) ~= "/"
or source:match("([^/]+)$") == format.managedName then return end
local managedPath = dirname(source) .. "/" .. format.managedName
if not noctalia.fileExists(managedPath) then return end
local signature = compositor .. "\n" .. source
if automaticMigrationAttempt == signature then return end
automaticMigrationAttempt = signature
busy = true
local request = {
request_id = "automatic-migration", compositor = compositor, source = source,
format = format, silentMigration = true,
}
local function fail(errorCode)
publishOperationResult(request, false, errorCode, managedPath)
busy = false
end
local preflight = "[ ! -L " .. shellQuote(source) .. " ] && [ ! -L "
.. shellQuote(managedPath) .. " ]"
local started = noctalia.runAsync(preflight, function(result)
if result.timedOut == true or result.exitCode ~= 0 then
fail("symlink_unsupported")
return
end
local root = noctalia.readFile(source)
local existing = noctalia.readFile(managedPath)
if type(root) ~= "string" then fail("source_unreadable") return end
if type(existing) ~= "string" then fail("managed_file_unreadable") return end
if #root > MAX_ROOT_BYTES then fail("source_too_large") return end
local payload, payloadError = managedPayload(compositor, existing)
if payload == nil then fail(payloadError) return end
local newRoot, migrationError = inlineManagedContent(root, format, payload)
if newRoot == nil then fail(migrationError) return end
if #newRoot > MAX_ROOT_BYTES then fail("source_too_large") return end
if noctalia.readFile(source) ~= root or noctalia.readFile(managedPath) ~= existing then
fail("source_changed")
return
end
local transaction = {
source = source, rootOld = root, rootNew = newRoot, rootWritten = false,
managedPath = managedPath, managedOld = existing, managedNew = existing,
managedExisted = true, managedWritten = false, removeManagedAfterSuccess = true,
}
if root ~= newRoot then
local ok, writeError = atomicWrite(source, newRoot)
if not ok then fail("source_" .. writeError) return end
transaction.rootWritten = true
end
verifyAndReload(request, transaction)
end, 2000)
if not started then fail("preflight_failed") end
end
local function xorNibbleSlow(left, right)
local result, place = 0, 1
for _ = 1, 4 do
@@ -1976,3 +2150,9 @@ noctalia.state.watch(UPDATE_REQUEST_KEY, function(request)
end
processUpdateRequest(request)
end)
noctalia.state.watch(SNAPSHOT_KEY, function(snapshotValue)
processAutomaticMigration(snapshotValue)
end)
processAutomaticMigration(noctalia.state.get(SNAPSHOT_KEY))