From 0919fff83c8c4e0f62720158a081d6006c9744a2 Mon Sep 17 00:00:00 2001 From: Dave Hammer Date: Sun, 9 Aug 2026 21:08:30 -0400 Subject: [PATCH] Add davemhammer/gocryptfs (#324) Mount, unmount, init, and auto-mount gocryptfs volumes from Noctalia. Passwords use secret-tool (desktop keyring) plus keyctl session cache. --- gocryptfs/README.md | 105 +++ gocryptfs/panel.luau | 878 +++++++++++++++++ gocryptfs/plugin.toml | 106 +++ gocryptfs/service.luau | 1609 ++++++++++++++++++++++++++++++++ gocryptfs/thumbnail.webp | Bin 0 -> 50042 bytes gocryptfs/translations/en.json | 140 +++ gocryptfs/widget.luau | 93 ++ 7 files changed, 2931 insertions(+) create mode 100644 gocryptfs/README.md create mode 100644 gocryptfs/panel.luau create mode 100644 gocryptfs/plugin.toml create mode 100644 gocryptfs/service.luau create mode 100644 gocryptfs/thumbnail.webp create mode 100644 gocryptfs/translations/en.json create mode 100644 gocryptfs/widget.luau diff --git a/gocryptfs/README.md b/gocryptfs/README.md new file mode 100644 index 0000000..cc4368f --- /dev/null +++ b/gocryptfs/README.md @@ -0,0 +1,105 @@ +# Gocryptfs + +Mount, unmount, initialize, and auto-mount [gocryptfs](https://github.com/rfjakob/gocryptfs) encrypted volumes from Noctalia — bar status, manager panel, and optional auto-mount after login. + +## Plugin + +| Field | Value | +| --- | --- | +| ID | `davemhammer/gocryptfs` | +| Entries | Bar widget: `status`; panel: `manager`; service: `service` | + +## Requirements + +Install these on `PATH` (declared in `plugin.toml` `dependencies`): + +- `gocryptfs` — mount and `gocryptfs -init` +- `fusermount3` or `fusermount` — FUSE unmount (first found wins) +- `keyctl` — kernel user-keyring session cache for remembered passwords (package `keyutils`) +- `secret-tool` — Freedesktop Secret Service client for reboot-persistent passwords (package `libsecret` / `libsecret-tools`) +- `chmod` — mode bits on short-lived temp password files +- `xdg-open` — open the mount point in the file manager +- `cat` — read `/proc/mounts` for mount status + +**Desktop keyring:** persistent “Remember” needs a Secret Service backend (GNOME Keyring, KeePassXC as Secret Service, etc.) running and unlocked after login. If only `keyctl` is available, remember still works for the current login session. + +## Usage + +Add the **status** bar widget from Settings → Bar (`davemhammer/gocryptfs:status`). + +- **Left-click** — open the manager panel +- **Right-click** — refresh mount status + +In the panel you can: + +- Select a volume → **Mount** / **Unmount** / **Open** (file manager via `xdg-open`) +- **Edit → Remember / Forget** — store or clear the volume password (desktop keyring + session cache) +- **Add** an existing cipher directory, or **Init** a new one (`gocryptfs -init`) +- Mount with a remembered keyring password, an optional advanced passfile path, or a one-shot password prompt (optional “also remember”) + +```sh +noctalia msg panel-toggle davemhammer/gocryptfs:manager +``` + +### Auto-mount on login + +Requires all of: + +1. Plugin setting **Auto-mount on login** (default on) +2. Per-volume **Auto-mount** enabled +3. A remembered keyring password (**Remember**) or an advanced passfile path + +After reboot, the desktop keyring must unlock (normal login) so `secret-tool` can supply the password. The kernel session key is refilled automatically on mount. + +## Settings + +| Setting | Type | Default | Description | +| --- | --- | --- | --- | +| `refresh_interval` | `int` | `3` | Seconds between `/proc/mounts` polls. | +| `notify_on_action` | `bool` | `true` | Notify after mount, unmount, init, remember, and forget. | +| `create_mountpoint` | `bool` | `true` | Create the mount directory if missing before mount. | +| `auto_mount` | `bool` | `true` | Global switch: on service start, queue volumes that have auto-mount + keyring/passfile. | +| `show_count` | `bool` (widget) | `true` | Show `mounted/total` on the bar. | +| `glyph_color` | `select` (widget) | `on_surface` | Lock icon color when nothing is mounted. | +| `mounted_color` | `select` (widget) | `tertiary` | Icon/dot color when at least one volume is mounted. | +| `unmounted_color` | `select` (widget) | `on_surface_variant` | Status-dot color when nothing is mounted. | + +## IPC + +```sh +noctalia msg panel-toggle davemhammer/gocryptfs:manager +noctalia msg plugin davemhammer/gocryptfs:service all refresh +noctalia msg plugin davemhammer/gocryptfs:service all reload +noctalia msg plugin davemhammer/gocryptfs:service all automount +``` + +- `refresh` — re-read `/proc/mounts` and refresh the snapshot +- `reload` — reload `volumes.json` from the plugin data dir, clear the auto-mount queue, then refresh +- `automount` — reset the auto-mount schedule and refresh (eligible volumes are queued again on the next status pass) + +## Notes + +### Data and filesystem + +- Volume definitions live under the plugin data directory as `volumes.json` (not inside the cipher directory). +- With **Create mount points** on, the service may `mkdir` the configured mount path before mounting. +- Cipher, mount, and passfile paths reject empty values, NUL, and `..` segments. Symlinks on those paths are followed by design (user-chosen paths). + +### Secrets (no long-lived plaintext under plugin data) + +**Remember password** does **not** write a long-lived password file under the plugin data dir. It stores the secret in: + +1. **Desktop keyring** via `secret-tool` — attributes `service=noctalia-gocryptfs`, `volume-id=`. Survives reboot while the login keyring is unlocked. +2. **Kernel session keyring** via `keyctl` — description `noctalia-gocryptfs:`. Fast cache for this login only; cleared on reboot/logout. + +On mount / auto-mount, the service prefers the session key; if missing, it hydrates from `secret-tool` into `keyctl`, then runs `gocryptfs -extpass keyctl pipe `. Fallback: `gocryptfs -extpass secret-tool lookup …`. + +- One-shot typed passwords use a short-lived file under `/dev/shm` (tmpfs) when available, then delete it. +- Optional **advanced** passfile paths remain supported for users who manage their own files (plaintext by user choice; not recommended). +- **Forget** and volume remove clear both the desktop keyring entry and the session key. +- Passwords are not logged. + +### Processes and network + +- Spawns: `gocryptfs`, `fusermount3` or `fusermount`, `keyctl`, `secret-tool`, `chmod`, `cat` (`/proc/mounts`), `xdg-open`. +- **Network:** none. diff --git a/gocryptfs/panel.luau b/gocryptfs/panel.luau new file mode 100644 index 0000000..d75cfda --- /dev/null +++ b/gocryptfs/panel.luau @@ -0,0 +1,878 @@ +--!nonstrict +-- Gocryptfs manager panel: list, mount/unmount, add/edit, init, password prompt. + +local STATE_KEY = "gocrypt_snapshot" +local COMMAND_KEY = "gocrypt_command" +local RESULT_KEY = "gocrypt_action_result" + +local snapshot = noctalia.state.get(STATE_KEY) or { + available = false, + loading = true, + busy = false, + volumes = {}, + mountedCount = 0, + totalCount = 0, + error = "", + updatedAt = 0, + revision = 0, +} + +local selectedId = "" +local requestCounter = 0 +local feedback = "" +local feedbackError = false +local dirty = true + +-- views: "list" | "form" | "password" +local view = "list" +local formMode = "add" -- add | edit | init +local formGeneration = 0 +local formName = "" +local formCipher = "" +local formMount = "" +local formPassfile = "" +local formAllowOther = false +local formReadOnly = false +local formAutoMount = false +local formPlaintextNames = false +local formAesSiv = false +local formSavePassfile = true +local formPassword = "" +local formPasswordConfirm = "" +local formPasswordKey = 0 +local formError = "" +local formEditId = "" + +local passwordVolumeId = "" +local passwordVolumeName = "" +local passwordValue = "" +local passwordKey = 0 +local passwordError = "" +-- "mount" | "store_keyring" +local passwordMode = "mount" +local passwordRememberKeyring = true + +local render + +local function tr(key, subst) + return noctalia.tr(key, subst) +end + +local function nextRequestId() + requestCounter += 1 + return `panel-{requestCounter}` +end + +local function sendCommand(action, values) + local command = { + action = action, + requestId = nextRequestId(), + } + if type(values) == "table" then + for key, value in pairs(values) do + command[key] = value + end + end + noctalia.state.set(COMMAND_KEY, command) + return command.requestId +end + +local function selectedVolume() + if selectedId == "" then + return nil + end + for _, vol in ipairs(snapshot.volumes or {}) do + if vol.id == selectedId then + return vol + end + end + return nil +end + +local function resetForm() + formName = "" + formCipher = "" + formMount = "" + formPassfile = "" + formAllowOther = false + formReadOnly = false + formAutoMount = true + formPlaintextNames = false + formAesSiv = false + formSavePassfile = true + formPassword = "" + formPasswordConfirm = "" + formPasswordKey += 1 + formError = "" + formEditId = "" + formGeneration += 1 +end + +local function fillFormFrom(vol) + formName = tostring(vol.name or "") + formCipher = tostring(vol.cipherDir or "") + formMount = tostring(vol.mountPoint or "") + formPassfile = tostring(vol.passfile or "") + formAllowOther = vol.allowOther == true + formReadOnly = vol.readOnly == true + formAutoMount = vol.autoMount == true + formPlaintextNames = false + formAesSiv = false + formSavePassfile = false + formPassword = "" + formPasswordConfirm = "" + formPasswordKey += 1 + formError = "" + formEditId = tostring(vol.id or "") + formGeneration += 1 +end + +local function statusColor(vol) + if vol.mounted then + return "tertiary" + end + if vol.cipherExists == false or vol.initialized == false then + return "error" + end + return "on_surface_variant" +end + +local function volumeCard(vol) + local selected = vol.id == selectedId + local statusText = vol.mounted and tr("panel.status.mounted") or tr("panel.status.unmounted") + local summary = `{vol.name} · {statusText} · {vol.mountPoint}` + + return ui.button({ + key = vol.id, + text = summary, + glyph = vol.mounted and "lock-open" or "lock", + contentAlign = "start", + variant = selected and "primary" or "outline", + selected = selected, + onClick = function() + selectedId = vol.id + feedback = "" + render() + end, + }) +end + +local function selectionToolbar() + local vol = selectedVolume() + if vol == nil then + return ui.label({ text = tr("panel.select_hint"), color = "on_surface_variant" }) + end + + local mounted = vol.mounted == true + local busy = snapshot.busy == true + local hasPassfile = type(vol.passfile) == "string" and vol.passfile ~= "" + local useKeyring = vol.useKeyring == true + local hints = {} + if useKeyring then + table.insert(hints, tr("panel.keyring_hint")) + elseif hasPassfile then + table.insert(hints, tr("panel.passfile_hint")) + end + if vol.autoMount == true and (useKeyring or hasPassfile) then + table.insert(hints, tr("panel.automount_hint")) + end + + local buttons = { + ui.button({ + text = mounted and tr("actions.unmount") or tr("actions.mount"), + glyph = mounted and "lock" or "lock-open", + variant = "primary", + enabled = not busy and snapshot.available == true, + onClick = "onToggleMount", + }), + ui.button({ + text = tr("actions.open"), + glyph = "folder-open", + variant = "outline", + enabled = not busy, + onClick = "onOpenMount", + }), + ui.button({ + text = tr("actions.edit"), + glyph = "edit", + variant = "outline", + enabled = not busy and not mounted, + onClick = "onEdit", + }), + ui.button({ + text = tr("actions.remove"), + glyph = "trash", + variant = "destructive", + enabled = not busy and not mounted, + onClick = "onRemove", + }), + } + + return ui.column({ gap = 4, padding = 10, fill = "surface_variant/0.45", radius = 10 }, { + ui.row({ gap = 8, align = "center" }, { + ui.glyph({ name = mounted and "lock-open" or "lock", size = 18, color = statusColor(vol) }), + ui.label({ text = tostring(vol.name), fontWeight = "bold", flexGrow = 1, maxLines = 1 }), + ui.label({ + text = mounted and tr("panel.status.mounted") or tr("panel.status.unmounted"), + color = statusColor(vol), + fontSize = 12, + }), + }), + ui.label({ + text = tr("panel.cipher", { path = vol.cipherDir }), + color = "on_surface_variant", + fontSize = 12, + maxLines = 1, + }), + ui.label({ + text = tr("panel.mountpoint", { path = vol.mountPoint }), + color = "on_surface_variant", + fontSize = 12, + maxLines = 1, + }), + ui.label({ + text = table.concat(hints, " · "), + color = "on_surface_variant", + fontSize = 11, + visible = #hints > 0, + }), + ui.row({ gap = 6, align = "center" }, buttons), + }) +end + +local function volumeList() + local vols = snapshot.volumes or {} + if #vols == 0 then + return ui.column({ align = "center", padding = 24, gap = 8 }, { + ui.glyph({ name = "lock", size = 42, color = "on_surface_variant" }), + ui.label({ text = tr("panel.empty"), color = "on_surface_variant", textAlign = "center" }), + }) + end + local rows = {} + for _, vol in ipairs(vols) do + table.insert(rows, volumeCard(vol)) + end + return ui.column({ gap = 8 }, rows) +end + +local function formTitle() + if formMode == "edit" then + return tr("panel.edit_title") + end + if formMode == "init" then + return tr("panel.init_title") + end + return tr("panel.add_title") +end + +local function formView() + local isInit = formMode == "init" + local children = { + ui.row({ gap = 8, align = "center" }, { + ui.label({ text = formTitle(), fontSize = 16, fontWeight = "bold", flexGrow = 1 }), + ui.button({ glyph = "close", onClick = "onCancelForm" }), + }), + ui.label({ + text = isInit and tr("panel.init_help") or "", + color = "on_surface_variant", + fontSize = 12, + visible = isInit, + }), + ui.label({ text = tr("panel.field.name"), color = "on_surface_variant" }), + ui.input({ + key = `form-name-{formGeneration}`, + value = formName, + placeholder = tr("panel.field.name_placeholder"), + onChange = "onFormName", + }), + ui.label({ text = tr("panel.field.cipher"), color = "on_surface_variant" }), + ui.input({ + key = `form-cipher-{formGeneration}`, + value = formCipher, + placeholder = tr("panel.field.cipher_placeholder"), + onChange = "onFormCipher", + }), + ui.label({ text = tr("panel.field.mount"), color = "on_surface_variant" }), + ui.input({ + key = `form-mount-{formGeneration}`, + value = formMount, + placeholder = tr("panel.field.mount_placeholder"), + onChange = "onFormMount", + }), + } + + if isInit then + table.insert(children, ui.label({ text = tr("panel.field.password"), color = "on_surface_variant" })) + table.insert(children, ui.input({ + key = `form-pw-{formGeneration}-{formPasswordKey}`, + value = "", + placeholder = tr("panel.password_placeholder"), + password = true, + onChange = "onFormPassword", + })) + table.insert(children, ui.label({ text = tr("panel.field.password_confirm"), color = "on_surface_variant" })) + table.insert(children, ui.input({ + key = `form-pw2-{formGeneration}-{formPasswordKey}`, + value = "", + placeholder = tr("panel.field.password_confirm_placeholder"), + password = true, + onChange = "onFormPasswordConfirm", + })) + table.insert(children, ui.row({ gap = 10, align = "center" }, { + ui.toggle({ checked = formPlaintextNames, onChange = "onFormPlaintextNames" }), + ui.label({ text = tr("panel.field.plaintextnames"), flexGrow = 1 }), + })) + table.insert(children, ui.row({ gap = 10, align = "center" }, { + ui.toggle({ checked = formAesSiv, onChange = "onFormAesSiv" }), + ui.label({ text = tr("panel.field.aessiv"), flexGrow = 1 }), + })) + table.insert(children, ui.row({ gap = 10, align = "center" }, { + ui.toggle({ checked = formSavePassfile, onChange = "onFormSavePassfile" }), + ui.label({ text = tr("panel.field.save_passfile"), flexGrow = 1 }), + })) + table.insert(children, ui.row({ gap = 10, align = "center", visible = formSavePassfile }, { + ui.toggle({ checked = formAutoMount, onChange = "onFormAutoMount" }), + ui.label({ text = tr("panel.field.auto_mount"), flexGrow = 1 }), + })) + else + -- Edit existing volume: keyring remember/forget + advanced passfile + local editVol = selectedVolume() + local editHasKeyring = editVol and editVol.useKeyring == true + table.insert(children, ui.label({ + text = tr("panel.field.keyring_section"), + color = "on_surface_variant", + fontWeight = "bold", + })) + table.insert(children, ui.label({ + text = editHasKeyring and tr("panel.keyring_hint") or tr("panel.keyring_not_set"), + color = editHasKeyring and "tertiary" or "on_surface_variant", + fontSize = 12, + })) + table.insert(children, ui.row({ gap = 8, align = "center" }, { + ui.button({ + text = tr("actions.remember"), + glyph = "key", + variant = "outline", + enabled = snapshot.busy ~= true, + onClick = "onRememberKeyring", + }), + ui.button({ + text = tr("actions.forget"), + glyph = "key-off", + variant = "destructive", + enabled = snapshot.busy ~= true and editHasKeyring == true, + onClick = "onForgetKeyring", + }), + })) + table.insert(children, ui.label({ + text = tr("panel.keyring_help"), + color = "on_surface_variant", + fontSize = 11, + maxLines = 4, + })) + table.insert(children, ui.label({ text = tr("panel.field.passfile"), color = "on_surface_variant" })) + table.insert(children, ui.input({ + key = `form-passfile-{formGeneration}`, + value = formPassfile, + placeholder = tr("panel.field.passfile_placeholder"), + onChange = "onFormPassfile", + })) + table.insert(children, ui.row({ gap = 10, align = "center" }, { + ui.toggle({ checked = formAutoMount, onChange = "onFormAutoMount" }), + ui.label({ text = tr("panel.field.auto_mount"), flexGrow = 1 }), + })) + table.insert(children, ui.label({ + text = tr("panel.field.auto_mount_help"), + color = "on_surface_variant", + fontSize = 11, + })) + end + + table.insert(children, ui.row({ gap = 10, align = "center" }, { + ui.toggle({ checked = formAllowOther, onChange = "onFormAllowOther" }), + ui.label({ text = tr("panel.field.allow_other"), flexGrow = 1 }), + })) + table.insert(children, ui.row({ gap = 10, align = "center" }, { + ui.toggle({ checked = formReadOnly, onChange = "onFormReadOnly" }), + ui.label({ text = tr("panel.field.read_only"), flexGrow = 1 }), + })) + table.insert(children, ui.label({ text = formError, color = "error", visible = formError ~= "" })) + table.insert(children, ui.row({ justify = "end", gap = 8 }, { + ui.button({ text = tr("actions.cancel"), variant = "outline", onClick = "onCancelForm" }), + ui.button({ + text = isInit and tr("actions.init") or tr("actions.save"), + glyph = isInit and "shield-lock" or "check", + variant = "primary", + enabled = snapshot.busy ~= true, + onClick = "onSaveForm", + }), + })) + + return ui.scroll({ flexGrow = 1, gap = 10 }, children) +end + +local function passwordView() + local isStore = passwordMode == "store_keyring" + local title = isStore + and tr("panel.keyring_title", { name = passwordVolumeName }) + or tr("panel.password_title", { name = passwordVolumeName }) + local confirmText = isStore and tr("actions.remember") or tr("actions.mount") + local confirmGlyph = isStore and "key" or "lock-open" + local children = { + ui.row({ gap = 8, align = "center" }, { + ui.label({ + text = title, + fontSize = 16, + fontWeight = "bold", + flexGrow = 1, + }), + ui.button({ glyph = "close", onClick = "onCancelPassword" }), + }), + ui.label({ + text = isStore and tr("panel.keyring_label") or tr("panel.password_label"), + color = "on_surface_variant", + }), + ui.input({ + key = "pw_" .. tostring(passwordKey), + value = "", + placeholder = tr("panel.password_placeholder"), + password = true, + focus = true, + onChange = "onPasswordChange", + onSubmit = "onConfirmPassword", + }), + } + if not isStore then + table.insert(children, ui.row({ gap = 10, align = "center" }, { + ui.toggle({ checked = passwordRememberKeyring, onChange = "onPasswordRemember" }), + ui.label({ text = tr("panel.remember_keyring"), flexGrow = 1 }), + })) + else + table.insert(children, ui.label({ + text = tr("panel.keyring_help"), + color = "on_surface_variant", + fontSize = 11, + maxLines = 4, + })) + end + table.insert(children, ui.label({ text = passwordError, color = "error", visible = passwordError ~= "" })) + table.insert(children, ui.row({ justify = "end", gap = 8 }, { + ui.button({ text = tr("actions.cancel"), variant = "outline", onClick = "onCancelPassword" }), + ui.button({ + text = confirmText, + glyph = confirmGlyph, + variant = "primary", + enabled = snapshot.busy ~= true, + onClick = "onConfirmPassword", + }), + })) + return ui.column({ flexGrow = 1, gap = 12 }, children) +end + +local function listView() + local statusRows = {} + if snapshot.loading == true then + table.insert(statusRows, ui.label({ text = tr("panel.loading"), color = "on_surface_variant" })) + end + if snapshot.busy == true then + table.insert(statusRows, ui.label({ text = tr("panel.busy"), color = "primary" })) + end + if type(snapshot.error) == "string" and snapshot.error ~= "" then + table.insert(statusRows, ui.label({ text = snapshot.error, color = "error", maxLines = 2 })) + end + if feedback ~= "" then + table.insert(statusRows, ui.label({ + text = feedback, + color = feedbackError and "error" or "tertiary", + maxLines = 2, + })) + end + + return ui.column({ flexGrow = 1, gap = 10 }, { + selectionToolbar(), + ui.column({ gap = 3 }, statusRows), + ui.scroll({ flexGrow = 1, gap = 8 }, { volumeList() }), + }) +end + +render = function() + dirty = false + + local content + if view == "form" then + content = formView() + elseif view == "password" then + content = passwordView() + else + content = listView() + end + + panel.render(ui.column({ flexGrow = 1, gap = 10 }, { + ui.row({ align = "center", gap = 8 }, { + ui.glyph({ + name = "lock", + size = 24, + color = snapshot.available and "primary" or "on_surface_variant", + }), + ui.column({ flexGrow = 1, gap = 0 }, { + ui.label({ text = tr("title"), fontSize = 18, fontWeight = "bold" }), + ui.label({ + text = tr("panel.subtitle"), + fontSize = 11, + color = "on_surface_variant", + }), + }), + ui.button({ + text = tr("actions.init"), + glyph = "shield-lock", + variant = "outline", + visible = view == "list", + onClick = "onInit", + }), + ui.button({ + text = tr("actions.add"), + glyph = "plus", + variant = "outline", + visible = view == "list", + onClick = "onAdd", + }), + ui.button({ + text = tr("actions.refresh"), + glyph = "refresh", + variant = "outline", + visible = view == "list", + onClick = "onRefresh", + }), + ui.button({ glyph = "close", onClick = "onCloseClicked" }), + }), + content, + ui.label({ + text = (snapshot.updatedAt or 0) > 0 + and tr("panel.updated", { time = noctalia.formatTime("%H:%M:%S", snapshot.updatedAt) }) + or "", + color = "on_surface_variant", + fontSize = 11, + visible = view == "list", + }), + })) +end + +noctalia.state.watch(STATE_KEY, function(value) + if type(value) ~= "table" then + return + end + -- Always re-render on snapshot publish. Mounted flags live inside + -- volumes[] and must update the list immediately after mount/unmount. + snapshot = value + if selectedId ~= "" and selectedVolume() == nil then + selectedId = "" + end + dirty = true +end) + +noctalia.state.watch(RESULT_KEY, function(result) + if type(result) ~= "table" then + return + end + if type(result.requestId) ~= "string" or not result.requestId:match("^panel%-") then + return + end + feedback = tostring(result.message or "") + feedbackError = result.ok ~= true + if result.ok == true and ( + result.action == "add_volume" + or result.action == "update_volume" + or result.action == "init_volume" + ) then + view = "list" + resetForm() + end + if result.ok == true and result.action == "mount" then + view = "list" + passwordValue = "" + passwordError = "" + end + if result.ok ~= true and result.action == "mount" and view == "password" then + passwordError = feedback + passwordKey += 1 + passwordValue = "" + end + if result.ok ~= true and result.action == "init_volume" and view == "form" then + formError = feedback + formPassword = "" + formPasswordConfirm = "" + formPasswordKey += 1 + end + dirty = true +end) + +panel.setWantsSecondTicks(true) + +function onOpen(_context) + view = "list" + feedback = "" + sendCommand("refresh") + render() +end + +function update() + if dirty then + render() + end +end + +function onCloseClicked() + panel.close() +end + +function onRefresh() + sendCommand("refresh") +end + +function onAdd() + formMode = "add" + resetForm() + formAutoMount = false + view = "form" + render() +end + +function onInit() + formMode = "init" + resetForm() + formAutoMount = true + formSavePassfile = true + view = "form" + render() +end + +function onEdit() + local vol = selectedVolume() + if not vol then + return + end + formMode = "edit" + fillFormFrom(vol) + view = "form" + render() +end + +function onCancelForm() + view = "list" + formError = "" + formPassword = "" + formPasswordConfirm = "" + render() +end + +function onFormName(value) formName = value end +function onFormCipher(value) formCipher = value end +function onFormMount(value) formMount = value end +function onFormPassfile(value) formPassfile = value end +function onFormPassword(value) formPassword = if type(value) == "string" then value else "" end +function onFormPasswordConfirm(value) formPasswordConfirm = if type(value) == "string" then value else "" end + +function onFormAllowOther(value) + formAllowOther = value == "true" + render() +end +function onFormReadOnly(value) + formReadOnly = value == "true" + render() +end +function onFormAutoMount(value) + formAutoMount = value == "true" + render() +end +function onFormPlaintextNames(value) + formPlaintextNames = value == "true" + render() +end +function onFormAesSiv(value) + formAesSiv = value == "true" + render() +end +function onFormSavePassfile(value) + formSavePassfile = value == "true" + if not formSavePassfile then + formAutoMount = false + end + render() +end + +function onSaveForm() + local name = noctalia.string.trim(formName) + local cipher = noctalia.string.trim(formCipher) + local mount = noctalia.string.trim(formMount) + if name == "" or cipher == "" or mount == "" then + formError = tr("panel.field.required") + render() + return + end + + if formMode == "init" then + if formPassword == "" then + formError = tr("panel.password_required") + render() + return + end + if formPassword ~= formPasswordConfirm then + formError = tr("panel.field.password_mismatch") + formPassword = "" + formPasswordConfirm = "" + formPasswordKey += 1 + render() + return + end + formError = "" + sendCommand("init_volume", { + name = name, + cipherDir = cipher, + mountPoint = mount, + password = formPassword, + plaintextNames = formPlaintextNames, + aesSiv = formAesSiv, + savePassfile = formSavePassfile, + autoMount = formAutoMount and formSavePassfile, + allowOther = formAllowOther, + readOnly = formReadOnly, + }) + formPassword = "" + formPasswordConfirm = "" + formPasswordKey += 1 + render() + return + end + + formError = "" + local payload = { + name = name, + cipherDir = cipher, + mountPoint = mount, + passfile = noctalia.string.trim(formPassfile), + allowOther = formAllowOther, + readOnly = formReadOnly, + autoMount = formAutoMount, + } + if formMode == "edit" then + payload.id = formEditId + sendCommand("update_volume", payload) + else + sendCommand("add_volume", payload) + end + render() +end + +function onRemove() + local vol = selectedVolume() + if not vol then + return + end + sendCommand("remove_volume", { id = vol.id }) +end + +-- Named onOpenMount so it does not override the panel lifecycle onOpen(). +function onOpenMount() + local vol = selectedVolume() + if not vol then + return + end + sendCommand("open", { id = vol.id }) +end + +local function openPasswordPrompt(vol, mode) + passwordVolumeId = vol.id + passwordVolumeName = vol.name + passwordValue = "" + passwordError = "" + passwordMode = mode or "mount" + passwordRememberKeyring = true + passwordKey += 1 + view = "password" + render() +end + +function onToggleMount() + local vol = selectedVolume() + if not vol then + return + end + if vol.mounted then + sendCommand("unmount", { id = vol.id }) + return + end + -- Prefer keyring or existing passfile without prompting. + if vol.useKeyring == true or (type(vol.passfile) == "string" and vol.passfile ~= "") then + sendCommand("mount", { id = vol.id }) + return + end + openPasswordPrompt(vol, "mount") +end + +function onRememberKeyring() + local vol = selectedVolume() + if not vol then + return + end + -- Keep edit context: after save, return to list via cancel/result + openPasswordPrompt(vol, "store_keyring") +end + +function onForgetKeyring() + local vol = selectedVolume() + if not vol then + return + end + sendCommand("forget_keyring", { id = vol.id }) + -- Stay on edit form; snapshot watch will refresh useKeyring flag + render() +end + +function onPasswordChange(value) + passwordValue = if type(value) == "string" then value else "" +end + +function onPasswordRemember(value) + passwordRememberKeyring = value == true or value == "true" +end + +function onCancelPassword() + passwordValue = "" + passwordError = "" + passwordMode = "mount" + view = "list" + render() +end + +function onConfirmPassword() + if snapshot.busy then + return + end + if passwordValue == "" then + passwordError = tr("panel.password_required") + render() + return + end + local pw = passwordValue + local mode = passwordMode + local id = passwordVolumeId + passwordValue = "" + passwordError = "" + passwordMode = "mount" + if mode == "store_keyring" then + sendCommand("store_keyring", { + id = id, + password = pw, + enableAutoMount = true, + }) + else + sendCommand("mount", { + id = id, + password = pw, + storeKeyring = passwordRememberKeyring == true, + }) + end + passwordKey += 1 + view = "list" + render() +end diff --git a/gocryptfs/plugin.toml b/gocryptfs/plugin.toml new file mode 100644 index 0000000..02e2fcf --- /dev/null +++ b/gocryptfs/plugin.toml @@ -0,0 +1,106 @@ +# Manage gocryptfs encrypted volumes: mount, unmount, and status. + +id = "davemhammer/gocryptfs" +name = "Gocryptfs" +version = "1.3.0" +plugin_api = 10 +author = "davemhammer" +license = "MIT" +dependencies = ["gocryptfs", "fusermount3", "fusermount", "chmod", "xdg-open", "cat", "keyctl", "secret-tool"] +tags = ["privacy", "utility", "bar", "panel", "service"] +icon = "lock" +description = "Mount, unmount, and auto-mount gocryptfs encrypted volumes from Noctalia." + +[[setting]] +key = "refresh_interval" +type = "int" +label_key = "settings.refresh_interval.label" +description_key = "settings.refresh_interval.description" +default = 3 +min = 1 +max = 60 + +[[setting]] +key = "notify_on_action" +type = "bool" +label_key = "settings.notify_on_action.label" +description_key = "settings.notify_on_action.description" +default = true + +[[setting]] +key = "create_mountpoint" +type = "bool" +label_key = "settings.create_mountpoint.label" +description_key = "settings.create_mountpoint.description" +default = true + +[[setting]] +key = "auto_mount" +type = "bool" +label_key = "settings.auto_mount.label" +description_key = "settings.auto_mount.description" +default = true + +[[widget]] +id = "status" +entry = "widget.luau" + + [[widget.setting]] + key = "show_count" + type = "bool" + label_key = "settings.show_count.label" + description_key = "settings.show_count.description" + default = true + + [[widget.setting]] + key = "glyph_color" + type = "select" + label_key = "settings.glyph_color.label" + description_key = "settings.glyph_color.description" + default = "on_surface" + options = [ + { value = "on_surface", label_key = "colors.default" }, + { value = "primary", label_key = "colors.primary" }, + { value = "secondary", label_key = "colors.secondary" }, + { value = "tertiary", label_key = "colors.tertiary" } + ] + + [[widget.setting]] + key = "mounted_color" + type = "select" + label_key = "settings.mounted_color.label" + description_key = "settings.mounted_color.description" + default = "tertiary" + options = [ + { value = "primary", label_key = "colors.primary" }, + { value = "secondary", label_key = "colors.secondary" }, + { value = "tertiary", label_key = "colors.tertiary" } + ] + + [[widget.setting]] + key = "unmounted_color" + type = "select" + label_key = "settings.unmounted_color.label" + description_key = "settings.unmounted_color.description" + default = "on_surface_variant" + options = [ + { value = "error", label_key = "colors.error" }, + { value = "on_surface_variant", label_key = "colors.muted" }, + { value = "primary", label_key = "colors.primary" }, + { value = "tertiary", label_key = "colors.tertiary" } + ] + +[[panel]] +id = "manager" +entry = "panel.luau" +width = 540 +height = 620 +placement = "floating" +position = "center" +open_near_click = true +keyboard_focus = "exclusive" +dismiss_on_outside_click = true + +[[service]] +id = "service" +entry = "service.luau" diff --git a/gocryptfs/service.luau b/gocryptfs/service.luau new file mode 100644 index 0000000..eab4d7d --- /dev/null +++ b/gocryptfs/service.luau @@ -0,0 +1,1609 @@ +--!nonstrict +-- Gocryptfs backend. Owns volume config, mount status, init, and mount/unmount. +-- Other entries talk to this service through noctalia.state only. + +local VOLUMES_FILE = "volumes.json" +local STATE_KEY = "gocrypt_snapshot" +local COMMAND_KEY = "gocrypt_command" +local RESULT_KEY = "gocrypt_action_result" + +local snapshot = { + available = false, + loading = true, + busy = false, + volumes = {}, + mountedCount = 0, + totalCount = 0, + error = "", + updatedAt = 0, + revision = 0, +} + +local refreshGeneration = 0 +local refreshPending = false +local refreshAgain = false +local actionBusy = false +local dataSignature = "" +local volumes = {} -- array of volume tables +local nextId = 1 +local autoMountScheduled = false +local autoMountQueue = {} + +local function trim(value) + return noctalia.string.trim(tostring(value or "")) +end + +local function shellQuote(value) + return "'" .. tostring(value):gsub("'", "'\\''") .. "'" +end + +local function shellCommand(args) + local quoted = {} + for _, value in ipairs(args) do + table.insert(quoted, shellQuote(value)) + end + return table.concat(quoted, " ") +end + +local function expand(path) + return noctalia.expandPath(trim(path)) +end + +-- Reject path traversal after expand. Volume paths are user-chosen (often absolute); +-- symlinks are followed by design. Empty paths, NUL, and ".." segments are refused. +local function isSafeFsPath(path) + path = expand(path) + if path == "" then + return false, "empty path" + end + if path:find("\0", 1, true) then + return false, "invalid path" + end + -- Reject .. as a path segment (//foo/../bar, /tmp/../etc, relative ../x, etc.) + for seg in (path:gsub("\\", "/") .. "/"):gmatch("([^/]*)/") do + if seg == ".." then + return false, "path must not contain .." + end + end + return true, path +end + +local function volumesPath() + local dir = noctalia.pluginDataDir() + if not dir then + return nil + end + return dir .. "/" .. VOLUMES_FILE +end + +local function passfilesDir() + local dir = noctalia.pluginDataDir() + if not dir then + return nil + end + local path = dir .. "/passfiles" + noctalia.mkdirAll(path) + return path +end + +local function newVolumeId() + local id = "vol-" .. tostring(os.time()) .. "-" .. tostring(nextId) + nextId += 1 + return id +end + +local function normalizeVolume(raw) + if type(raw) ~= "table" then + return nil + end + local name = trim(raw.name) + local cipherDir = trim(raw.cipherDir or raw.cipher_dir) + local mountPoint = trim(raw.mountPoint or raw.mount_point) + if name == "" or cipherDir == "" or mountPoint == "" then + return nil + end + local passfile = trim(raw.passfile) + local useKeyring = raw.useKeyring == true or raw.use_keyring == true + local autoMount = raw.autoMount + if autoMount == nil then + autoMount = raw.auto_mount + end + if autoMount == nil then + -- default: auto-mount when keyring or passfile is configured + autoMount = useKeyring or passfile ~= "" + else + autoMount = autoMount == true + end + return { + id = trim(raw.id) ~= "" and trim(raw.id) or newVolumeId(), + name = name, + cipherDir = cipherDir, + mountPoint = mountPoint, + passfile = passfile, + useKeyring = useKeyring, + allowOther = raw.allowOther == true or raw.allow_other == true, + readOnly = raw.readOnly == true or raw.read_only == true, + autoMount = autoMount, + } +end + +local function loadVolumes() + local path = volumesPath() + volumes = {} + if not path then + return + end + local raw = noctalia.readFile(path) + if not raw or raw == "" then + return + end + local decoded, err = noctalia.json.decode(raw) + if type(decoded) ~= "table" then + noctalia.log(`gocryptfs: could not parse volumes.json: {err or "unknown"}`) + return + end + local list = decoded.volumes + if type(list) ~= "table" then + return + end + for _, item in ipairs(list) do + local vol = normalizeVolume(item) + if vol then + table.insert(volumes, vol) + end + end +end + +local function saveVolumes() + local path = volumesPath() + if not path then + return false, "no plugin data dir" + end + local payload = { volumes = volumes } + local encoded, err = noctalia.json.encode(payload, true) + if not encoded then + return false, err or "encode failed" + end + local ok, writeErr = noctalia.writeFile(path, encoded) + if not ok then + return false, writeErr or "write failed" + end + return true +end + +local function findVolume(id) + id = trim(id) + for _, vol in ipairs(volumes) do + if vol.id == id then + return vol + end + end + return nil +end + +local function pathEqual(a, b) + a = expand(a) + b = expand(b) + a = a:gsub("/+$", "") + b = b:gsub("/+$", "") + if a == "" then a = "/" end + if b == "" then b = "/" end + return a == b +end + +local function parseMounts(stdout) + local mounts = {} + for line in (tostring(stdout or "") .. "\n"):gmatch("(.-)\n") do + line = trim(line) + if line ~= "" then + local source, target, fstype = line:match("^(%S+)%s+(%S+)%s+(%S+)") + if fstype and (fstype == "fuse.gocryptfs" or fstype:find("gocryptfs", 1, true)) then + local function unescape(s) + return (s:gsub("\\(%d%d%d)", function(oct) + return string.char(tonumber(oct, 8)) + end)) + end + mounts[unescape(target)] = unescape(source) + end + end + end + return mounts +end + +local function isMounted(vol, mounts) + local mp = expand(vol.mountPoint) + mp = mp:gsub("/+$", "") + if mp == "" then mp = "/" end + if mounts[mp] then + return true + end + for target, _ in pairs(mounts) do + if pathEqual(target, vol.mountPoint) then + return true + end + end + return false +end + +local function publishSnapshot() + snapshot.busy = actionBusy + snapshot.totalCount = #volumes + snapshot.volumes = {} + local mounted = 0 + for _, vol in ipairs(volumes) do + local entry = { + id = vol.id, + name = vol.name, + cipherDir = vol.cipherDir, + mountPoint = vol.mountPoint, + passfile = vol.passfile, + useKeyring = vol.useKeyring == true, + allowOther = vol.allowOther, + readOnly = vol.readOnly, + autoMount = vol.autoMount == true, + mounted = vol.mounted == true, + cipherExists = vol.cipherExists == true, + initialized = vol.initialized == true, + } + if entry.mounted then + mounted += 1 + end + table.insert(snapshot.volumes, entry) + end + snapshot.mountedCount = mounted + noctalia.state.set(STATE_KEY, snapshot) +end + +local function updateRevision(signature) + if signature ~= dataSignature then + dataSignature = signature + snapshot.revision += 1 + end +end + +local function refreshIntervalMs() + local seconds = tonumber(noctalia.getConfig("refresh_interval")) or 3 + seconds = math.max(1, math.min(60, math.floor(seconds))) + return seconds * 1000 +end + +local function shouldNotify() + return noctalia.getConfig("notify_on_action") ~= false +end + +local function autoMountEnabled() + return noctalia.getConfig("auto_mount") ~= false +end + +local refreshAll +local mountVolume +local kickAutoMount + +local refreshStartedAt = 0 + +refreshAll = function() + -- Recover if an in-flight /proc/mounts callback never completed. + if refreshPending and refreshStartedAt > 0 and (os.time() - refreshStartedAt) >= 15 then + noctalia.log("gocryptfs: forcing stuck refresh reset") + refreshPending = false + refreshStartedAt = 0 + snapshot.loading = false + end + + if refreshPending then + refreshAgain = true + return + end + refreshPending = true + refreshAgain = false + refreshStartedAt = os.time() + refreshGeneration += 1 + local generation = refreshGeneration + + if not noctalia.commandExists("gocryptfs") then + snapshot.available = false + snapshot.loading = false + snapshot.error = noctalia.tr("result.gocryptfs_missing") + for _, vol in ipairs(volumes) do + vol.mounted = false + vol.cipherExists = noctalia.fileExists(expand(vol.cipherDir)) + vol.initialized = noctalia.fileExists(expand(vol.cipherDir) .. "/gocryptfs.conf") + end + refreshPending = false + refreshStartedAt = 0 + updateRevision("gocryptfs-missing") + publishSnapshot() + return + end + + snapshot.available = true + -- Avoid flashing "Checking mounts…" on every poll when we already have data. + if (snapshot.updatedAt or 0) == 0 then + snapshot.loading = true + publishSnapshot() + end + + local started = noctalia.runAsync("cat /proc/mounts", function(result) + if generation ~= refreshGeneration then + return + end + local mounts = {} + if result and result.exitCode == 0 then + mounts = parseMounts(result.stdout) + snapshot.error = "" + else + snapshot.error = trim(result and result.stderr) + if snapshot.error == "" then + snapshot.error = "could not read /proc/mounts" + end + end + + local sigParts = {} + for _, vol in ipairs(volumes) do + vol.mounted = isMounted(vol, mounts) + local cipher = expand(vol.cipherDir) + vol.cipherExists = noctalia.fileExists(cipher) + vol.initialized = noctalia.fileExists(cipher .. "/gocryptfs.conf") + table.insert(sigParts, vol.id .. ":" .. tostring(vol.mounted) .. ":" .. vol.mountPoint) + end + + snapshot.loading = false + snapshot.updatedAt = os.time() + refreshPending = false + refreshStartedAt = 0 + updateRevision(table.concat(sigParts, "|") .. "|" .. tostring(#volumes)) + publishSnapshot() + + if not autoMountScheduled then + autoMountScheduled = true + if autoMountEnabled() then + for _, vol in ipairs(volumes) do + if vol.autoMount and not vol.mounted + and (vol.useKeyring == true or trim(vol.passfile) ~= "") + then + table.insert(autoMountQueue, vol.id) + end + end + if #autoMountQueue > 0 then + noctalia.log(`gocryptfs: auto-mount queue has {#autoMountQueue} volume(s)`) + kickAutoMount() + end + end + end + + if refreshAgain then + refreshAgain = false + refreshAll() + end + end) + + if not started then + snapshot.loading = false + snapshot.error = "could not start status check" + refreshPending = false + refreshStartedAt = 0 + publishSnapshot() + end +end + +local function actionResult(command, ok, message, extra) + local result = { + requestId = command and command.requestId or "", + action = command and command.action or "", + ok = ok, + message = message or "", + } + if type(extra) == "table" then + for key, value in pairs(extra) do + result[key] = value + end + end + noctalia.state.set(RESULT_KEY, result) +end + +local function notifyOk(message) + if shouldNotify() then + noctalia.notify(noctalia.tr("title"), message) + end +end + +local function notifyErr(message) + noctalia.notifyError(noctalia.tr("title"), message) +end + +local function unmountBinary() + if noctalia.commandExists("fusermount3") then + return "fusermount3" + end + if noctalia.commandExists("fusermount") then + return "fusermount" + end + return nil +end + +local function finishAction(command, ok, message) + actionBusy = false + local silent = command and command.silent == true + actionResult(command, ok, message) + if not silent then + if ok then + notifyOk(message) + else + notifyErr(message) + end + elseif not ok then + -- still surface auto-mount failures + notifyErr(message) + end + -- Always bump revision so panel/widget re-render even if mount flags + -- were updated optimistically to the same shape as a prior sample. + updateRevision( + "action:" + .. tostring(command and command.action or "") + .. ":" + .. tostring(command and command.id or "") + .. ":" + .. tostring(ok) + .. ":" + .. tostring(os.time()) + .. ":" + .. tostring(snapshot.mountedCount) + ) + publishSnapshot() + -- Confirm against /proc/mounts (may be briefly stale; optimistic flags already set). + refreshPending = false + refreshAgain = false + refreshAll() + if #autoMountQueue > 0 then + kickAutoMount() + end +end + +local function ensureDir(path) + local okPath, resolved = isSafeFsPath(path) + if not okPath then + return false, resolved or "unsafe path" + end + path = resolved + if noctalia.fileExists(path) then + local info = noctalia.fileInfo(path) + if info and info.isDir then + return true + end + return false, "not a directory" + end + local ok, err = noctalia.mkdirAll(path) + if not ok then + return false, err or "mkdir failed" + end + return true +end + +local function ensureMountPoint(path) + local okPath, resolved = isSafeFsPath(path) + if not okPath then + return false, resolved or "unsafe path" + end + path = resolved + if noctalia.fileExists(path) then + local info = noctalia.fileInfo(path) + if info and info.isDir then + return true + end + return false, "not a directory" + end + if noctalia.getConfig("create_mountpoint") == false then + return false, "does not exist" + end + return ensureDir(path) +end + +local function removePassfile(path) + if path and path ~= "" then + noctalia.removeFile(path) + end +end + +-- Short-lived password material on tmpfs when possible (never long-term secret storage). +local function tempPassPath(suffix) + local base = "/dev/shm" + if not noctalia.fileExists(base) then + base = noctalia.pluginDataDir() or "/tmp" + end + return base .. "/noctalia-gocryptfs-" .. tostring(suffix) +end + +local function writeSecurePassfile(path, password) + local written, werr = noctalia.writeFile(path, password .. "\n") + if not written then + return false, werr or "write failed" + end + noctalia.runAsync(shellCommand({ "chmod", "600", path })) + return true +end + +-- Kernel user-keyring description (session cache; payload does not survive reboot). +local function keyringDesc(volumeId) + return "noctalia-gocryptfs:" .. tostring(volumeId or "") +end + +-- Freedesktop Secret Service attributes (GNOME Keyring / KeePassXC / etc.). +local SECRET_SERVICE = "noctalia-gocryptfs" + +local function keyctlAvailable() + return noctalia.commandExists("keyctl") +end + +local function secretToolAvailable() + return noctalia.commandExists("secret-tool") +end + +-- Store password in @u keyring (replaces any prior key with same description). +-- Password is written only to a tmpfs temp file, then loaded via keyctl padd stdin. +local function storeSessionKeyring(volumeId, password, callback) + if type(callback) ~= "function" then + callback = function() end + end + if not keyctlAvailable() then + callback(false, "keyctl not found") + return + end + local desc = keyringDesc(volumeId) + if desc == "noctalia-gocryptfs:" or password == nil or password == "" then + callback(false, "invalid keyring store") + return + end + local tmp = tempPassPath("kr-" .. tostring(volumeId) .. "-" .. tostring(os.time())) + local written, werr = noctalia.writeFile(tmp, password) + if not written then + callback(false, werr or "temp write failed") + return + end + noctalia.runAsync(shellCommand({ "chmod", "600", tmp }), function() + local cmd = "OLD=$(keyctl search @u user " + .. shellQuote(desc) + .. " 2>/dev/null); " + .. "[ -n \"$OLD\" ] && keyctl unlink \"$OLD\" @u 2>/dev/null; " + .. "keyctl padd user " + .. shellQuote(desc) + .. " @u < " + .. shellQuote(tmp) + .. "; EC=$?; rm -f " + .. shellQuote(tmp) + .. "; exit $EC" + noctalia.runAsync(cmd, function(result) + removePassfile(tmp) + local ok = result ~= nil and result.exitCode == 0 and not result.timedOut + if ok then + callback(true, nil) + else + local err = trim(result and (result.stderr ~= "" and result.stderr or result.stdout) or "keyctl padd failed") + callback(false, err) + end + end, 10000) + end) +end + +-- Persist password via libsecret (secret-tool → desktop keyring). Survives reboot +-- once the login keyring is unlocked. +local function storePersistentPassword(volumeId, password, callback) + if type(callback) ~= "function" then + callback = function() end + end + if not secretToolAvailable() then + callback(false, "secret-tool not found") + return + end + local vid = tostring(volumeId or "") + if vid == "" or password == nil or password == "" then + callback(false, "invalid persistent store") + return + end + local tmp = tempPassPath("sec-" .. vid .. "-" .. tostring(os.time())) + local written, werr = noctalia.writeFile(tmp, password) + if not written then + callback(false, werr or "temp write failed") + return + end + local label = "noctalia-gocryptfs:" .. vid + noctalia.runAsync(shellCommand({ "chmod", "600", tmp }), function() + -- Clear any previous entry first so store does not leave duplicates. + local cmd = "secret-tool clear service " + .. shellQuote(SECRET_SERVICE) + .. " volume-id " + .. shellQuote(vid) + .. " 2>/dev/null; " + .. "secret-tool store --label=" + .. shellQuote(label) + .. " service " + .. shellQuote(SECRET_SERVICE) + .. " volume-id " + .. shellQuote(vid) + .. " < " + .. shellQuote(tmp) + .. "; EC=$?; rm -f " + .. shellQuote(tmp) + .. "; exit $EC" + noctalia.runAsync(cmd, function(result) + removePassfile(tmp) + local ok = result ~= nil and result.exitCode == 0 and not result.timedOut + if ok then + callback(true, nil) + else + local err = trim(result and (result.stderr ~= "" and result.stderr or result.stdout) or "secret-tool store failed") + callback(false, err) + end + end, 30000) + end) +end + +-- Store in session keyctl and, when available, desktop Secret Service. +-- Succeeds if at least one backend works (prefer both). +local function storeKeyringPassword(volumeId, password, callback) + if type(callback) ~= "function" then + callback = function() end + end + local desc = keyringDesc(volumeId) + if desc == "noctalia-gocryptfs:" or password == nil or password == "" then + callback(false, "invalid keyring store") + return + end + if not keyctlAvailable() and not secretToolAvailable() then + callback(false, "neither keyctl nor secret-tool found") + return + end + + local sessionDone = false + local persistDone = false + local sessionOk, sessionErr = false, nil + local persistOk, persistErr = false, nil + + local function maybeFinish() + if not sessionDone or not persistDone then + return + end + if sessionOk or persistOk then + if not persistOk and secretToolAvailable() and persistErr then + noctalia.log(`gocryptfs: secret-tool store failed (session key still set): {persistErr}`) + end + if not sessionOk and keyctlAvailable() and sessionErr then + noctalia.log(`gocryptfs: keyctl store failed (persistent secret still set): {sessionErr}`) + end + callback(true, nil) + else + callback(false, sessionErr or persistErr or "keyring store failed") + end + end + + if keyctlAvailable() then + storeSessionKeyring(volumeId, password, function(ok, err) + sessionOk = ok + sessionErr = err + sessionDone = true + maybeFinish() + end) + else + sessionDone = true + maybeFinish() + end + + if secretToolAvailable() then + storePersistentPassword(volumeId, password, function(ok, err) + persistOk = ok + persistErr = err + persistDone = true + maybeFinish() + end) + else + persistDone = true + maybeFinish() + end +end + +local function unlinkSessionKeyring(volumeId, callback) + if type(callback) ~= "function" then + callback = function() end + end + if not keyctlAvailable() then + callback(true) + return + end + local desc = keyringDesc(volumeId) + local cmd = "OLD=$(keyctl search @u user " + .. shellQuote(desc) + .. " 2>/dev/null); " + .. "[ -n \"$OLD\" ] && keyctl unlink \"$OLD\" @u 2>/dev/null; exit 0" + noctalia.runAsync(cmd, function() + callback(true) + end, 5000) +end + +local function clearPersistentPassword(volumeId, callback) + if type(callback) ~= "function" then + callback = function() end + end + if not secretToolAvailable() then + callback(true) + return + end + local vid = tostring(volumeId or "") + if vid == "" then + callback(true) + return + end + local cmd = "secret-tool clear service " + .. shellQuote(SECRET_SERVICE) + .. " volume-id " + .. shellQuote(vid) + .. " 2>/dev/null; exit 0" + noctalia.runAsync(cmd, function() + callback(true) + end, 15000) +end + +-- Clear session key + desktop keyring entry. +local function unlinkKeyringPassword(volumeId, callback) + if type(callback) ~= "function" then + callback = function() end + end + local left = 2 + local function done() + left -= 1 + if left <= 0 then + callback(true) + end + end + unlinkSessionKeyring(volumeId, done) + clearPersistentPassword(volumeId, done) +end + +-- Shell prelude: ensure session key exists, hydrating from secret-tool when needed. +-- Sets shell var K to key id, or exits 2 if no password material is available. +local function ensureSessionKeyShell(volumeId) + local desc = keyringDesc(volumeId) + local vid = tostring(volumeId or "") + return "K=$(keyctl search @u user " + .. shellQuote(desc) + .. " 2>/dev/null) || true; " + .. "if [ -z \"$K\" ] && command -v secret-tool >/dev/null 2>&1 && command -v keyctl >/dev/null 2>&1; then " + .. "secret-tool lookup service " + .. shellQuote(SECRET_SERVICE) + .. " volume-id " + .. shellQuote(vid) + .. " 2>/dev/null | keyctl padd user " + .. shellQuote(desc) + .. " @u >/dev/null 2>&1 || true; " + .. "K=$(keyctl search @u user " + .. shellQuote(desc) + .. " 2>/dev/null) || true; " + .. "fi; " + .. "if [ -z \"$K\" ]; then exit 2; fi; " +end + +-- Legacy managed plaintext path (no longer created; still cleaned on remove). +local function defaultPassfilePath(volumeId) + local dir = passfilesDir() + if not dir then + return nil + end + return dir .. "/" .. volumeId .. ".pass" +end + +local function canAutoMount(vol) + if not vol or vol.mounted then + return false + end + if not vol.autoMount then + return false + end + return vol.useKeyring == true or trim(vol.passfile) ~= "" +end + +mountVolume = function(command) + local vol = findVolume(command.id) + if not vol then + actionResult(command, false, noctalia.tr("result.not_found")) + kickAutoMount() + return + end + if vol.mounted then + actionResult(command, false, noctalia.tr("result.already_mounted", { name = vol.name })) + kickAutoMount() + return + end + + local okCipher, cipherOrErr = isSafeFsPath(vol.cipherDir) + if not okCipher then + local msg = noctalia.tr("result.failed", { error = cipherOrErr or "unsafe cipher path" }) + actionResult(command, false, msg) + notifyErr(msg) + kickAutoMount() + return + end + local cipher = cipherOrErr + local okMountPath, mountOrErr = isSafeFsPath(vol.mountPoint) + if not okMountPath then + local msg = noctalia.tr("result.failed", { error = mountOrErr or "unsafe mount path" }) + actionResult(command, false, msg) + notifyErr(msg) + kickAutoMount() + return + end + local mount = mountOrErr + + if not noctalia.fileExists(cipher) then + local msg = noctalia.tr("result.cipher_missing", { path = cipher }) + actionResult(command, false, msg) + if command.silent ~= true then + notifyErr(msg) + else + notifyErr(msg) + end + kickAutoMount() + return + end + + local okMp, mpErr = ensureMountPoint(mount) + if not okMp then + local msg = noctalia.tr("result.mount_create_failed", { path = mount }) .. " (" .. tostring(mpErr) .. ")" + actionResult(command, false, msg) + notifyErr(msg) + kickAutoMount() + return + end + + local args = { "gocryptfs", "-q" } + if vol.readOnly then + table.insert(args, "-ro") + end + if vol.allowOther then + table.insert(args, "-allow_other") + end + + local tempPassfile = nil + local passfile = trim(vol.passfile) + local password = type(command.password) == "string" and command.password or "" + local useKeyring = vol.useKeyring == true + local storeKeyring = command.storeKeyring == true or useKeyring + local mountCmd = nil -- full shell when using keyring (search + extpass) + + if password ~= "" then + -- One-shot: password via tmpfs temp passfile (deleted after mount). + tempPassfile = tempPassPath("pass-" .. vol.id .. "-" .. tostring(os.time())) + local written, werr = writeSecurePassfile(tempPassfile, password) + if not written then + actionResult(command, false, noctalia.tr("result.failed", { error = werr or "could not write passfile" })) + kickAutoMount() + return + end + table.insert(args, "-passfile") + table.insert(args, tempPassfile) + elseif useKeyring then + if not keyctlAvailable() and not secretToolAvailable() then + local msg = noctalia.tr("result.failed", { + error = "neither keyctl nor secret-tool found (install keyutils and/or libsecret)", + }) + actionResult(command, false, msg) + if command.silent ~= true then + notifyErr(msg) + end + kickAutoMount() + return + end + -- Prefer session keyctl (hydrated from secret-tool after reboot); else secret-tool -extpass. + local gocryptParts = {} + for _, a in ipairs(args) do + table.insert(gocryptParts, shellQuote(a)) + end + local cipherQ = shellQuote(cipher) + local mountQ = shellQuote(mount) + local vid = tostring(vol.id) + if keyctlAvailable() then + table.insert(gocryptParts, shellQuote("-extpass")) + table.insert(gocryptParts, shellQuote("keyctl")) + table.insert(gocryptParts, shellQuote("-extpass")) + table.insert(gocryptParts, shellQuote("pipe")) + table.insert(gocryptParts, shellQuote("-extpass")) + table.insert(gocryptParts, "\"$K\"") + table.insert(gocryptParts, cipherQ) + table.insert(gocryptParts, mountQ) + local keyctlMount = table.concat(gocryptParts, " ") + if secretToolAvailable() then + -- Fallback: secret-tool -extpass when session hydrate cannot produce K + local secretParts = {} + for _, a in ipairs(args) do + table.insert(secretParts, shellQuote(a)) + end + table.insert(secretParts, shellQuote("-extpass")) + table.insert(secretParts, shellQuote("secret-tool")) + table.insert(secretParts, shellQuote("-extpass")) + table.insert(secretParts, shellQuote("lookup")) + table.insert(secretParts, shellQuote("-extpass")) + table.insert(secretParts, shellQuote("service")) + table.insert(secretParts, shellQuote("-extpass")) + table.insert(secretParts, shellQuote(SECRET_SERVICE)) + table.insert(secretParts, shellQuote("-extpass")) + table.insert(secretParts, shellQuote("volume-id")) + table.insert(secretParts, shellQuote("-extpass")) + table.insert(secretParts, shellQuote(vid)) + table.insert(secretParts, cipherQ) + table.insert(secretParts, mountQ) + -- ensureSessionKeyShell exits 2 when no material; override to allow secret-tool path + mountCmd = "K=$(keyctl search @u user " + .. shellQuote(keyringDesc(vid)) + .. " 2>/dev/null) || true; " + .. "if [ -z \"$K\" ] && command -v secret-tool >/dev/null 2>&1; then " + .. "secret-tool lookup service " + .. shellQuote(SECRET_SERVICE) + .. " volume-id " + .. shellQuote(vid) + .. " 2>/dev/null | keyctl padd user " + .. shellQuote(keyringDesc(vid)) + .. " @u >/dev/null 2>&1 || true; " + .. "K=$(keyctl search @u user " + .. shellQuote(keyringDesc(vid)) + .. " 2>/dev/null) || true; " + .. "fi; " + .. "if [ -n \"$K\" ]; then " + .. keyctlMount + .. "; else " + .. table.concat(secretParts, " ") + .. "; fi" + else + mountCmd = ensureSessionKeyShell(vid) .. keyctlMount + end + else + -- No keyctl: mount directly via secret-tool + table.insert(gocryptParts, shellQuote("-extpass")) + table.insert(gocryptParts, shellQuote("secret-tool")) + table.insert(gocryptParts, shellQuote("-extpass")) + table.insert(gocryptParts, shellQuote("lookup")) + table.insert(gocryptParts, shellQuote("-extpass")) + table.insert(gocryptParts, shellQuote("service")) + table.insert(gocryptParts, shellQuote("-extpass")) + table.insert(gocryptParts, shellQuote(SECRET_SERVICE)) + table.insert(gocryptParts, shellQuote("-extpass")) + table.insert(gocryptParts, shellQuote("volume-id")) + table.insert(gocryptParts, shellQuote("-extpass")) + table.insert(gocryptParts, shellQuote(vid)) + table.insert(gocryptParts, cipherQ) + table.insert(gocryptParts, mountQ) + mountCmd = table.concat(gocryptParts, " ") + end + elseif passfile ~= "" then + -- Optional legacy/custom plaintext passfile path (user-managed). + local okPf, pfOrErr = isSafeFsPath(passfile) + if not okPf then + local msg = noctalia.tr("result.failed", { error = pfOrErr or "unsafe passfile path" }) + actionResult(command, false, msg) + notifyErr(msg) + kickAutoMount() + return + end + local pf = pfOrErr + if not noctalia.fileExists(pf) then + local msg = noctalia.tr("result.failed", { error = "passfile not found: " .. pf }) + actionResult(command, false, msg) + notifyErr(msg) + kickAutoMount() + return + end + table.insert(args, "-passfile") + table.insert(args, pf) + else + actionResult(command, false, noctalia.tr("panel.password_required")) + kickAutoMount() + return + end + + if mountCmd == nil then + table.insert(args, cipher) + table.insert(args, mount) + mountCmd = shellCommand(args) + end + + actionBusy = true + publishSnapshot() + + local launched = noctalia.runAsync(mountCmd, function(result) + removePassfile(tempPassfile) + local ok = result ~= nil and result.exitCode == 0 and not result.timedOut + local message + if ok then + vol.mounted = true + message = noctalia.tr("result.mounted", { name = vol.name }) + -- After a typed-password mount, keep password in keyring for this login session. + if password ~= "" and storeKeyring then + storeKeyringPassword(vol.id, password, function(krOk, krErr) + if krOk then + vol.useKeyring = true + saveVolumes() + publishSnapshot() + else + noctalia.log(`gocryptfs: keyring store failed: {krErr}`) + end + end) + end + else + local err = trim(result and (result.stderr ~= "" and result.stderr or result.stdout)) + if err == "" then + if result and result.exitCode == 2 and useKeyring and password == "" then + err = "keyring password missing (Remember once, or unlock desktop keyring)" + else + err = result and result.timedOut and "timed out" or "unknown error" + end + end + message = noctalia.tr("result.failed", { error = err }) + end + finishAction(command, ok, message) + end, 60000) + + if not launched then + removePassfile(tempPassfile) + actionBusy = false + actionResult(command, false, noctalia.tr("result.failed", { error = "could not start gocryptfs" })) + publishSnapshot() + kickAutoMount() + end +end + +kickAutoMount = function() + if actionBusy then + return + end + if #autoMountQueue == 0 then + return + end + local id = table.remove(autoMountQueue, 1) + local vol = findVolume(id) + if not canAutoMount(vol) then + kickAutoMount() + return + end + noctalia.log(`gocryptfs: auto-mounting {vol.name}`) + mountVolume({ + action = "mount", + id = id, + requestId = "auto-" .. id, + silent = true, + }) +end + +local function unmountVolume(command) + local vol = findVolume(command.id) + if not vol then + actionResult(command, false, noctalia.tr("result.not_found")) + return + end + if not vol.mounted then + actionResult(command, false, noctalia.tr("result.not_mounted", { name = vol.name })) + return + end + + local bin = unmountBinary() + if not bin then + actionResult(command, false, noctalia.tr("result.failed", { error = "fusermount not found" })) + return + end + + local okMountPath, mountOrErr = isSafeFsPath(vol.mountPoint) + if not okMountPath then + actionResult(command, false, noctalia.tr("result.failed", { error = mountOrErr or "unsafe mount path" })) + return + end + local mount = mountOrErr + local args = { bin, "-u", mount } + + actionBusy = true + publishSnapshot() + + local launched = noctalia.runAsync(shellCommand(args), function(result) + local ok = result ~= nil and result.exitCode == 0 and not result.timedOut + local message + if ok then + -- Optimistic: list must show unmounted even if the follow-up + -- /proc/mounts refresh is delayed or coalesced with an in-flight poll. + vol.mounted = false + message = noctalia.tr("result.unmounted", { name = vol.name }) + else + local err = trim(result and (result.stderr ~= "" and result.stderr or result.stdout)) + if err == "" then + err = "unknown error" + end + message = noctalia.tr("result.failed", { error = err }) + end + finishAction(command, ok, message) + end, 30000) + + if not launched then + actionBusy = false + actionResult(command, false, noctalia.tr("result.failed", { error = "could not start fusermount" })) + publishSnapshot() + end +end + +local function openMount(command) + local vol = findVolume(command.id) + if not vol then + actionResult(command, false, noctalia.tr("result.not_found")) + return + end + local okPath, pathOrErr = isSafeFsPath(vol.mountPoint) + if not okPath then + actionResult(command, false, noctalia.tr("result.failed", { error = pathOrErr or "unsafe mount path" })) + return + end + local path = pathOrErr + if not vol.mounted then + if not noctalia.fileExists(path) then + actionResult(command, false, noctalia.tr("result.not_mounted", { name = vol.name })) + return + end + end + noctalia.runAsync(shellCommand({ "xdg-open", path })) + actionResult(command, true, noctalia.tr("result.success")) +end + +local function conflictsWith(vol, excludeId) + for _, other in ipairs(volumes) do + if other.id ~= excludeId then + if pathEqual(other.cipherDir, vol.cipherDir) or pathEqual(other.mountPoint, vol.mountPoint) then + return true + end + end + end + return false +end + +local function addOrUpdateVolume(command, isUpdate) + local raw = { + id = isUpdate and command.id or nil, + name = command.name, + cipherDir = command.cipherDir, + mountPoint = command.mountPoint, + passfile = command.passfile, + useKeyring = command.useKeyring == true, + allowOther = command.allowOther == true, + readOnly = command.readOnly == true, + autoMount = command.autoMount, + } + if command.autoMount == nil and not isUpdate then + raw.autoMount = command.useKeyring == true or trim(command.passfile or "") ~= "" + end + local vol = normalizeVolume(raw) + if not vol then + actionResult(command, false, noctalia.tr("panel.field.required")) + return + end + + local okCipher, cipherOrErr = isSafeFsPath(vol.cipherDir) + if not okCipher then + actionResult(command, false, noctalia.tr("result.failed", { error = cipherOrErr or "unsafe cipher path" })) + return + end + vol.cipherDir = cipherOrErr + local okMountPath, mountOrErr = isSafeFsPath(vol.mountPoint) + if not okMountPath then + actionResult(command, false, noctalia.tr("result.failed", { error = mountOrErr or "unsafe mount path" })) + return + end + vol.mountPoint = mountOrErr + if trim(vol.passfile) ~= "" then + local okPf, pfOrErr = isSafeFsPath(vol.passfile) + if not okPf then + actionResult(command, false, noctalia.tr("result.failed", { error = pfOrErr or "unsafe passfile path" })) + return + end + vol.passfile = pfOrErr + end + + if isUpdate then + local existing = findVolume(command.id) + if not existing then + actionResult(command, false, noctalia.tr("result.not_found")) + return + end + if conflictsWith(vol, existing.id) then + actionResult(command, false, noctalia.tr("panel.field.duplicate")) + return + end + existing.name = vol.name + existing.cipherDir = vol.cipherDir + existing.mountPoint = vol.mountPoint + existing.passfile = vol.passfile + existing.useKeyring = vol.useKeyring + existing.allowOther = vol.allowOther + existing.readOnly = vol.readOnly + existing.autoMount = vol.autoMount + else + if conflictsWith(vol, nil) then + actionResult(command, false, noctalia.tr("panel.field.duplicate")) + return + end + table.insert(volumes, vol) + end + + local ok, err = saveVolumes() + if not ok then + actionResult(command, false, noctalia.tr("result.failed", { error = err or "save failed" })) + return + end + actionResult(command, true, noctalia.tr("result.saved", { name = vol.name })) + updateRevision("volumes-changed-" .. tostring(os.time())) + publishSnapshot() + refreshAll() +end + +local function removeVolume(command) + local id = trim(command.id) + local index = nil + local name = "" + local passfile = "" + local useKeyring = false + for i, vol in ipairs(volumes) do + if vol.id == id then + index = i + name = vol.name + passfile = vol.passfile + useKeyring = vol.useKeyring == true + if vol.mounted then + actionResult(command, false, noctalia.tr("result.failed", { error = "unmount before removing" })) + return + end + break + end + end + if not index then + actionResult(command, false, noctalia.tr("result.not_found")) + return + end + table.remove(volumes, index) + + -- remove managed passfile if it lives under our passfiles dir (legacy) + local managed = defaultPassfilePath(id) + if managed and passfile ~= "" and pathEqual(passfile, managed) then + noctalia.removeFile(expand(managed)) + end + if useKeyring then + unlinkKeyringPassword(id, function() end) + end + + local ok, err = saveVolumes() + if not ok then + actionResult(command, false, noctalia.tr("result.failed", { error = err or "save failed" })) + return + end + actionResult(command, true, noctalia.tr("result.removed", { name = name })) + updateRevision("volumes-removed-" .. tostring(os.time())) + publishSnapshot() + refreshAll() +end + +local function initVolume(command) + if actionBusy then + actionResult(command, false, noctalia.tr("result.busy")) + return + end + + local name = trim(command.name) + local cipherDir = trim(command.cipherDir) + local mountPoint = trim(command.mountPoint) + local password = type(command.password) == "string" and command.password or "" + local plaintextNames = command.plaintextNames == true + local aesSiv = command.aesSiv == true + -- "savePassfile" UI flag now means store in kernel keyring (no plaintext at rest). + local saveKeyring = command.savePassfile == true or command.saveKeyring == true + local autoMount = command.autoMount == true + local allowOther = command.allowOther == true + local readOnly = command.readOnly == true + + if name == "" or cipherDir == "" or mountPoint == "" then + actionResult(command, false, noctalia.tr("panel.field.required")) + return + end + if password == "" then + actionResult(command, false, noctalia.tr("panel.password_required")) + return + end + if saveKeyring and not keyctlAvailable() and not secretToolAvailable() then + actionResult(command, false, noctalia.tr("result.failed", { + error = "neither keyctl nor secret-tool found (install keyutils and/or libsecret)", + })) + return + end + + local vol = normalizeVolume({ + name = name, + cipherDir = cipherDir, + mountPoint = mountPoint, + passfile = "", + useKeyring = saveKeyring, + allowOther = allowOther, + readOnly = readOnly, + autoMount = autoMount and saveKeyring, + }) + if not vol then + actionResult(command, false, noctalia.tr("panel.field.required")) + return + end + if conflictsWith(vol, nil) then + actionResult(command, false, noctalia.tr("panel.field.duplicate")) + return + end + + local okCipher, cipherOrErr = isSafeFsPath(vol.cipherDir) + if not okCipher then + actionResult(command, false, noctalia.tr("result.failed", { error = cipherOrErr or "unsafe cipher path" })) + return + end + local cipher = cipherOrErr + local okMountPath, mountOrErr = isSafeFsPath(vol.mountPoint) + if not okMountPath then + actionResult(command, false, noctalia.tr("result.failed", { error = mountOrErr or "unsafe mount path" })) + return + end + + if noctalia.fileExists(cipher .. "/gocryptfs.conf") then + actionResult(command, false, noctalia.tr("result.already_initialized", { path = cipher })) + return + end + + local okDir, dirErr = ensureDir(cipher) + if not okDir then + actionResult(command, false, noctalia.tr("result.failed", { error = "cipher dir: " .. tostring(dirErr) })) + return + end + + local tempPassfile = tempPassPath("init-" .. vol.id .. "-" .. tostring(os.time())) + local written, werr = writeSecurePassfile(tempPassfile, password) + if not written then + actionResult(command, false, noctalia.tr("result.failed", { error = werr or "could not write passfile" })) + return + end + + local args = { "gocryptfs", "-init", "-q", "-passfile", tempPassfile } + if plaintextNames then + table.insert(args, "-plaintextnames") + end + if aesSiv then + table.insert(args, "-aessiv") + end + table.insert(args, cipher) + + actionBusy = true + publishSnapshot() + + local launched = noctalia.runAsync(shellCommand(args), function(result) + removePassfile(tempPassfile) + local ok = result ~= nil and result.exitCode == 0 and not result.timedOut + if not ok then + local err = trim(result and (result.stderr ~= "" and result.stderr or result.stdout)) + if err == "" then + err = result and result.timedOut and "timed out" or "unknown error" + end + finishAction(command, false, noctalia.tr("result.failed", { error = err })) + return + end + + local function finishInit() + table.insert(volumes, vol) + local saved, saveErr = saveVolumes() + if not saved then + finishAction(command, false, noctalia.tr("result.failed", { error = saveErr or "save failed" })) + return + end + finishAction(command, true, noctalia.tr("result.initialized", { name = vol.name })) + end + + if saveKeyring then + storeKeyringPassword(vol.id, password, function(krOk, krErr) + if krOk then + vol.useKeyring = true + vol.autoMount = autoMount + else + noctalia.log(`gocryptfs: keyring store failed: {krErr}`) + vol.useKeyring = false + vol.autoMount = false + end + finishInit() + end) + else + finishInit() + end + end, 120000) + + if not launched then + removePassfile(tempPassfile) + actionBusy = false + actionResult(command, false, noctalia.tr("result.failed", { error = "could not start gocryptfs -init" })) + publishSnapshot() + end +end + +-- Store password in kernel keyring for an existing volume (no mount required). +local function storeKeyringAction(command) + local vol = findVolume(command.id) + if not vol then + actionResult(command, false, noctalia.tr("result.not_found")) + return + end + local password = type(command.password) == "string" and command.password or "" + if password == "" then + actionResult(command, false, noctalia.tr("panel.password_required")) + return + end + if not keyctlAvailable() and not secretToolAvailable() then + actionResult(command, false, noctalia.tr("result.failed", { + error = "neither keyctl nor secret-tool found (install keyutils and/or libsecret)", + })) + return + end + + actionBusy = true + publishSnapshot() + + storeKeyringPassword(vol.id, password, function(ok, err) + actionBusy = false + if not ok then + local msg = noctalia.tr("result.failed", { error = err or "keyring store failed" }) + actionResult(command, false, msg) + notifyErr(msg) + publishSnapshot() + return + end + vol.useKeyring = true + if command.enableAutoMount == true then + vol.autoMount = true + end + local saved, saveErr = saveVolumes() + if not saved then + local msg = noctalia.tr("result.failed", { error = saveErr or "save failed" }) + actionResult(command, false, msg) + notifyErr(msg) + publishSnapshot() + return + end + local msg = noctalia.tr("result.keyring_saved", { name = vol.name }) + actionResult(command, true, msg) + notifyOk(msg) + publishSnapshot() + refreshAll() + end) +end + +local function executeAction(command) + if type(command) ~= "table" or type(command.action) ~= "string" then + return + end + + if command.action == "refresh" then + refreshAll() + return + end + + if command.action == "reload_config" then + loadVolumes() + autoMountScheduled = false + autoMountQueue = {} + refreshAll() + return + end + + if command.action == "add_volume" then + addOrUpdateVolume(command, false) + return + end + if command.action == "update_volume" then + addOrUpdateVolume(command, true) + return + end + if command.action == "remove_volume" then + removeVolume(command) + return + end + if command.action == "open" then + openMount(command) + return + end + if command.action == "init_volume" then + initVolume(command) + return + end + if command.action == "store_keyring" then + storeKeyringAction(command) + return + end + + if command.action == "forget_keyring" then + local vol = findVolume(command.id) + if not vol then + actionResult(command, false, noctalia.tr("result.not_found")) + return + end + actionBusy = true + publishSnapshot() + unlinkKeyringPassword(vol.id, function() + vol.useKeyring = false + -- Do not force autoMount off if user still has a passfile path + if trim(vol.passfile) == "" then + vol.autoMount = false + end + local saved, saveErr = saveVolumes() + actionBusy = false + if not saved then + local msg = noctalia.tr("result.failed", { error = saveErr or "save failed" }) + actionResult(command, false, msg) + notifyErr(msg) + publishSnapshot() + return + end + local msg = noctalia.tr("result.keyring_forgotten", { name = vol.name }) + actionResult(command, true, msg) + notifyOk(msg) + publishSnapshot() + refreshAll() + end) + return + end + + if actionBusy then + actionResult(command, false, noctalia.tr("result.busy")) + return + end + + if command.action == "mount" then + mountVolume(command) + elseif command.action == "unmount" then + unmountVolume(command) + else + actionResult(command, false, `Unknown action: {command.action}`) + end +end + +-- boot +loadVolumes() +noctalia.state.watch(COMMAND_KEY, executeAction) +noctalia.setUpdateInterval(refreshIntervalMs()) +refreshAll() + +function update() + refreshAll() +end + +function onConfigChanged() + noctalia.setUpdateInterval(refreshIntervalMs()) + -- re-run auto-mount if user turned it on + if autoMountEnabled() and autoMountScheduled then + for _, vol in ipairs(volumes) do + if canAutoMount(vol) then + local already = false + for _, id in ipairs(autoMountQueue) do + if id == vol.id then + already = true + break + end + end + if not already then + table.insert(autoMountQueue, vol.id) + end + end + end + kickAutoMount() + end + refreshAll() +end + +function onIpc(event, _payload) + if event == "refresh" then + refreshAll() + elseif event == "reload" then + loadVolumes() + autoMountScheduled = false + autoMountQueue = {} + refreshAll() + elseif event == "automount" then + autoMountScheduled = false + autoMountQueue = {} + refreshAll() + end +end diff --git a/gocryptfs/thumbnail.webp b/gocryptfs/thumbnail.webp new file mode 100644 index 0000000000000000000000000000000000000000..8e362ef6df49dd197aded41847acc9e37f75fee6 GIT binary patch literal 50042 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%0A0J;kM~U1-QjlTaHu{IxZj-K=CS zc)0TH)KGEhS0lPZgiF9n$GA6|0`+u^>oAu?f8vf`7iwaSiSqx zon`N{1J~~Odwhy&!lb?Jx-W(5yb^*Bym=l`AQEF7xI{Al#`ex}f2Cc_z zKj$Sb(s)y_c4gwDqb!k|kL%?IU-Ffb5VkgL-qn@6a+&~J%Rw%qMxDmvYZ@)~&F=09 zJi!sKkzROwpIl7G81gZJpn)ZJdZ|xl`Zx^?{(bbh@XSRr=RZ< z$}V@k%JyDyMr@n2)0Zy`7bpE)6}@y%QOxp%?1vX7haD=N<*?Oh*TL1D%l!7f{FMCk zP*Ora=a+BgQBxD!p3FV^t2$?o@T*M^+01uwm&{-Pdh+t9(!Ja6YP?GMVHtNlN=Q_4 z|FT1GMeF33rH1u2b57BFcDKYi^M_&Ne2pV%9~Mc*mcHfSf2}0*hhyQ%2P><@Ht4X* zia7ANp4oQkTi#=3*D3BPos7DDea#mqdWC#)D|=8?b#(KpXx;B>uV$X*%UirWU~jg0 zchYC`ZanZ+P+k?8^DI5< zsw}&Ltkl25EL!VY6&j-A!#cedU-!B;@nb{glJ5cQxjn2qVep@0JG4Zgtl8L z_r)h2`#v)}_wn}9hpVIFJ$p6nWcE!mU|A{i{Tqj_tS7^TXMy@ZZdv}lTa?13h0!ymk4LtnNi)@=9NuwtT~RopqV&!0qAg{0r#)%q`PDfhPAa~HjhcXi$s>T&j6 z{AIxw>5IR47c*=Ly|7F(_~pfQofmqzU4ypIaXfp{*(js$N?Pl@%>S)T9n%d0F08Kz zJ<_V{Y(}nq~iEHCmfqe#^Y_i$~3W+k)Z-{&(!o-?DZHsXX>c-NE^6 zwiEaF6u#49Z&eOVSpNKzhVR}T?{>+nP2I=AAeFfF*B9-;88!l9Q|=#5+O@&P@W-n^ zXC>eH`$k;7?!2zKcR@j3qQHXGFKzz5M>r3gccrbbpOv#X>Nexmz|+y^^X$H5J-qtH z;lWeO$L>T2nI5 zt4Lqn^1Jrus%dNECbw>Wc)D=e?_16D40UF{Pi6L-SUTy?-h#Y2ud@ukIx`mOPZWID zbV=H2QQ0BmEsGZ>i7;F?{^#)MXQf~JOG8^XHP@eeXZWpGeY9@D_vX!6b6s!z*k<5< zH_2h|SIyS8i=WEwBq@X5(m`rawMsP*q`t~4|+ z>@c-su`X=%x+~K5>h}iUx98r8xb|PTaaTTZxs2P)^AF~qKlbyZi`h2)bRT&cH3Q8m zYpcuCXGMSP$+C!HVVwM$rSM(%L+;}f{7$J!+pr3VJ)NQv+8xkfD#omnxN_pD$FoO~pvp+R8Y810*)N;Rg`Tp^O4_~BjpZ?xfeM)~-Wz2?ynxWg|Br-Hu22AN^Q%Z6&6w_<=+~foce0| zpEnEq-P;9^`*&t&M;)6}<(084{r2x$G1lkq_Ll^^zH&KvwDrukzf-|@y< z{fR3iUTe?Qaa6g<^Ve1KfzR)`=kvqr_SEccvdR0i(C@%QZO*66jxw#&7RR*NTIj1< z-kBvc@tA{w_O2W41s_r_Ofo#iG(+!Pf9|*4ik%4uo9=0HG0$24Lh-G^Bpay@H@TUD z7d*RYq_#k*(fGtB`z!4=O?Cxk8@bKfY+ZM1rmR@Ab-u8HJ@Q;xWruJr=VDu1l7>Z@Q@VdgMm^TBndniZ{w? zbTWejyY8HmRW%A+V>Tm6DkP|CAM^CLCoXGEdFA|Oapqz9zQ70V8occfmMAOSvs!%r ztgF7NdiLhke-CG{vVA`g;=y!+;X3=oh@e>u3+g1o9S>U7s8v47Z{Nk->`}!fF6}7S zcAsZr$F9kKNedjLSMH74_t@i{m*Apj`{wTTE6clQxOB(2}8@c!U7*4e#qZQz&dsto<1 z=Q3{GpS^9v3c(ZqICCR7rc7^|Uh4RCX7QGdZE}gvS@Mk2Lt6hPJaM}-XU65%_okl6 z`})D)=aWCOKC`bVP2b(BxJZQmMSn@&#D*PJE;3)aB^cjMSvMiZKzYhmef{U*Rj1~D zIC;8Yzd)(b#0G_28UOpy&1S!a1e&JRYwf8x`D?|VX?K3rPty;%cdeyCaMSL?FcA~UwNZEj<-q9_1~G#KdXW(oGrin@yYI{n`^R+#dYT@9O<32)iqPobk+IK zYJ#8nuIx3D(q=H(uj;HKlCbGh_J=q1;*}SR?-l#+++-KA`=J8UQ`Od-j(t7+cdp%F z*~}>)sy;1Yr^Yn1$hQfZU8LtH1d>cDofAi63Kh_s~N1eZ( zZV1`Wu=RIpc;~xsYlI{3hh=wYYZ?@r-G1ltSF_sv%g>8lq496kw?;gDB*(0vlz9Bl zf1$S9E1JIDl3LSuzq+;J=yMClec^iQ@(!o2-MjVMh{NVpZqzZscM&#b=Vzb$z?1e} za?=w*MJvDH_pGO8y0Y!eJo>Ur`;AD7QP8|s((G+3#AnY6Sz#i2ZbrCBNs;@u8+W&U zEOD%~YW*e=bvZfvWMinuO`9je=UI}~7(dzSInQ^C6PfA%`^Gznkf3 zO7-4l-P3n?2`U)bUB7$6+gj%M(r7bxzTXDUeI`q0+kc&M%uQKkgM}dmHm*)IILpu+?tj3(cn`EgiS#X|VqdnDy~4m;0ItmmQpf zMMAFf+?6k$(Epoj`BbCe&TlabUe#U#RZwuvR! ze>m1zw4{Db@5!#RumwFW@v{rvJoB&S-7&eotZBy!iO(n3o^tB3mS}v?s~kN`bkYf? z4}aF|TVbJn>qeu)<;#f$q31Wo<&^Z@J1eUwJJo-U&ir{oS*jxGuPgeqo~N#5IiPgy zXz{LVycJW{mTfVgV!F5^KPK#vRdaT2zSr+L&vGU*uep2vf*b4%vWW*}9(;XVcF*>#)PLDC zUumogPFS$_i9tic2bJ7CLcQ~%9A--z-~Yq?u7WeZSY(>Tb3rx9)t58=ZGCj@gi?^r zEZbiRpA(HcURf=hB6IP-7gx!#W+erO;5QY&FZ&(OTV<2o*BuppB|Q3)@fp+C&9`44 z@tAqO;Ca;D1xq^4rB9d}x|(6hgR5!95%=@XoCjxX{0?qI1qkuiDMA6PNn`?6McT?-sQvS+jx7IOL$x@q7K(WY=HXbpG33 z#s7D2sR;OTtlbo~zB@w0^|Qs-orjkhhw=A)-2J@f`|Q6DFW&!XsH@X1x+eX9-@fKm zPrcG5uJ3R5_%9&8XXP{7nZLJ&dMj>!7rEhiPV|)HSs$|54qQEYt7n;ETOGIbtE#hi zzPC!)y{WUg_R*97%>PJzOV<4VU-p>@ZhVzj`u@Gn6hj9F^U1k;KhJsMv)+g0?bL~7 z2kd&}AFWEv<;p4V zd?LiN8oyn>^XZnE%lSmE>+6+T@?D%I!q4fO7;Nc#a<|5;H8p1<$FA+HDJ&j~ljg73 zoh8O@(qd>o^=0Ft9KQMcO!H)e&iwZl(LQ@z)pm;aw4*L*o5L5_JkYxrb>mju@r99m zP8RAca~gv5td=wM+r^m_rwL5oI&xyf99>GRt_!AIOdS6=@Q3VIg%YWIPf z$HCJS*W9R>eQoB852;aJLT?zeH$BX;(fHf>E|vdKf^QO2nM>R>Mf*1o+v*Re|JIOF zng0Bh!du4YjAfs$9lQL^)-O|1zhUR}jOC{vbC!PZ6v=WBymNTwgluuEFDt)HDw-6Q zu$3!n|H7MV6t$PJbRL9=$DrQGoH>>H4|Ov_DlTBrb9nit@lN}4Drwli~sm5 zN1xkvA#?NQiSF@#Qrsu#sx;>Jo{9{7JTd0GuDDtArebxRZ{N`ud=tmP)D7X8G zKh^!?&L1~Zu6N6|cd9LHbx$~4Qg{w;3DxXh8R>jv*{t&Fez&g=PI-s@V`$8Mc9nT$ z7WXsZJXuBVS^b;M8De55XD^-6VJ;$kQ)mnWj0RYC+YTbIy@}etk|o>XoO-Ei)}?gWAvKPpZ=HD}G$H_SbG^ zlf&n3egC{7$Vxiumx9dQ`3XnPKHj@j*-vD6|2z7J z8h@(k%RVcesMo!vm%Xsx@#*&tH(dddZ9$9gR|H&WKXQ^k%)RuJXP(X~52cWj#ZPCn zE!h)j)4*`~>)ojn_*U-QVxa3^SNLwTL5~^hOwlDNPd}}R){Xl#sWeQ{>3G!f6wDuj7FSu;^UTdenGey7s(YxN;sv<5OOE29r$efXsd(=j?g<@0SvPI%CL$??LUNG(48@b)=f)O**%U-rV;r(=Yn_Y7CdmXp_rM9vL54>&(+H7xf zG*aL>rZXe|haD4_`>VRiRX>jjP7j$Q%OH2f&~WB5q2#_1=D)vl7!JesE-0PP5y* z{Pu2}69sBB6pnno`y_Mou9u69|9n|{!7D`a{*`5g;zuR*b=OUK867bD&HU^(_oK^v z7Yeg_P3Zho9{n6rqXcu;O7NX4i|MktUUR*jqjXV@_ofKn<9NCYnb#f^zRYHygmCTDEk*u8q^j(@A>{i%93bNRgnlc{rT`Lnm08=9{U z4M{(v#S=13EhcZr_b)2t+XFs_Zggz$`u=97jdFXN#;p%$T4gR;-M{zl^of^O*t|n- z$FO()n8=b{DfX{)@8=1{3xu_JrZ}Yg^^}I|pHJHT*l=#=Jq4Dj_y(#FtK3pE<>JhX zGR*6hEw((2m>F$#_7JbeyjK%ev`JaZrK%r@G7bD(Yib}X8@Tcx->iajZ)>uiHB_>FZOa4{trb7RS7jJK5iM)!6L0q^*?1Mb-Bozptv} zHaoRwi|@*(`M1+8vVIFq$zz%)m6B4aC^L<-xujeH>4e9G+dU{SE{Ea>{y#QMI&O} zp4O>83^BfcGUrOS?%HQ?;iQ+9PlZ?8ivD}CjMm1J;y-sDyR_x+A1j|jyTANA6WzYP znc39FTCn5D?muU4^S@TP%n_3y*RcBR+P2q8XOcf(D{s#`vRxt2>Y&+!PRryQrJSzk zM7_iO<1-ac`MzUKWUSb{E7R@8%>63BH=VdFIa zcW`^>i+?i}&!!cu+x}N;{x0|5t1`2;tFFxVtVi`O{~XqGT|C0O z)%3n*RZz|{JF67g>PLLnZfizmoU~^2*qz>YsQGnGE^CVXG<^qyOM=Ud%-rJr^+Y#) zP*GWvWqmzJ;+2o^g&neQBc9rw(gI@g?0JFvp)iL#7&+Ft=9^W4)0+K)e~2Wx3& zXhyT``SRe(R7=;U1jDaJTH#g!46VV3rzy++m(oduz4Nl+~9OZ@G3M zV)MmimpLC)$z7gUCAFn({mZ1lI&1dh7XX+=n!-s9H zIW+J(P(nW zV~_C`Ip;0n73;$pwpj^y*memq-?2~N=6L)5L^=DN|Dn#s^RJg4`M~NtFEgv!q(yAk z)z~xD&aeMG*!b;o>+Egkw{o~h+`88y@Z;#0g767{m?d}BHKeZ-i)K1i%4zMS{e(Yk z1@p&pk(ADOX*RB-o4=Y18M)lhbW2)bTPk?WG-Jw(ciWt$*R>n$I(bN=jmfdX`PXZK zxqHscI=0^JtL4<>ZTEj4zWHy;ndKH`v2MHH_&-^u8NfShPQzkzq1_fAUz8o#<}uH5 zX6Cm}gWG%_H4i35ynpiO;(`CGQsTHoRqsmMc1N7ON&4N1#T{ zq|LAAR{x80P`oJV+UFm|{8IhDdT!_D#U)w`H*~ki{D1kP-fH%fz9o!H#6KK0V3A1R z-{UtyW>en72EMlLRZk}9e0Sn_t2;;b{Nd=vux}sq52ZdRy!6`e*bcF^D}KIMztMVf X;P!3-ud}LI`5CtQU%RF-FfafBhf^*S literal 0 HcmV?d00001 diff --git a/gocryptfs/translations/en.json b/gocryptfs/translations/en.json new file mode 100644 index 0000000..1373d26 --- /dev/null +++ b/gocryptfs/translations/en.json @@ -0,0 +1,140 @@ +{ + "title": "Gocryptfs", + "settings": { + "refresh_interval": { + "label": "Refresh interval (seconds)", + "description": "How often the service rechecks mount status." + }, + "notify_on_action": { + "label": "Notify on mount/unmount", + "description": "Show a notification when a volume mounts or unmounts." + }, + "create_mountpoint": { + "label": "Create mount points", + "description": "Create the mount directory if it does not exist before mounting." + }, + "auto_mount": { + "label": "Auto-mount on login", + "description": "When Noctalia starts, mount volumes that have auto-mount enabled and a keyring password or passfile." + }, + "show_count": { + "label": "Show mounted count", + "description": "Display mounted/total on the bar widget." + }, + "glyph_color": { + "label": "Icon color", + "description": "Color of the lock icon when gocryptfs is available." + }, + "mounted_color": { + "label": "Mounted indicator", + "description": "Status-dot color when at least one volume is mounted." + }, + "unmounted_color": { + "label": "Unmounted indicator", + "description": "Status-dot color when nothing is mounted." + } + }, + "colors": { + "default": "Default", + "primary": "Primary", + "secondary": "Secondary", + "tertiary": "Tertiary", + "error": "Error", + "muted": "Muted" + }, + "widget": { + "tooltip": "{mounted} of {total} mounted", + "tooltip_none": "No volumes configured", + "unavailable": "gocryptfs is not installed", + "refresh_requested": "Refreshing volume status…" + }, + "panel": { + "subtitle": "Encrypted volumes", + "empty": "No volumes yet. Initialize a new volume or add an existing one.", + "loading": "Checking mounts…", + "busy": "Working…", + "select_hint": "Select a volume to manage it.", + "updated": "Updated {time}", + "password_title": "Password for {name}", + "password_label": "Password", + "password_placeholder": "Volume password", + "password_required": "Password is required.", + "keyring_title": "Remember password for {name}", + "keyring_label": "Volume password (desktop keyring + session cache)", + "keyring_help": "Saved in your desktop keyring (survives reboot when unlocked) and cached for this login. No password file under plugin data.", + "keyring_not_set": "No remembered password for this volume.", + "remember_keyring": "Also remember in keyring (desktop + this login)", + "add_title": "Add existing volume", + "edit_title": "Edit volume", + "init_title": "Initialize new volume", + "init_help": "Runs gocryptfs -init on the cipher directory, then registers it for mounting.", + "field": { + "name": "Name", + "name_placeholder": "Documents", + "cipher": "Cipher directory", + "cipher_placeholder": "~/Encrypted/docs", + "mount": "Mount point", + "mount_placeholder": "~/Private/docs", + "keyring_section": "Password (desktop keyring)", + "passfile": "Passfile path (optional, advanced)", + "passfile_placeholder": "Leave empty — prefer desktop keyring", + "password": "Password", + "password_confirm": "Confirm password", + "password_confirm_placeholder": "Re-enter password", + "password_mismatch": "Passwords do not match.", + "plaintextnames": "Plaintext file names (-plaintextnames)", + "aessiv": "AES-SIV encryption (-aessiv)", + "save_passfile": "Remember password in keyring (desktop + this login)", + "auto_mount": "Auto-mount on login", + "auto_mount_help": "Requires a remembered keyring password or advanced passfile. Global auto-mount must also be on. Desktop keyring must unlock after login for reboot-safe auto-mount.", + "allow_other": "Allow other users (-allow_other)", + "read_only": "Read-only (-ro)", + "required": "Name, cipher directory, and mount point are required.", + "duplicate": "A volume with that cipher directory or mount point already exists." + }, + "status": { + "mounted": "Mounted", + "unmounted": "Unmounted", + "unknown": "Unknown" + }, + "cipher": "Cipher: {path}", + "mountpoint": "Mount: {path}", + "passfile_hint": "Using passfile", + "keyring_hint": "Password in keyring", + "automount_hint": "Auto-mount" + }, + "actions": { + "mount": "Mount", + "unmount": "Unmount", + "open": "Open", + "refresh": "Refresh", + "add": "Add", + "init": "Init", + "edit": "Edit", + "remove": "Remove", + "remember": "Remember", + "forget": "Forget", + "save": "Save", + "cancel": "Cancel", + "close": "Close" + }, + "result": { + "success": "Done", + "failed": "Failed: {error}", + "busy": "Another operation is already running.", + "gocryptfs_missing": "gocryptfs is not on PATH. Install gocryptfs and try again.", + "mounted": "Mounted {name}", + "unmounted": "Unmounted {name}", + "initialized": "Initialized and saved {name}", + "already_initialized": "Already a gocryptfs volume: {path}", + "not_found": "Volume not found.", + "cipher_missing": "Cipher directory does not exist: {path}", + "mount_create_failed": "Could not create mount point: {path}", + "already_mounted": "{name} is already mounted.", + "not_mounted": "{name} is not mounted.", + "removed": "Removed {name}", + "saved": "Saved {name}", + "keyring_saved": "Password for {name} saved in desktop keyring", + "keyring_forgotten": "Keyring password cleared for {name}" + } +} diff --git a/gocryptfs/widget.luau b/gocryptfs/widget.luau new file mode 100644 index 0000000..dbe411d --- /dev/null +++ b/gocryptfs/widget.luau @@ -0,0 +1,93 @@ +--!nonstrict + +local PANEL_ID = "davemhammer/gocryptfs:manager" +local STATE_KEY = "gocrypt_snapshot" +local COMMAND_KEY = "gocrypt_command" + +local snapshot = noctalia.state.get(STATE_KEY) or { + available = false, + loading = true, + mountedCount = 0, + totalCount = 0, +} + +local requestId = 0 + +local function configString(key, fallback) + local value = noctalia.getConfig(key) + return type(value) == "string" and value or fallback +end + +local function render() + local mounted = tonumber(snapshot.mountedCount) or 0 + local total = tonumber(snapshot.totalCount) or 0 + local available = snapshot.available == true + local showCount = noctalia.getConfig("show_count") ~= false + local anyMounted = mounted > 0 + + local glyphName = anyMounted and "lock-open" or "lock" + local glyphColor = available and configString("glyph_color", "on_surface") or "on_surface_variant" + if anyMounted then + glyphColor = configString("mounted_color", "tertiary") + end + + local children = { + ui.glyph({ + name = glyphName, + size = 16, + color = glyphColor, + }), + } + + if showCount and available and total > 0 then + table.insert(children, ui.label({ + text = `{mounted}/{total}`, + fontWeight = "bold", + color = "on_surface", + })) + end + + if available and total > 0 then + table.insert(children, ui.box({ + width = 7, + height = 7, + radius = 4, + fill = anyMounted and configString("mounted_color", "tertiary") or configString("unmounted_color", "on_surface_variant"), + })) + end + + local container = barWidget.isVertical() and ui.column or ui.row + barWidget.render(container({ gap = 5, align = "center" }, children)) + + if not available then + barWidget.setTooltip(noctalia.tr("widget.unavailable")) + elseif total == 0 then + barWidget.setTooltip(noctalia.tr("widget.tooltip_none")) + else + barWidget.setTooltip(noctalia.tr("widget.tooltip", { mounted = mounted, total = total })) + end +end + +noctalia.state.watch(STATE_KEY, function(value) + if type(value) == "table" then + snapshot = value + render() + end +end) + +noctalia.setUpdateInterval(5000) +render() + +function update() + render() +end + +function onClick() + noctalia.togglePanel(PANEL_ID) +end + +function onRightClick() + requestId += 1 + noctalia.state.set(COMMAND_KEY, { action = "refresh", requestId = `widget-{requestId}` }) + noctalia.notify(noctalia.tr("title"), noctalia.tr("widget.refresh_requested")) +end