Files
community-plugins/udiskie/service.luau
T
Arístides PérezandGitHub 57e4b108c4 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
2026-08-05 17:58:15 -04:00

264 lines
10 KiB
Luau

--!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