Add davemhammer/gocryptfs (#324)
Mount, unmount, init, and auto-mount gocryptfs volumes from Noctalia. Passwords use secret-tool (desktop keyring) plus keyctl session cache.
This commit is contained in:
@@ -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=<volume-id>`. Survives reboot while the login keyring is unlocked.
|
||||||
|
2. **Kernel session keyring** via `keyctl` — description `noctalia-gocryptfs:<volume-id>`. 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 <id>`. 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.
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 49 KiB |
@@ -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}"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user