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:
@@ -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
|
||||
@@ -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.
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
Executable
+82
@@ -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 |
@@ -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}"
|
||||
}
|
||||
@@ -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}"
|
||||
}
|
||||
Reference in New Issue
Block a user