feat(udiskie): add aristides/udiskie plugin for usb/media drives management (#256)

* feat(udiskie): add Udiskie service for real-time device monitoring, notifications, and IPC

* feat(udiskie): add main manager panel for drives and partitions

* feat(udiskie): add plugin manifest, status widget, and english translation

* feat(udiskie): add Makefile for testing and linting translations

* feat(udiskie): add error handling for missing udiskie package

* feat(udiskie): add right-click functionality to refresh Udiskie service

* feat(udiskie): add option to hide widget when no devices are connected

* feat(udiskie): add settings button to the panel for quick access to configuration

* feat(udiskie): add button to copy mount path to clipboard in partition row

* fix(udiskie): duplicated notifications when removing or unmounting devices

* feat(udiskie): add disk usage information for mounted partitions in device state

* feat(udiskie): update partition row status colors and enhance UI for status display

* fix(udiskie): adjust timeout to give time for sudo tasks such as LUKS unlock

* feat(udiskie): update partition row status color for LUKS devices and adjust panel width

* fix(udiskie): scroll not working properly and items overflow container

* feat(udiskie): add missing translations and remove hardcoded strings messages

* feat(udiskie): add README and thumbnail

* docs(udiskie): add performance section to README with resource usage comparison

* fix(udiskie): update output parsing to use safe tab delimiters for device information

* fix(udiskie): improve device hierarchy parsing to correctly identify partitions

* feat(udiskie): centralize open with file manager code, and close panel on auto-open on mount (like manually open)

* fix(udiskie): exclude LUKS containers from "Drive Connected" notifications

* fix(udiskie): quote paths and device names in shell commands

* fix(udiskie): improve error message cleaning regex

* feat(udiskie): enhance README with device attributes and update service to suppress errors

* fix(udiskie): use shell quoting for device paths in panel async commands

* feat(udiskie): add spanish translations and update tests to check every translation
This commit is contained in:
Arístides Pérez
2026-08-05 17:58:15 -04:00
committed by GitHub
parent 52bb05d5aa
commit 57e4b108c4
10 changed files with 1055 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
SHELL := /bin/sh
.PHONY: test translations lint
test: translations lint
translations:
@if command -v jq >/dev/null 2>&1; then \
jq empty translations/en.json && echo "✓ translations/en.json is valid JSON"; \
fi
python3 tests/check_translations.py
lint:
@if command -v noctalia >/dev/null 2>&1; then \
noctalia plugins lint . && echo "✓ Noctalia plugin lint passed"; \
fi
python3 ../.github/workflows/scripts/validate-plugins.py && echo "✓ Official repo manifest validation passed"
git diff --check
+98
View File
@@ -0,0 +1,98 @@
# Udiskie Manager
Manage USB drives and media with notifications in just one panel.
## Features
- Real-time device discovery and status monitoring via UDisks2 event streaming.
- Storage capacity and partition usage indicators with formatted bytes and percentage.
- Automatic detection and dedicated unlock action for LUKS encrypted volumes (`crypto_LUKS`).
- Interactive management panel with drive hierarchy, open, mount, unmount, eject, and power off actions.
- Copy mount path to clipboard action.
- Configurable bar widget with mounted device counter and auto-hide when empty option.
- Native desktop notifications for drive connections, disconnections, mounts, unmounts, and errors.
- Quick refresh and native settings integration.
## Plugin
| Field | Value |
| ------- | ---------------------------------------------------------- |
| ID | `aristides/udiskie` |
| Entries | Bar widget: `status`; panel: `manager`; service: `service` |
## Requirements
Install `udiskie`, `udisks2`, and `xdg-open` on `PATH`:
- `udiskie`: Device mount operations and device info queries.
- `udisks2`: Provides `udisksctl` for real-time DBus event streaming.
- `xdg-open`: Launcher binary for opening mounted folders in the file manager.
> **`udiskie-info -o` fields**: the service queries `udiskie-info -o` with the
> device attributes listed under `VALID_PARAMETERS` (e.g. `is_drive`,
> `is_partition`, `is_luks`, `mount_path`, `is_detachable`). If your `udiskie` is
> old enough to lack any of them, the device list will come back empty — upgrade
> `udiskie` in that case. A recent release (2.x) is recommended.
## Usage
- **Bar Widget (`status`)**: Add `status` to the bar configuration in Noctalia settings. Left-click to open the Udiskie Manager Panel. Right-click to trigger an immediate plugin refresh.
- **Panel (`manager`)**: Open via the bar widget or run:
```sh
noctalia msg panel-toggle aristides/udiskie:manager
```
## Settings
| Setting | Type | Default | Description |
| ----------------------- | -------- | ------------ | ---------------------------------------------------------------------- |
| `enable_notifications` | `bool` | `true` | Show desktop notifications on drive events and errors. |
| `auto_open_filemanager` | `bool` | `false` | Automatically open mounted drives in the file manager upon connection. |
| `file_manager_cmd` | `string` | `xdg-open` | File manager launcher command. |
| `glyph` | `glyph` | `device-usb` | Icon glyph shown for the widget on the bar. |
| `show_count` | `bool` | `true` | Display the count of mounted devices on the bar widget. |
| `hide_when_empty` | `bool` | `false` | Hide the bar widget when no USB drives are connected. |
## IPC
```sh
# Toggle panel
noctalia msg panel-toggle aristides/udiskie:manager
# Service actions
noctalia msg plugin aristides/udiskie:service all mount /dev/sdX
noctalia msg plugin aristides/udiskie:service all unmount /dev/sdX
noctalia msg plugin aristides/udiskie:service all eject /dev/sdX
noctalia msg plugin aristides/udiskie:service all detach /dev/sdX
noctalia msg plugin aristides/udiskie:service all mount_all
noctalia msg plugin aristides/udiskie:service all unmount_all
noctalia msg plugin aristides/udiskie:service all refresh
```
## Performance
The plugin eliminates the standalone Python `udiskie` daemon by using an event-driven `udisksctl monitor` subprocess managed by Noctalia. Both approaches activate `udisksd` via D-Bus on first use.
| Component | RSS | VSZ | CPU |
| ------------------------------------ | ---------- | -------- | ---- |
| `udisksctl monitor` (this plugin) | ~12 MB | ~170 MB | 0.0% |
| `udiskie` Python daemon (standalone) | ~84–110 MB | ~1183 MB | 0.2% |
| `udisksd` (shared, D-Bus activated) | ~22 MB | ~688 MB | 0.2% |
**Total footprint**: plugin ~34 MB vs standalone ~106–132 MB. **Saves ~72–98 MB RAM** and avoids a persistent Python process.
> Measurements are local to a given system/kernel and udiskie version; actual
> RSS/VSZ/CPU values vary by hardware and environment.
## Development
Run plugin validation and tests using the workspace Makefile:
```sh
make test
```
## Notes
- Requires plugin API level 9 or newer.
+307
View File
@@ -0,0 +1,307 @@
--!nonstrict
-- Udiskie Manager Panel: Hierarchical Declarative UI panel for managing drives & partitions.
local function shellQuote(value)
return "'" .. tostring(value):gsub("'", "'\\''") .. "'"
end
local function mountDevice(dev)
noctalia.runAsync("noctalia msg plugin aristides/udiskie:service all mount " .. shellQuote(dev))
-- Close the panel when mounting if the file manager will auto-open.
if noctalia.getConfig("auto_open_filemanager") == true then
panel.close()
end
end
local function unmountDevice(dev)
noctalia.runAsync("noctalia msg plugin aristides/udiskie:service all unmount " .. shellQuote(dev))
end
local function ejectDevice(dev)
noctalia.runAsync("noctalia msg plugin aristides/udiskie:service all eject " .. shellQuote(dev))
end
local function detachDevice(dev)
noctalia.runAsync("noctalia msg plugin aristides/udiskie:service all detach " .. shellQuote(dev))
end
local function openPath(path)
noctalia.runAsync("noctalia msg plugin aristides/udiskie:service all open " .. shellQuote(path))
panel.close()
end
local function getData()
local data = noctalia.state.get("udiskie_devices")
if type(data) ~= "table" then
return { drives = {}, standalone = {}, raw = {} }
end
return data
end
local function formatSize(bytes)
local b = tonumber(bytes)
if not b or b <= 0 then return "" end
local units = { "B", "KB", "MB", "GB", "TB" }
local i = 1
while b >= 1000 and i < #units do
b = b / 1000
i = i + 1
end
return string.format("%.1f %s", b, units[i])
end
local function renderPartitionRow(part)
local statusText = part.mounted and noctalia.tr("status_mounted") or (part.isLuks and noctalia.tr("status_locked") or noctalia.tr("status_unmounted"))
local statusColor = part.mounted and "primary" or (part.isLuks and "error" or "on_surface_variant")
local statusGlyph = part.mounted and "circle-check" or (part.isLuks and "lock" or "circle")
local usageText = part.device
if part.mounted and part.usedSize and part.usedSize > 0 then
local usedStr = formatSize(part.usedSize)
if usedStr ~= "" then
local pct = math.floor((part.usedSize / (part.size > 0 and part.size or 1)) * 100)
usageText = usageText .. " · " .. usedStr .. " used (" .. tostring(pct) .. "%)"
end
end
local actions = {}
if part.mounted then
if part.mountPath ~= "" then
table.insert(actions, ui.button({
glyph = "copy",
tooltip = noctalia.tr("copy_path_tooltip"),
variant = "ghost",
controlSize = "sm",
onClick = function()
noctalia.copyToClipboard(part.mountPath, "text/plain;charset=utf-8")
noctalia.notify(noctalia.tr("copy_path_tooltip"), noctalia.tr("notify_path_copied"))
end,
}))
table.insert(actions, ui.button({
text = noctalia.tr("btn_open"),
glyph = "folder",
variant = "ghost",
controlSize = "sm",
onClick = function() openPath(part.mountPath) end,
}))
end
table.insert(actions, ui.button({
glyph = "player-stop",
tooltip = noctalia.tr("btn_unmount"),
variant = "ghost",
controlSize = "sm",
onClick = function() unmountDevice(part.device) end,
}))
else
if part.isLuks then
table.insert(actions, ui.button({
text = noctalia.tr("btn_unlock"),
glyph = "lock-open",
variant = "ghost",
controlSize = "sm",
onClick = function() mountDevice(part.device) end,
}))
else
table.insert(actions, ui.button({
text = noctalia.tr("btn_mount"),
glyph = "player-play",
variant = "ghost",
controlSize = "sm",
onClick = function() mountDevice(part.device) end,
}))
end
end
return ui.row({
align = "center",
justify = "space_between",
fill = "surface_variant/0.2",
radius = 6,
paddingV = 6,
paddingH = 8,
gap = 12,
height = 52,
}, {
ui.column({ gap = 2, flexGrow = 1, justify = "center" }, {
ui.row({ gap = 8, align = "center" }, {
ui.glyph({ name = statusGlyph, size = 14, color = statusColor }),
ui.label({ text = part.label, fontWeight = "bold", fontSize = 13, maxLines = 1 }),
ui.row({
align = "center",
fill = statusColor .. "/0.15",
radius = 4,
paddingV = 1,
paddingH = 6,
}, {
ui.label({ text = statusText, color = statusColor, fontSize = 10, fontWeight = "medium" }),
}),
}),
ui.label({
text = usageText,
color = "on_surface_variant",
fontSize = 11,
maxLines = 1,
}),
}),
ui.row({ gap = 4, align = "center" }, actions),
})
end
local function renderDriveCard(drvItem)
local drv = drvItem.drive
local parts = drvItem.partitions or {}
local sizeStr = formatSize(drv.size)
local driveHeaderActions = {
ui.button({
text = noctalia.tr("btn_eject"),
glyph = "player-eject",
variant = "outline",
controlSize = "sm",
onClick = function() ejectDevice(drv.device) end,
}),
ui.button({
glyph = "plug-off",
tooltip = noctalia.tr("btn_poweroff"),
variant = "destructive",
controlSize = "sm",
onClick = function() detachDevice(drv.device) end,
}),
}
local partRows = {}
if #parts == 0 then
table.insert(partRows, ui.label({ text = noctalia.tr("no_partitions_found"), color = "on_surface_variant", fontSize = 12 }))
else
for _, p in ipairs(parts) do
table.insert(partRows, renderPartitionRow(p))
end
end
return ui.column({
fill = "surface_variant/0.3",
radius = 8,
padding = 12,
gap = 10,
}, {
-- Drive Header
ui.row({ align = "center", justify = "space_between" }, {
ui.row({ gap = 8, align = "center" }, {
ui.glyph({ name = "device-usb", size = 18, color = "primary" }),
ui.column({ gap = 2 }, {
ui.label({ text = drv.label, fontWeight = "bold", fontSize = 14, maxLines = 1 }),
ui.label({
text = drv.device .. (sizeStr ~= "" and (" · " .. sizeStr) or ""),
color = "on_surface_variant",
fontSize = 11,
}),
}),
}),
ui.row({ gap = 4, align = "center" }, driveHeaderActions),
}),
ui.separator({ thickness = 1, color = "surface_variant/0.4" }),
-- Partition List
ui.column({ gap = 6 }, partRows),
})
end
local function renderHeaderRow()
return ui.row({ align = "center", paddingV = 4 }, {
ui.row({ gap = 8, align = "center" }, {
ui.glyph({ name = "device-usb", size = 20, color = "primary" }),
ui.label({ text = noctalia.tr("panel_title"), fontSize = 16, fontWeight = "bold" }),
}),
ui.spacer(),
ui.row({ gap = 6 }, {
ui.button({
glyph = "settings",
variant = "ghost",
controlSize = "sm",
onClick = function()
noctalia.openSettings()
end,
}),
ui.button({
text = noctalia.tr("btn_mount_all"),
glyph = "link",
variant = "secondary",
controlSize = "sm",
onClick = function() noctalia.runAsync("noctalia msg plugin aristides/udiskie:service all mount_all") end,
}),
ui.button({
glyph = "unlink",
tooltip = noctalia.tr("btn_unmount_all"),
variant = "destructive",
controlSize = "sm",
onClick = function() noctalia.runAsync("noctalia msg plugin aristides/udiskie:service all unmount_all") end,
}),
}),
})
end
local function render()
local data = getData()
local children = {}
table.insert(children, renderHeaderRow())
table.insert(children, ui.separator({ thickness = 1, color = "surface_variant" }))
local drives = data.drives or {}
local standalone = data.standalone or {}
if data.error then
table.insert(children, ui.column({ align = "center", justify = "center", paddingV = 24, gap = 12 }, {
ui.glyph({ name = "alert-triangle", size = 36, color = "warning" }),
ui.label({ text = noctalia.tr("error_missing_dep"), color = "on_surface" }),
ui.row({ gap = 8, align = "center" }, {
ui.button({
text = noctalia.tr("btn_retry"),
glyph = "refresh",
variant = "outline",
controlSize = "sm",
onClick = function()
noctalia.runAsync("noctalia msg plugin aristides/udiskie:service all refresh")
end,
}),
ui.button({
text = noctalia.tr("btn_docs"),
glyph = "external-link",
variant = "ghost",
controlSize = "sm",
onClick = function()
noctalia.runAsync("xdg-open 'https://github.com/coldfix/udiskie/wiki/Installation'")
end,
}),
}),
}))
elseif #drives == 0 and #standalone == 0 then
table.insert(children, ui.column({ align = "center", justify = "center", paddingV = 24, gap = 8 }, {
ui.glyph({ name = "device-floppy", size = 32, color = "on_surface_variant/0.5" }),
ui.label({ text = noctalia.tr("panel_empty"), color = "on_surface_variant" }),
}))
else
local cardItems = {}
for _, drvItem in ipairs(drives) do
table.insert(cardItems, renderDriveCard(drvItem))
end
for _, p in ipairs(standalone) do
table.insert(cardItems, renderPartitionRow(p))
end
table.insert(children, ui.scroll({ flexGrow = 1, gap = 10 }, cardItems))
end
panel.render(ui.column({ flexGrow = 1, gap = 12, padding = 16, align = "stretch" }, children))
end
function onOpen()
render()
end
noctalia.state.watch("udiskie_devices", function()
render()
end)
render()
+70
View File
@@ -0,0 +1,70 @@
# Udiskie Manager: bar widget, service, and panel for managing USB drives and media.
# A service monitors udiskie events in real time and publishes to shared state.
# The bar widget renders mount count and the panel provides full device actions.
id = "aristides/udiskie"
name = "Udiskie Manager"
version = "0.1.0"
plugin_api = 9
author = "aristides"
license = "MIT"
dependencies = ["udiskie", "udisks2", "xdg-open"]
tags = ["bar", "hardware", "panel", "service", "system", "utility"]
icon = "device-usb"
description = "Manage USB drives and media with notifications in just one panel."
[[setting]]
key = "enable_notifications"
type = "bool"
label_key = "settings.enable_notifications.label"
description_key = "settings.enable_notifications.description"
default = true
[[setting]]
key = "auto_open_filemanager"
type = "bool"
label_key = "settings.auto_open_filemanager.label"
description_key = "settings.auto_open_filemanager.description"
default = false
[[setting]]
key = "file_manager_cmd"
type = "string"
label_key = "settings.file_manager_cmd.label"
description_key = "settings.file_manager_cmd.description"
default = "xdg-open"
[[service]]
id = "service"
entry = "service.luau"
[[widget]]
id = "status"
entry = "status.luau"
[[widget.setting]]
key = "glyph"
type = "glyph"
label_key = "settings.glyph.label"
description_key = "settings.glyph.description"
default = "device-usb"
[[widget.setting]]
key = "show_count"
type = "bool"
label_key = "settings.show_count.label"
description_key = "settings.show_count.description"
default = true
[[widget.setting]]
key = "hide_when_empty"
type = "bool"
label_key = "settings.hide_when_empty.label"
description_key = "settings.hide_when_empty.description"
default = false
[[panel]]
id = "manager"
entry = "panel.luau"
width = 600
height = 450
+263
View File
@@ -0,0 +1,263 @@
--!nonstrict
-- Udiskie service: monitors `udisksctl monitor` in real time, publishes device.
-- state to shared state (`udiskie_devices`), handles notifications, and processes IPC.
--
-- External commands: udiskie-info, udiskie-mount, udiskie-umount, udisksctl, xdg-open.
local prevMountedDevices = {}
local prevAllDevices = {}
local isInitialFetch = true
local function isTrue(val)
return type(val) == "string" and val:lower() == "true"
end
local function shellQuote(value)
return "'" .. tostring(value):gsub("'", "'\\''") .. "'"
end
local function openWithFileManager(path)
local cmd = noctalia.getConfig("file_manager_cmd") or "xdg-open"
noctalia.runAsync(cmd .. " " .. shellQuote(path))
end
local function parseUdiskieOutput(stdout)
local rawDevices = {}
local currentMounted = {}
if not stdout then
return rawDevices, currentMounted
end
for line in stdout:gmatch("[^\r\n]+") do
local dev, label, mounted, mountPath, isLuks, isDrive, isPartition, isFilesystem, isDetachable, isEjectable, inUse, deviceSize = line:match("^([^\t]*)\t([^\t]*)\t([^\t]*)\t([^\t]*)\t([^\t]*)\t([^\t]*)\t([^\t]*)\t([^\t]*)\t([^\t]*)\t([^\t]*)\t([^\t]*)\t(.*)$")
if dev ~= nil and dev ~= "" then
local cleanLabel = (label ~= nil and label ~= "") and label or dev
cleanLabel = cleanLabel:gsub("^[^:]+:%s*", "")
local isM = isTrue(mounted)
local devObj = {
device = dev,
label = cleanLabel,
mounted = isM,
mountPath = mountPath or "",
isLuks = isTrue(isLuks),
isDrive = isTrue(isDrive),
isPartition = isTrue(isPartition),
isFilesystem = isTrue(isFilesystem),
isDetachable = isTrue(isDetachable),
isEjectable = isTrue(isEjectable),
inUse = isTrue(inUse),
size = tonumber(deviceSize) or 0,
}
table.insert(rawDevices, devObj)
if isM then
currentMounted[dev] = devObj.label
end
end
end
return rawDevices, currentMounted
end
local function buildHierarchy(rawDevices)
local drives = {}
local standalonePartitions = {}
for _, d in ipairs(rawDevices) do
if not d.isPartition and not d.isFilesystem then
table.insert(drives, { drive = d, partitions = {} })
end
end
for _, d in ipairs(rawDevices) do
if d.isPartition or d.isFilesystem or d.isLuks then
local parentFound = false
for _, drvItem in ipairs(drives) do
if d.device:find("^" .. drvItem.drive.device .. "[%dp]") then
table.insert(drvItem.partitions, d)
parentFound = true
break
end
end
if not parentFound then
table.insert(standalonePartitions, d)
end
end
end
return {
drives = drives,
standalone = standalonePartitions,
raw = rawDevices,
}
end
local function processNotifications(rawDevices, currentMounted)
local enableNotifs = noctalia.getConfig("enable_notifications")
if enableNotifs == false then
return
end
local currentAllDevs = {}
for _, d in ipairs(rawDevices) do
-- Physical drives only: LUKS containers are excluded so the "Drive
-- Connected" notification fires once for the underlying physical disk,
-- not again for the encrypted volume.
if not d.isLuks and (d.isDrive or (not d.isPartition and not d.isFilesystem)) then
currentAllDevs[d.device] = d.label
end
end
-- 1. Device Insertion (Physical USB plugged in)
if not isInitialFetch then
for dev, label in pairs(currentAllDevs) do
if not prevAllDevices[dev] then
noctalia.notify(noctalia.tr("notif_drive_connected_title"), noctalia.tr("notif_drive_connected_body", { label = label }))
end
end
-- 2. Device Removal (Physical USB unplugged / powered off)
for dev, label in pairs(prevAllDevices) do
if not currentAllDevs[dev] then
noctalia.notify(noctalia.tr("notif_drive_removed_title"), noctalia.tr("notif_drive_removed_body", { label = label }))
end
end
end
-- 3. Device Mounted
for dev, label in pairs(currentMounted) do
if not prevMountedDevices[dev] then
noctalia.notify(noctalia.tr("notif_drive_mounted_title"), noctalia.tr("notif_drive_mounted_body", { label = label }))
if noctalia.getConfig("auto_open_filemanager") == true then
for _, d in ipairs(rawDevices) do
if d.device == dev and d.mountPath ~= "" then
openWithFileManager(d.mountPath)
break
end
end
end
end
end
-- 4. Device Unmounted
for dev, label in pairs(prevMountedDevices) do
if not currentMounted[dev] and currentAllDevs[dev] then
noctalia.notify(noctalia.tr("notif_drive_unmounted_title"), noctalia.tr("notif_drive_unmounted_body", { label = label }))
end
end
prevAllDevices = currentAllDevs
prevMountedDevices = currentMounted
isInitialFetch = false
end
local function fetchDevices()
noctalia.runAsync("udiskie-info -a -o \"{device_file}\t{ui_label}\t{is_mounted}\t{mount_path}\t{is_luks}\t{is_drive}\t{is_partition}\t{is_filesystem}\t{is_detachable}\t{is_ejectable}\t{in_use}\t{device_size}\"", function(res)
if not res or res.exitCode ~= 0 then
noctalia.state.set("udiskie_devices", { drives = {}, standalone = {}, raw = {}, error = "missing_dep" })
return
end
local rawDevices, currentMounted = parseUdiskieOutput(res.stdout)
local structured = buildHierarchy(rawDevices)
structured.error = nil
processNotifications(rawDevices, currentMounted)
-- Fetch df disk usage for mounted partitions
noctalia.runAsync("df -B1 --output=target,used 2>/dev/null", function(dfRes)
if dfRes and dfRes.exitCode == 0 and dfRes.stdout then
local usageMap = {}
for line in dfRes.stdout:gmatch("[^\r\n]+") do
local target, used = line:match("^(%S+)%s+(%d+)$")
if target and used then
usageMap[target] = tonumber(used)
end
end
for _, d in ipairs(rawDevices) do
if d.mounted and d.mountPath ~= "" and usageMap[d.mountPath] then
d.usedSize = usageMap[d.mountPath]
end
end
end
noctalia.state.set("udiskie_devices", structured)
end)
end, 5000)
end
-- Initial fetch on startup
noctalia.state.set("udiskie_devices", {})
fetchDevices()
-- Event-driven streaming using udisksctl monitor
noctalia.runStream("udisksctl monitor 2>/dev/null", function(line)
if line:find("Added") or line:find("PropertiesChanged") or line:find("Removed") then
fetchDevices()
end
end)
local function cleanError(stderr, fallback)
if not stderr or stderr == "" then
return fallback
end
-- upstream formats errors as "failed to <verb> <device>: <message>").
local clean = stderr:gsub("failed to %w+ [^:]+:%s*", "")
-- Strip GDBus error identifiers like "GDBus.Error:org.freedesktop.UDisks2.Error.DeviceBusy:"
clean = clean:gsub("GDBus%.Error:[%w%.]+:%s*", "")
-- Clean leading/trailing whitespace
clean = clean:gsub("^%s*", ""):gsub("%s*$", "")
return clean ~= "" and clean or fallback
end
-- IPC Handlers for Panel and Shortcuts
function onIpc(event, payload)
if event == "mount" and payload then
noctalia.runAsync("udiskie-mount -r " .. shellQuote(payload), function(res)
if res and res.exitCode ~= 0 and noctalia.getConfig("enable_notifications") ~= false then
noctalia.notifyError(noctalia.tr("notif_mount_failed_title"), cleanError(res.stderr, noctalia.tr("notif_mount_failed_fallback", { device = payload })))
end
fetchDevices()
end, 30000)
elseif event == "mount_all" then
noctalia.runAsync("udiskie-mount -a", function(res)
if res and res.exitCode ~= 0 and noctalia.getConfig("enable_notifications") ~= false then
noctalia.notifyError(noctalia.tr("notif_mount_all_failed_title"), cleanError(res and res.stderr, noctalia.tr("notif_mount_all_failed_fallback")))
end
fetchDevices()
end, 10000)
elseif event == "unmount" and payload then
noctalia.runAsync("udiskie-umount " .. shellQuote(payload), function(res)
if res and res.exitCode ~= 0 and noctalia.getConfig("enable_notifications") ~= false then
noctalia.notifyError(noctalia.tr("notif_unmount_failed_title"), cleanError(res.stderr, noctalia.tr("notif_unmount_failed_fallback", { device = payload })))
end
fetchDevices()
end, 10000)
elseif event == "unmount_all" then
noctalia.runAsync("udiskie-umount -a", function(res)
if res and res.exitCode ~= 0 and noctalia.getConfig("enable_notifications") ~= false then
noctalia.notifyError(noctalia.tr("notif_unmount_all_failed_title"), cleanError(res and res.stderr, noctalia.tr("notif_unmount_all_failed_fallback")))
end
fetchDevices()
end, 10000)
elseif event == "eject" and payload then
noctalia.runAsync("udiskie-umount -e " .. shellQuote(payload), function(res)
if res and res.exitCode ~= 0 and noctalia.getConfig("enable_notifications") ~= false then
noctalia.notifyError(noctalia.tr("notif_eject_failed_title"), cleanError(res.stderr, noctalia.tr("notif_eject_failed_fallback", { device = payload })))
end
fetchDevices()
end, 10000)
elseif event == "detach" and payload then
noctalia.runAsync("udiskie-umount -d " .. shellQuote(payload), function(res)
if res and res.exitCode ~= 0 and noctalia.getConfig("enable_notifications") ~= false then
noctalia.notifyError(noctalia.tr("notif_poweroff_failed_title"), cleanError(res.stderr, noctalia.tr("notif_poweroff_failed_fallback", { device = payload })))
end
fetchDevices()
end)
elseif event == "open" and payload then
openWithFileManager(payload)
elseif event == "refresh" then
fetchDevices()
end
end
+79
View File
@@ -0,0 +1,79 @@
--!nonstrict
-- Udiskie bar widget: renders the mounted-device count and a tooltip listing.
-- the mounted devices. State comes from the udiskie service via shared state.
local function mountedDevices(data)
if type(data) ~= "table" or type(data.raw) ~= "table" then
return {}
end
local out = {}
for _, d in ipairs(data.raw) do
if d.mounted then
table.insert(out, d)
end
end
return out
end
local function render()
local data = noctalia.state.get("udiskie_devices")
local mounted = mountedDevices(data)
local glyph = noctalia.getConfig("glyph") or "device-usb"
local showCount = noctalia.getConfig("show_count")
barWidget.setGlyph(glyph)
if type(data) == "table" and data.error then
barWidget.setGlyphColor("#f59e0b")
barWidget.setText("")
barWidget.setTooltip(noctalia.tr("error_missing_dep"))
return
else
-- Reset to the theme default. The API does not document "" as a reset;
-- today it clears the role/color. Revisit if the host ever rejects it.
barWidget.setGlyphColor("")
barWidget.setColor("")
end
local hideWhenEmpty = noctalia.getConfig("hide_when_empty") == true
local rawCount = (type(data) == "table" and type(data.raw) == "table") and #data.raw or 0
if hideWhenEmpty and rawCount == 0 then
barWidget.setVisible(false)
return
else
barWidget.setVisible(true)
end
if #mounted == 0 then
barWidget.setText("")
barWidget.setTooltip(noctalia.tr("tooltip_empty"))
return
end
if showCount == nil or showCount == true then
barWidget.setText(tostring(#mounted))
else
barWidget.setText("")
end
local rows = {}
for _, d in ipairs(mounted) do
table.insert(rows, d.label .. (d.mountPath ~= "" and (" (" .. d.mountPath .. ")") or ""))
end
barWidget.setTooltip(noctalia.tr("tooltip_mounted") .. "\n" .. table.concat(rows, "\n"))
end
noctalia.state.watch("udiskie_devices", function()
render()
end)
function onClick()
noctalia.togglePanel("aristides/udiskie:manager")
end
function onRightClick()
noctalia.runAsync("noctalia msg plugin aristides/udiskie:service all refresh")
end
render()
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env python3
import json
import os
import sys
def main():
plugin_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
trans_dir = os.path.join(plugin_dir, "translations")
en_file = os.path.join(trans_dir, "en.json")
if not os.path.exists(en_file):
print(f"Error: Translation file not found at {en_file}")
sys.exit(1)
def flatten_keys(d, prefix=""):
keys = []
for k, v in d.items():
full_key = f"{prefix}.{k}" if prefix else k
if isinstance(v, dict):
keys.extend(flatten_keys(v, full_key))
else:
keys.append(full_key)
return keys
with open(en_file, "r", encoding="utf-8") as f:
en_translations = json.load(f)
en_keys = set(flatten_keys(en_translations))
errors = []
# Code and manifest files to scan (checked against en.json, the reference).
scan_files = ["plugin.toml", "service.luau", "status.luau", "panel.luau"]
combined_content = ""
for fname in scan_files:
fpath = os.path.join(plugin_dir, fname)
if os.path.exists(fpath):
with open(fpath, "r", encoding="utf-8") as f:
combined_content += f.read() + "\n"
# 1. Every en.json key must be used somewhere in the codebase.
missing_keys = []
for key in sorted(en_keys):
# Setting label_key and description_key append .label and .description automatically.
base_key = key.replace(".label", "").replace(".description", "")
if key not in combined_content and base_key not in combined_content:
missing_keys.append(key)
if missing_keys:
errors.append("Unused translation key(s) found in translations/en.json:\n - " + "\n - ".join(missing_keys))
# 2. Every other translation file must have exactly the same keys as en.json.
for fname in sorted(os.listdir(trans_dir)):
if not fname.endswith(".json") or fname == "en.json":
continue
fpath = os.path.join(trans_dir, fname)
with open(fpath, "r", encoding="utf-8") as f:
try:
other = json.load(f)
except json.JSONDecodeError as e:
errors.append(f"Invalid JSON in translations/{fname}: {e}")
continue
other_keys = set(flatten_keys(other))
missing = sorted(en_keys - other_keys)
extra = sorted(other_keys - en_keys)
if missing:
errors.append(f"translations/{fname} is missing key(s):\n - " + "\n - ".join(missing))
if extra:
errors.append(f"translations/{fname} has extra key(s) not in en.json:\n - " + "\n - ".join(extra))
if errors:
for e in errors:
print(e)
sys.exit(1)
other_count = len([f for f in os.listdir(trans_dir) if f.endswith(".json") and f != "en.json"])
print(f"✓ All {len(en_keys)} translation keys in translations/en.json are active and used in codebase.")
if other_count:
print(f"✓ {other_count} other translation file(s) match the en.json key set exactly.")
if __name__ == "__main__":
main()
Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

+69
View File
@@ -0,0 +1,69 @@
{
"settings": {
"enable_notifications": {
"label": "Desktop notifications",
"description": "Show native Noctalia desktop notifications on device events."
},
"auto_open_filemanager": {
"label": "Auto-open file manager",
"description": "Automatically open mounted drives in file manager."
},
"file_manager_cmd": {
"label": "File manager command",
"description": "Command used to open mounted folders."
},
"glyph": {
"label": "Bar glyph",
"description": "The glyph shown for the Udiskie widget on the bar."
},
"show_count": {
"label": "Show device count",
"description": "Display count of mounted devices on the bar widget."
},
"hide_when_empty": {
"label": "Hide when empty",
"description": "Hide the bar widget when no USB drives are connected."
}
},
"tooltip_empty": "No devices mounted",
"tooltip_mounted": "Mounted devices",
"panel_title": "Udiskie Manager",
"panel_empty": "No USB drives detected",
"btn_mount_all": "Mount All",
"btn_unmount_all": "Unmount All",
"btn_mount": "Mount",
"btn_unlock": "Unlock",
"btn_unmount": "Unmount",
"btn_eject": "Eject",
"btn_poweroff": "Power Off",
"btn_open": "Open",
"status_mounted": "Mounted",
"status_unmounted": "Unmounted",
"status_locked": "Locked (LUKS)",
"error_missing_dep": "udiskie binary is missing from PATH",
"btn_retry": "Retry Connection",
"btn_docs": "Check Documentation",
"copy_path_tooltip": "Copy mount path",
"notify_path_copied": "Path copied to clipboard",
"no_partitions_found": "No partitions found",
"notif_drive_connected_title": "Drive Connected",
"notif_drive_connected_body": "{label} connected",
"notif_drive_removed_title": "Drive Removed",
"notif_drive_removed_body": "{label} disconnected",
"notif_drive_mounted_title": "Drive Mounted",
"notif_drive_mounted_body": "{label} is ready to use",
"notif_drive_unmounted_title": "Drive Unmounted",
"notif_drive_unmounted_body": "{label} was safely unmounted",
"notif_mount_failed_title": "Mount Failed",
"notif_mount_failed_fallback": "Could not mount {device}",
"notif_mount_all_failed_title": "Mount All Failed",
"notif_mount_all_failed_fallback": "Could not mount all devices",
"notif_unmount_failed_title": "Unmount Failed",
"notif_unmount_failed_fallback": "Device {device} is busy or in use",
"notif_unmount_all_failed_title": "Unmount All Failed",
"notif_unmount_all_failed_fallback": "One or more devices are busy",
"notif_eject_failed_title": "Eject Failed",
"notif_eject_failed_fallback": "Could not eject {device}",
"notif_poweroff_failed_title": "Power Off Failed",
"notif_poweroff_failed_fallback": "Could not power off {device}"
}
+69
View File
@@ -0,0 +1,69 @@
{
"settings": {
"enable_notifications": {
"label": "Notificaciones de escritorio",
"description": "Mostrar notificaciones nativas de escritorio de Noctalia en los eventos de dispositivos."
},
"auto_open_filemanager": {
"label": "Abrir gestor de archivos automáticamente",
"description": "Abrir automáticamente los dispositivos montados en el gestor de archivos."
},
"file_manager_cmd": {
"label": "Comando del gestor de archivos",
"description": "Comando usado para abrir las carpetas montadas."
},
"glyph": {
"label": "Icono de la barra",
"description": "El icono que se muestra para el widget de Udiskie en la barra."
},
"show_count": {
"label": "Mostrar número de dispositivos",
"description": "Mostrar el número de dispositivos montados en el widget de la barra."
},
"hide_when_empty": {
"label": "Ocultar cuando esté vacío",
"description": "Ocultar el widget de la barra cuando no haya unidades USB conectadas."
}
},
"tooltip_empty": "No hay dispositivos montados",
"tooltip_mounted": "Dispositivos montados",
"panel_title": "Udiskie Manager",
"panel_empty": "No se detectaron unidades USB",
"btn_mount_all": "Montar todo",
"btn_unmount_all": "Desmontar todo",
"btn_mount": "Montar",
"btn_unlock": "Desbloquear",
"btn_unmount": "Desmontar",
"btn_eject": "Expulsar",
"btn_poweroff": "Apagar",
"btn_open": "Abrir",
"status_mounted": "Montado",
"status_unmounted": "Desmontado",
"status_locked": "Bloqueado (LUKS)",
"error_missing_dep": "El binario udiskie no está en el PATH",
"btn_retry": "Reintentar conexión",
"btn_docs": "Consultar documentación",
"copy_path_tooltip": "Copiar ruta de montaje",
"notify_path_copied": "Ruta copiada al portapapeles",
"no_partitions_found": "No se encontraron particiones",
"notif_drive_connected_title": "Unidad conectada",
"notif_drive_connected_body": "{label} se ha conectado",
"notif_drive_removed_title": "Unidad extraída",
"notif_drive_removed_body": "{label} se ha desconectado",
"notif_drive_mounted_title": "Unidad montada",
"notif_drive_mounted_body": "{label} está listo para usar",
"notif_drive_unmounted_title": "Unidad desmontada",
"notif_drive_unmounted_body": "{label} se desmontó de forma segura",
"notif_mount_failed_title": "Error al montar",
"notif_mount_failed_fallback": "No se pudo montar {device}",
"notif_mount_all_failed_title": "Error al montar todo",
"notif_mount_all_failed_fallback": "No se pudieron montar todos los dispositivos",
"notif_unmount_failed_title": "Error al desmontar",
"notif_unmount_failed_fallback": "El dispositivo {device} está ocupado o en uso",
"notif_unmount_all_failed_title": "Error al desmontar todo",
"notif_unmount_all_failed_fallback": "Uno o más dispositivos están ocupados",
"notif_eject_failed_title": "Error al expulsar",
"notif_eject_failed_fallback": "No se pudo expulsar {device}",
"notif_poweroff_failed_title": "Error al apagar",
"notif_poweroff_failed_fallback": "No se pudo apagar {device}"
}