diff --git a/udiskie/Makefile b/udiskie/Makefile new file mode 100644 index 0000000..54e8dc7 --- /dev/null +++ b/udiskie/Makefile @@ -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 diff --git a/udiskie/README.md b/udiskie/README.md new file mode 100644 index 0000000..7feb5e8 --- /dev/null +++ b/udiskie/README.md @@ -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. diff --git a/udiskie/panel.luau b/udiskie/panel.luau new file mode 100644 index 0000000..2aa798f --- /dev/null +++ b/udiskie/panel.luau @@ -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() diff --git a/udiskie/plugin.toml b/udiskie/plugin.toml new file mode 100644 index 0000000..98bd490 --- /dev/null +++ b/udiskie/plugin.toml @@ -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 diff --git a/udiskie/service.luau b/udiskie/service.luau new file mode 100644 index 0000000..236bff6 --- /dev/null +++ b/udiskie/service.luau @@ -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 : "). + 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 diff --git a/udiskie/status.luau b/udiskie/status.luau new file mode 100644 index 0000000..92c5460 --- /dev/null +++ b/udiskie/status.luau @@ -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() + diff --git a/udiskie/tests/check_translations.py b/udiskie/tests/check_translations.py new file mode 100755 index 0000000..7a6994c --- /dev/null +++ b/udiskie/tests/check_translations.py @@ -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() \ No newline at end of file diff --git a/udiskie/thumbnail.webp b/udiskie/thumbnail.webp new file mode 100644 index 0000000..b44e334 Binary files /dev/null and b/udiskie/thumbnail.webp differ diff --git a/udiskie/translations/en.json b/udiskie/translations/en.json new file mode 100644 index 0000000..3824347 --- /dev/null +++ b/udiskie/translations/en.json @@ -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}" +} \ No newline at end of file diff --git a/udiskie/translations/es.json b/udiskie/translations/es.json new file mode 100644 index 0000000..96856bc --- /dev/null +++ b/udiskie/translations/es.json @@ -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}" +}