From ab9783934bbd0576169f6f3c865978c970a9b4e9 Mon Sep 17 00:00:00 2001 From: rylos Date: Tue, 21 Jul 2026 10:12:36 +0200 Subject: [PATCH 1/9] syncthing: add Syncthing status & control plugin Port of the v4 syncthing-status plugin (by Pir0c0pter0) to the v5 Luau plugin API. Talks to the Syncthing REST API directly via noctalia.http (no helper scripts). Bar widget, Folders|Devices panel with per-folder and per-device pause/resume and rescan, sync progress, transfer rates, event notifications, /st launcher provider, global-pause shortcut, and a desktop widget. en + it translations. Co-Authored-By: Claude Fable 5 --- syncthing/README.md | 93 +++++ syncthing/bar.luau | 161 +++++++++ syncthing/desktop.luau | 87 +++++ syncthing/icon-light.svg | 25 ++ syncthing/icon.svg | 25 ++ syncthing/launcher.luau | 59 ++++ syncthing/panel.luau | 497 ++++++++++++++++++++++++++ syncthing/plugin.toml | 112 ++++++ syncthing/service.luau | 623 +++++++++++++++++++++++++++++++++ syncthing/shortcut.luau | 43 +++ syncthing/thumbnail.webp | Bin 0 -> 5294 bytes syncthing/translations/en.json | 77 ++++ syncthing/translations/it.json | 77 ++++ 13 files changed, 1879 insertions(+) create mode 100644 syncthing/README.md create mode 100644 syncthing/bar.luau create mode 100644 syncthing/desktop.luau create mode 100644 syncthing/icon-light.svg create mode 100644 syncthing/icon.svg create mode 100644 syncthing/launcher.luau create mode 100644 syncthing/panel.luau create mode 100644 syncthing/plugin.toml create mode 100644 syncthing/service.luau create mode 100644 syncthing/shortcut.luau create mode 100644 syncthing/thumbnail.webp create mode 100644 syncthing/translations/en.json create mode 100644 syncthing/translations/it.json diff --git a/syncthing/README.md b/syncthing/README.md new file mode 100644 index 00000000..950aa99b --- /dev/null +++ b/syncthing/README.md @@ -0,0 +1,93 @@ +# Syncthing + +Syncthing status and control for Noctalia. Shows the sync state in the bar, +gives you a Syncthing Tray-style panel with folder and device lists, per-folder +and per-device pause/resume, rescan, sync progress, transfer rates, and desktop +notifications on sync events. A port of the v4 `syncthing-status` plugin (by +Pir0c0pter0) to the v5 Luau plugin API, talking to the Syncthing REST API +directly — no helper scripts. + +## Plugin + +| Field | Value | +| --- | --- | +| ID | `rylos/syncthing` | +| Entries | Bar widget: `bar`; panel: `panel`; service: `poller`; shortcut: `pause`; launcher: `folders`; desktop widget: `desktop` | +| Launcher Prefix | `/st` | + +## Requirements + +Install `syncthing` and have it running for this user. The plugin autodetects +the GUI URL and API key from `~/.local/state/syncthing/config.xml` (or +`~/.config/syncthing/config.xml`), so it usually works with zero configuration. + +## Usage + +- **Bar widget** (`bar`): add it from the Add-widget picker. The Syncthing logo + is dimmed/badged by state (syncing, paused, error, offline, …) and shows the + overall completion percentage while syncing. Left click opens the panel, + right click forces a refresh. The tooltip shows devices, folders, pending + items, transfer rates, and the last check time. +- **Panel** (`panel`): header with pause-all/resume-all, open web GUI, and + refresh buttons; stat tiles (devices / folders / pending); **Folders | + Devices** tabs. Folder rows show state, sync progress, and last activity, + with per-folder pause/resume and rescan buttons. Device rows show connection + state, address, and client version, with per-device pause/resume. + + ```sh + noctalia msg panel-toggle rylos/syncthing:panel + ``` + +- **Launcher** (`/st`): type `/st ` to fuzzy-search monitored folders; + activating a result triggers a rescan of that folder. +- **Shortcut** (`pause`): add it from Settings → Control Center shortcuts. It + toggles Syncthing's global pause (pause/resume all devices). +- **Desktop widget** (`desktop`): add it from the desktop-widgets editor. A + compact card with state, counters, sync progress, and transfer rates. +- **Notifications**: by default the service notifies when a folder finishes + syncing, reports an error, or a device connects/disconnects. Disable with + the **Event notifications** setting. + +## Settings + +| Setting | Type | Default | Description | +| --- | --- | --- | --- | +| `url` | `string` | `""` | Syncthing GUI base URL. Empty = autodetect from config.xml. | +| `api_key` | `string` | `""` | REST API key. Empty = autodetect from config.xml. | +| `config_path` | `file` | `""` | Explicit config.xml path for autodetection (advanced). | +| `poll_interval` | `int` | `10` | Status refresh interval in seconds (2–300). | +| `notify_events` | `bool` | `true` | Desktop notifications for folder done/error and device connect/disconnect. | +| `folders` | `string_list` | `[]` | Folder IDs to monitor. Empty = all folders (advanced). | +| `insecure_tls` | `bool` | `false` | Accept self-signed certificates on HTTPS GUI URLs (advanced; uses `curl -k`). | +| `show_pending` | `bool` | `true` | Bar widget: show completion %/pending count while syncing. | + +## IPC + +```sh +noctalia msg plugin rylos/syncthing:poller all refresh # force a status refresh +noctalia msg plugin rylos/syncthing:poller all pause # pause all devices +noctalia msg plugin rylos/syncthing:poller all resume # resume all devices +noctalia msg plugin rylos/syncthing:poller all open # open the web GUI +noctalia msg panel-toggle rylos/syncthing:panel +``` + +## Notes + +- **Network**: all requests go to the local (or configured) Syncthing REST API + via `noctalia.http` — `/rest/noauth/health`, `/rest/system/status`, + `/rest/config`, `/rest/system/connections`, `/rest/system/error`, + `/rest/stats/folder`, `/rest/db/status`, and the action endpoints + (`/rest/config/folders|devices/` PATCH, `/rest/db/scan` POST, + `/rest/system/pause|resume` POST). No other network access. +- **Processes**: `curl` is spawned only when `insecure_tls` is enabled on an + HTTPS URL (noctalia.http cannot skip certificate verification); `gio open` + (fallback `xdg-open`) is spawned by the *Open web GUI* action. +- **Filesystem**: reads Syncthing's `config.xml` for URL/API key autodetection. + Nothing is written. +- The API key never leaves the machine: it is only sent as the `X-API-Key` + header to the Syncthing GUI endpoint it belongs to. + +## Credits + +Based on the v4 [syncthing-status](https://github.com/noctalia-dev/legacy-v4-plugins/tree/main/syncthing-status) +plugin by Pir0c0pter0. MIT license. diff --git a/syncthing/bar.luau b/syncthing/bar.luau new file mode 100644 index 00000000..68b94182 --- /dev/null +++ b/syncthing/bar.luau @@ -0,0 +1,161 @@ +--!nonstrict +-- Syncthing bar widget — the [[widget]] entry. +-- +-- Shows a state-colored cloud glyph, plus the pending item count while +-- syncing (widget setting "show_pending"). The tooltip carries the full +-- summary. Left click opens the detail panel, right click forces a refresh. + +-- Real Syncthing logo, shipped with the plugin: light fill for dark themes, +-- dark fill for light themes. State is conveyed by a small glyph overlay and +-- the pending count, since the image itself cannot be tinted. +local function logo(state) + return ui.image({ + path = noctalia.isDarkMode() and "icon.svg" or "icon-light.svg", + width = 16, + height = 16, + fit = "contain", + opacity = (state == "paused" or state == "unconfigured") and 0.45 or 1, + }) +end + +local BADGES = { + syncing = { glyph = "refresh", color = "secondary" }, + paused = { glyph = "player-pause", color = "outline" }, + disconnected = { glyph = "plug-connected-x", color = "secondary" }, + offline = { glyph = "x", color = "error" }, + unauthorized = { glyph = "lock-exclamation", color = "error" }, + error = { glyph = "alert-triangle", color = "error" }, + unconfigured = { glyph = "question-mark", color = "outline" }, +} + +local COLORS = { + idle = "on_surface", + syncing = "secondary", + paused = "outline", + disconnected = "secondary", + offline = "error", + unauthorized = "error", + error = "error", + unconfigured = "outline", +} + +local snapshot = nil +local cmdNonce = 0 + +local function sendCmd(action) + cmdNonce += 1 + noctalia.state.set("st.cmd", noctalia.json.encode({ n = cmdNonce, action = action })) +end + +local function stateOf() + return snapshot and snapshot.state or "unconfigured" +end + +local function pendingText() + if not snapshot then + return "" + end + -- Prefer the overall completion percentage; fall back to pending counts. + local pct = snapshot.syncPercent or -1 + if pct >= 0 and pct < 100 then + return `{pct}%` + end + local items = snapshot.totals.needItems + if items > 99 then + return "99+" + end + if items > 0 then + return tostring(items) + end + if snapshot.totals.syncingFolders > 0 then + return tostring(snapshot.totals.syncingFolders) + end + return "" +end + +local function formatRate(bytesPerSec) + local units = { "B", "KB", "MB", "GB" } + local value = bytesPerSec + local index = 1 + while value >= 1024 and index < #units do + value /= 1024 + index += 1 + end + local rounded = (value >= 10 or index == 1) and tostring(math.round(value)) or string.format("%.1f", value) + return `{rounded} {units[index]}/s` +end + +local function tooltipRows() + local state = stateOf() + local rows = { + { key = noctalia.tr("tooltip.state"), value = noctalia.tr(`state.{state}`) }, + } + if snapshot then + table.insert(rows, { + key = noctalia.tr("tooltip.devices"), + value = `{snapshot.devices.connected}/{snapshot.devices.configured}`, + }) + table.insert(rows, { + key = noctalia.tr("tooltip.folders"), + value = tostring(snapshot.totals.monitoredFolders), + }) + if snapshot.totals.needItems > 0 then + table.insert(rows, { + key = noctalia.tr("tooltip.pending"), + value = tostring(snapshot.totals.needItems), + }) + end + if (snapshot.rateIn or 0) >= 1024 or (snapshot.rateOut or 0) >= 1024 then + table.insert(rows, { + key = noctalia.tr("tooltip.rates"), + value = `↓ {formatRate(snapshot.rateIn or 0)} · ↑ {formatRate(snapshot.rateOut or 0)}`, + }) + end + table.insert(rows, { key = noctalia.tr("tooltip.checked"), value = snapshot.checkedAt }) + end + return rows +end + +local function render() + local state = stateOf() + local color = COLORS[state] or "outline" + local showPending = noctalia.getConfig("show_pending") ~= false + local pending = state == "syncing" and showPending and pendingText() or "" + local container = barWidget.isVertical() and ui.column or ui.row + + local children = { logo(state) } + local badge = BADGES[state] + if badge then + table.insert(children, ui.glyph({ name = badge.glyph, size = 11, color = badge.color })) + end + if pending ~= "" then + table.insert(children, ui.label({ text = pending, fontWeight = "bold", color = color })) + end + + barWidget.render(container({ gap = 5, align = "center" }, children)) + barWidget.setTooltip(tooltipRows()) +end + +noctalia.state.watch("st.snapshot", function(encoded) + snapshot = encoded and noctalia.json.decode(encoded) or nil + render() +end) + +function update() + noctalia.setUpdateInterval(2000) + render() +end + +function onClick() + noctalia.togglePanel("rylos/syncthing:panel") +end + +function onRightClick() + sendCmd("refresh") +end + +function onIpc(event) + if event == "refresh" then + sendCmd("refresh") + end +end diff --git a/syncthing/desktop.luau b/syncthing/desktop.luau new file mode 100644 index 00000000..7a375f3e --- /dev/null +++ b/syncthing/desktop.luau @@ -0,0 +1,87 @@ +--!nonstrict +-- Syncthing desktop widget — a compact status card pinned to the desktop. +-- +-- Logo + state, device/folder/pending counters, transfer rates, and a +-- progress bar while syncing. Pure display; open the panel for actions. + +local snapshot = nil + +local STATE_COLORS = { + idle = "primary", + syncing = "secondary", + paused = "outline", + disconnected = "secondary", + offline = "error", + unauthorized = "error", + error = "error", + unconfigured = "outline", +} + +local function formatRate(bytesPerSec) + local units = { "B", "KB", "MB", "GB" } + local value = bytesPerSec + local index = 1 + while value >= 1024 and index < #units do + value /= 1024 + index += 1 + end + local rounded = (value >= 10 or index == 1) and tostring(math.round(value)) or string.format("%.1f", value) + return `{rounded} {units[index]}/s` +end + +local function stat(value, label) + return ui.column({ gap = 1, align = "center", flexGrow = 1 }, { + ui.label({ text = value, fontSize = 15, fontWeight = "bold" }), + ui.label({ text = label, fontSize = 10, color = "on_surface/0.6" }), + }) +end + +local function render() + local state = snapshot and snapshot.state or "unconfigured" + local color = STATE_COLORS[state] or "outline" + local devices = snapshot and `{snapshot.devices.connected}/{snapshot.devices.configured}` or "-" + local folders = snapshot and tostring(snapshot.totals.monitoredFolders) or "-" + local pending = snapshot and tostring(snapshot.totals.needItems) or "-" + + local children = { + ui.row({ gap = 8, align = "center" }, { + ui.image({ + path = noctalia.isDarkMode() and "icon.svg" or "icon-light.svg", + width = 18, + height = 18, + fit = "contain", + }), + ui.label({ text = "Syncthing", fontSize = 14, fontWeight = "bold", flexGrow = 1 }), + ui.label({ text = noctalia.tr(`state.{state}`), fontSize = 11, color = color }), + }), + ui.row({ gap = 6 }, { + stat(devices, noctalia.tr("panel.devices")), + stat(folders, noctalia.tr("panel.folders")), + stat(pending, noctalia.tr("panel.pending")), + }), + } + + if snapshot and state == "syncing" and (snapshot.syncPercent or -1) >= 0 then + table.insert(children, ui.progress({ progress = snapshot.syncPercent / 100, height = 4, fill = "secondary" })) + end + if snapshot and ((snapshot.rateIn or 0) >= 1024 or (snapshot.rateOut or 0) >= 1024) then + table.insert(children, ui.label({ + text = `↓ {formatRate(snapshot.rateIn or 0)} · ↑ {formatRate(snapshot.rateOut or 0)}`, + fontSize = 10, + color = "secondary", + textAlign = "center", + })) + end + + desktopWidget.render(ui.column({ gap = 8, padding = 10, fill = "surface_variant/0.4", radius = 12 }, children)) +end + +noctalia.state.watch("st.snapshot", function(encoded) + snapshot = encoded and noctalia.json.decode(encoded) or nil + render() +end) + +function update() + noctalia.setUpdateInterval(5000) + render() +end diff --git a/syncthing/icon-light.svg b/syncthing/icon-light.svg new file mode 100644 index 00000000..5d3c1dce --- /dev/null +++ b/syncthing/icon-light.svg @@ -0,0 +1,25 @@ + + + + + diff --git a/syncthing/icon.svg b/syncthing/icon.svg new file mode 100644 index 00000000..d2afa377 --- /dev/null +++ b/syncthing/icon.svg @@ -0,0 +1,25 @@ + + + + + diff --git a/syncthing/launcher.luau b/syncthing/launcher.luau new file mode 100644 index 00000000..5ca7d1cb --- /dev/null +++ b/syncthing/launcher.luau @@ -0,0 +1,59 @@ +--!nonstrict +-- Syncthing launcher provider — type `/st` in the launcher. +-- +-- Fuzzy-search the monitored folders; Enter triggers a rescan of the +-- selected folder. + +local function decodeSnapshot() + local encoded = noctalia.state.get("st.snapshot") + return encoded and noctalia.json.decode(encoded) or nil +end + +local STATE_GLYPHS = { + idle = "cloud-check", + syncing = "refresh", + paused = "player-pause", + error = "alert-triangle", +} + +function onQuery(text) + local snapshot = decodeSnapshot() + if not snapshot or #snapshot.folders == 0 then + launcher.setResults(text, { + { id = "noop", title = noctalia.tr("panel.no-folders"), glyph = "cloud-question" }, + }) + return + end + + local query = noctalia.string.trim(text) + local results = {} + for _, folder in snapshot.folders do + local score = 0 + if query ~= "" then + score = noctalia.fuzzyScore(query, `{folder.label} {folder.id}`) + if not score then + continue + end + end + table.insert(results, { + id = folder.id, + title = folder.label, + subtitle = noctalia.tr("launcher.rescan-hint", { id = folder.id }), + glyph = STATE_GLYPHS[folder.state] or "cloud-question", + badge = noctalia.tr(`state.{folder.state}`), + score = score, + }) + end + launcher.setResults(text, results) +end + +local cmdNonce = 0 + +function onActivate(id) + if id == "noop" then + return + end + cmdNonce += 1 + noctalia.state.set("st.cmd", noctalia.json.encode({ n = cmdNonce, action = "rescan", folder = id })) + noctalia.notify(noctalia.tr("toast.title"), noctalia.tr("launcher.rescan-started", { id = id })) +end diff --git a/syncthing/panel.luau b/syncthing/panel.luau new file mode 100644 index 00000000..945ffa6c --- /dev/null +++ b/syncthing/panel.luau @@ -0,0 +1,497 @@ +--!nonstrict +-- Syncthing detail panel — the [[panel]] entry. +-- +-- Header with live state + refresh and pause-all/resume-all buttons, stat +-- tiles (devices / folders / pending), a scrolling folder list with +-- per-folder pause/resume and rescan actions, then details, recent errors, +-- and connection info. +-- +-- Per-folder buttons use the predeclared-slot pattern (onPause0…, onRescan0…): +-- ui.* callbacks are global function *names*, so each rendered row binds its +-- folder id into a slot table the numbered globals read back. + +local MAX_FOLDER_SLOTS = 16 + +local snapshot = nil +local isOpen = false +local cmdNonce = 0 +-- Active list tab: "folders" | "devices" (Syncthing Tray-style tabs). +local view = "folders" +-- Slot table: index → { kind = "folder"|"device", id = }. +local folderSlots = {} + +local STATE_COLORS = { + idle = "primary", + syncing = "secondary", + paused = "outline", + disconnected = "secondary", + offline = "error", + unauthorized = "error", + error = "error", + unconfigured = "outline", + disabled = "outline", +} + +local function tr(key, subst) + return noctalia.tr(key, subst) +end + +local function sendCmd(action, folderId) + cmdNonce += 1 + noctalia.state.set("st.cmd", noctalia.json.encode({ n = cmdNonce, action = action, folder = folderId })) +end + +local function formatBytes(bytes) + local units = { "B", "KB", "MB", "GB", "TB" } + local value = tonumber(bytes) or 0 + local index = 1 + while value >= 1024 and index < #units do + value /= 1024 + index += 1 + end + local rounded = (value >= 10 or index == 1) and tostring(math.round(value)) or string.format("%.1f", value) + return `{rounded} {units[index]}` +end + +local function statusSummary() + if not snapshot then + return tr("state.unconfigured") + end + local state = snapshot.state + if state == "idle" then + if snapshot.devices.configured > 0 then + return tr("summary.idle-devices", { + connected = snapshot.devices.connected, + configured = snapshot.devices.configured, + }) + end + return tr("summary.idle-no-devices") + end + if state == "syncing" then + if snapshot.totals.needItems > 0 then + return tr("summary.syncing-items", { count = snapshot.totals.needItems }) + end + return tr("summary.syncing-folders", { count = math.max(snapshot.totals.syncingFolders, 1) }) + end + return tr(`summary.{state}`) +end + +local function detailText() + if not snapshot or snapshot.detail == "" then + return "" + end + -- Structured details are translation keys; anything else passes through. + if snapshot.detail == "no-url" or snapshot.detail == "no-api-key" or snapshot.detail == "no-folders" then + return tr(`detail.{snapshot.detail}`) + end + return snapshot.detail +end + +local function allPaused() + return snapshot ~= nil + and snapshot.devices.configured > 0 + and snapshot.devices.paused >= snapshot.devices.configured +end + +-- ── Sections ───────────────────────────────────────────────────────────────── + +local function header() + local state = snapshot and snapshot.state or "unconfigured" + local paused = allPaused() + return ui.row({ gap = 8, align = "center" }, { + ui.image({ + path = noctalia.isDarkMode() and "icon.svg" or "icon-light.svg", + width = 20, + height = 20, + fit = "contain", + }), + ui.column({ gap = 2, flexGrow = 1 }, { + ui.label({ text = tr("panel.title"), fontSize = 16, fontWeight = "bold" }), + ui.label({ text = tr(`state.{state}`), fontSize = 12, color = STATE_COLORS[state] or "outline" }), + }), + ui.button({ + glyph = paused and "player-play" or "player-pause", + variant = "outline", + controlSize = "sm", + tooltip = paused and tr("panel.resume-all") or tr("panel.pause-all"), + onClick = paused and "onResumeAll" or "onPauseAll", + }), + ui.button({ + glyph = "external-link", + variant = "ghost", + controlSize = "sm", + tooltip = tr("panel.open-gui"), + onClick = "onOpenGui", + }), + ui.button({ + glyph = "refresh", + variant = "primary", + controlSize = "sm", + tooltip = tr("panel.refresh"), + onClick = "onRefresh", + }), + }) +end + +local function formatRate(bytesPerSec) + if bytesPerSec < 1024 then + return nil + end + return `{formatBytes(bytesPerSec)}/s` +end + +local function ratesLabel() + -- Always returns a node (an invisible one when idle): a nil in the middle of + -- a children array literal would truncate the siblings after it. + local down = snapshot and formatRate(snapshot.rateIn or 0) or nil + local up = snapshot and formatRate(snapshot.rateOut or 0) or nil + return ui.label({ + text = `↓ {down or "0 B/s"} · ↑ {up or "0 B/s"}`, + fontSize = 11, + color = "secondary", + visible = (down or up) ~= nil, + }) +end + +local function statTile(value, label) + return ui.column({ gap = 2, padding = 8, fill = "surface_variant/0.5", radius = 8, align = "center", flexGrow = 1 }, { + ui.label({ text = value, fontSize = 15, fontWeight = "bold" }), + ui.label({ text = label, fontSize = 11, color = "on_surface/0.6" }), + }) +end + +local function stats() + local devices = snapshot and `{snapshot.devices.connected}/{snapshot.devices.configured}` or "-" + local folders = snapshot and tostring(snapshot.totals.monitoredFolders) or "-" + local pending = "-" + if snapshot then + pending = snapshot.totals.needItems > 0 + and tostring(snapshot.totals.needItems) + or formatBytes(snapshot.totals.needBytes) + end + return ui.row({ gap = 8 }, { + statTile(devices, tr("panel.devices")), + statTile(folders, tr("panel.folders")), + statTile(pending, tr("panel.pending")), + }) +end + +local function folderStateText(folder) + if folder.state == "syncing" then + local base = `{tr(`state.{folder.state}`)} {folder.completion}%` + if folder.needItems > 0 then + base = tr("panel.folder-state", { state = base, count = folder.needItems }) + end + return base + end + if folder.state == "error" and folder.detail ~= "" then + return folder.detail + end + if folder.state == "idle" and folder.lastActivity ~= "" then + return tr("panel.last-activity", { value = folder.lastActivity }) + end + return tr(`state.{folder.state}`) +end + +local function tabs() + local folderCount = snapshot and snapshot.totals.monitoredFolders or 0 + local connected = snapshot and snapshot.devices.connected or 0 + local configured = snapshot and snapshot.devices.configured or 0 + return ui.row({ gap = 6 }, { + ui.button({ + text = `{tr("panel.tab-folders")} ({folderCount})`, + glyph = "folder", + variant = view == "folders" and "secondary" or "ghost", + controlSize = "sm", + flexGrow = 1, + onClick = "onTabFolders", + }), + ui.button({ + text = `{tr("panel.tab-devices")} ({connected}/{configured})`, + glyph = "devices-pc", + variant = view == "devices" and "secondary" or "ghost", + controlSize = "sm", + flexGrow = 1, + onClick = "onTabDevices", + }), + }) +end + +local function folderRow(folder, slot) + folderSlots[slot] = { kind = "folder", id = folder.id } + local color = STATE_COLORS[folder.state] or "outline" + return ui.row({ + key = `folder-{folder.id}`, + gap = 8, + padding = 8, + align = "center", + fill = "surface_variant/0.5", + radius = 8, + border = `{color}/0.5`, + borderWidth = 1, + }, { + ui.box({ width = 8, height = 8, radius = 4, fill = color }), + ui.column({ gap = 2, flexGrow = 1 }, { + ui.label({ text = folder.label, fontWeight = "bold", fontSize = 13, maxLines = 1 }), + ui.label({ text = folderStateText(folder), fontSize = 11, color = "on_surface/0.65", maxLines = 1 }), + ui.progress({ + progress = folder.completion / 100, + height = 3, + fill = "secondary", + visible = folder.state == "syncing", + }), + }), + ui.button({ + glyph = folder.paused and "player-play" or "player-pause", + variant = "ghost", + controlSize = "sm", + tooltip = folder.paused and tr("panel.folder-resume") or tr("panel.folder-pause"), + onClick = (folder.paused and "onResume" or "onPause") .. slot, + }), + ui.button({ + glyph = "refresh", + variant = "ghost", + controlSize = "sm", + enabled = not folder.paused, + tooltip = tr("panel.folder-rescan"), + onClick = "onRescan" .. slot, + }), + }) +end + +local function deviceRow(device, slot) + folderSlots[slot] = { kind = "device", id = device.id } + local color = device.connected and "primary" or "outline" + local subtitle + if device.paused then + subtitle = tr("device.paused") + elseif device.connected then + subtitle = device.address ~= "" and device.address or tr("device.connected") + if device.version ~= "" then + subtitle = `{subtitle} · {device.version}` + end + else + subtitle = tr("device.disconnected") + end + return ui.row({ + key = `device-{device.shortId}`, + gap = 8, + padding = 8, + align = "center", + fill = "surface_variant/0.5", + radius = 8, + border = `{color}/0.5`, + borderWidth = 1, + opacity = device.connected and 1 or 0.7, + }, { + ui.box({ width = 8, height = 8, radius = 4, fill = color }), + ui.column({ gap = 1, flexGrow = 1 }, { + ui.label({ text = device.name, fontWeight = "bold", fontSize = 13, maxLines = 1 }), + ui.label({ text = subtitle, fontSize = 11, color = "on_surface/0.65", maxLines = 1 }), + }), + ui.button({ + glyph = device.paused and "player-play" or "player-pause", + variant = "ghost", + controlSize = "sm", + tooltip = device.paused and tr("panel.device-resume") or tr("panel.device-pause"), + onClick = (device.paused and "onResume" or "onPause") .. slot, + }), + }) +end + +local function folderList() + local rows = {} + if snapshot then + if view == "devices" then + for index, device in snapshot.devicesList do + if index > MAX_FOLDER_SLOTS then + break + end + table.insert(rows, deviceRow(device, index - 1)) + end + else + for index, folder in snapshot.folders do + if index > MAX_FOLDER_SLOTS then + break + end + table.insert(rows, folderRow(folder, index - 1)) + end + end + end + if #rows == 0 then + local emptyKey = view == "devices" and "panel.no-devices" or "panel.no-folders" + table.insert(rows, ui.label({ text = tr(emptyKey), fontSize = 12, color = "on_surface/0.6" })) + end + return ui.scroll({ gap = 6, flexGrow = 1 }, rows) +end + +local function footer() + local children = {} + local detail = detailText() + if detail ~= "" then + table.insert(children, ui.label({ + text = detail, + fontSize = 11, + color = (snapshot and STATE_COLORS[snapshot.state] == "error") and "error" or "on_surface/0.75", + maxLines = 3, + })) + end + if snapshot then + for _, err in snapshot.recentErrors do + if #children >= 4 then + break + end + table.insert(children, ui.label({ text = err.message, fontSize = 11, color = "error/0.85", maxLines = 2 })) + end + table.insert(children, ui.label({ + text = tr("panel.last-check-info", { value = snapshot.checkedAt }), + fontSize = 11, + color = "on_surface/0.55", + })) + if snapshot.sources.resolvedUrl ~= "" then + table.insert(children, ui.label({ + text = snapshot.sources.resolvedUrl, + fontSize = 11, + color = "on_surface/0.55", + maxLines = 1, + })) + end + end + return ui.column({ gap = 3 }, children) +end + +local function render() + if not isOpen then + return + end + folderSlots = {} + -- height pins the root to the [[panel]] size so the folder list scrolls + -- instead of growing past the panel background + panel.render(ui.column({ gap = 12, padding = 14, height = 500 }, { + header(), + ui.label({ text = statusSummary(), fontSize = 12, color = "on_surface/0.8" }), + stats(), + tabs(), + folderList(), + ratesLabel(), + ui.separator({}), + footer(), + })) +end + +-- ── Lifecycle & callbacks ──────────────────────────────────────────────────── + +noctalia.state.watch("st.snapshot", function(encoded) + snapshot = encoded and noctalia.json.decode(encoded) or nil + render() +end) + +function onOpen(_context) + isOpen = true + local encoded = noctalia.state.get("st.snapshot") + snapshot = encoded and noctalia.json.decode(encoded) or nil + render() + sendCmd("refresh") +end + +function onClose() + isOpen = false +end + +function onRefresh() + sendCmd("refresh") +end + +function onOpenGui() + sendCmd("open_gui") +end + +function onPauseAll() + sendCmd("pause_all") +end + +function onResumeAll() + sendCmd("resume_all") +end + +function onTabFolders() + view = "folders" + render() +end + +function onTabDevices() + view = "devices" + render() +end + +local function pauseAt(slot) + local target = folderSlots[slot] + if target then + sendCmd(target.kind == "device" and "device_pause" or "folder_pause", target.id) + end +end + +local function resumeAt(slot) + local target = folderSlots[slot] + if target then + sendCmd(target.kind == "device" and "device_resume" or "folder_resume", target.id) + end +end + +local function rescanAt(slot) + local target = folderSlots[slot] + if target and target.kind == "folder" then + sendCmd("rescan", target.id) + end +end + +function onPause0() pauseAt(0) end +function onPause1() pauseAt(1) end +function onPause2() pauseAt(2) end +function onPause3() pauseAt(3) end +function onPause4() pauseAt(4) end +function onPause5() pauseAt(5) end +function onPause6() pauseAt(6) end +function onPause7() pauseAt(7) end +function onPause8() pauseAt(8) end +function onPause9() pauseAt(9) end +function onPause10() pauseAt(10) end +function onPause11() pauseAt(11) end +function onPause12() pauseAt(12) end +function onPause13() pauseAt(13) end +function onPause14() pauseAt(14) end +function onPause15() pauseAt(15) end + +function onResume0() resumeAt(0) end +function onResume1() resumeAt(1) end +function onResume2() resumeAt(2) end +function onResume3() resumeAt(3) end +function onResume4() resumeAt(4) end +function onResume5() resumeAt(5) end +function onResume6() resumeAt(6) end +function onResume7() resumeAt(7) end +function onResume8() resumeAt(8) end +function onResume9() resumeAt(9) end +function onResume10() resumeAt(10) end +function onResume11() resumeAt(11) end +function onResume12() resumeAt(12) end +function onResume13() resumeAt(13) end +function onResume14() resumeAt(14) end +function onResume15() resumeAt(15) end + +function onRescan0() rescanAt(0) end +function onRescan1() rescanAt(1) end +function onRescan2() rescanAt(2) end +function onRescan3() rescanAt(3) end +function onRescan4() rescanAt(4) end +function onRescan5() rescanAt(5) end +function onRescan6() rescanAt(6) end +function onRescan7() rescanAt(7) end +function onRescan8() rescanAt(8) end +function onRescan9() rescanAt(9) end +function onRescan10() rescanAt(10) end +function onRescan11() rescanAt(11) end +function onRescan12() rescanAt(12) end +function onRescan13() rescanAt(13) end +function onRescan14() rescanAt(14) end +function onRescan15() rescanAt(15) end diff --git a/syncthing/plugin.toml b/syncthing/plugin.toml new file mode 100644 index 00000000..08322582 --- /dev/null +++ b/syncthing/plugin.toml @@ -0,0 +1,112 @@ +# Syncthing status & control for Noctalia v5. +# Port of the v4 syncthing-status plugin (by Pir0c0pter0) to the Luau plugin API, +# with the Python helper replaced by direct REST calls (noctalia.http) and new +# actions: per-folder pause/resume + rescan, and a global pause shortcut. + +id = "rylos/syncthing" +name = "Syncthing" +version = "2.1.0" +plugin_api = 3 +author = "Marco Ziliani" +license = "MIT" +dependencies = ["syncthing"] +tags = ["bar", "panel", "service", "network", "utility", "indicator"] +icon = "cloud-network" +description = "Syncthing status and control: bar widget, detail panel with per-folder pause/rescan, and a global pause shortcut." + +# ── Plugin-level settings (shared by every entry, edited in Settings → Plugins) ── + +[[setting]] +key = "url" +type = "string" +label_key = "settings.url.label" +description_key = "settings.url.description" +default = "" + +[[setting]] +key = "api_key" +type = "string" +label_key = "settings.api_key.label" +description_key = "settings.api_key.description" +default = "" + +[[setting]] +key = "config_path" +type = "file" +label_key = "settings.config_path.label" +description_key = "settings.config_path.description" +default = "" +advanced = true + +[[setting]] +key = "poll_interval" +type = "int" +label_key = "settings.poll_interval.label" +description_key = "settings.poll_interval.description" +default = 10 +min = 2 +max = 300 + +[[setting]] +key = "folders" +type = "string_list" +label_key = "settings.folders.label" +description_key = "settings.folders.description" +default = [] +advanced = true + +[[setting]] +key = "notify_events" +type = "bool" +label_key = "settings.notify_events.label" +description_key = "settings.notify_events.description" +default = true + +[[setting]] +key = "insecure_tls" +type = "bool" +label_key = "settings.insecure_tls.label" +description_key = "settings.insecure_tls.description" +default = false +advanced = true + +# ── Entries ────────────────────────────────────────────────────────────────── + +[[service]] +id = "poller" +entry = "service.luau" + +[[widget]] +id = "bar" +entry = "bar.luau" + + [[widget.setting]] + key = "show_pending" + type = "bool" + label_key = "settings.show_pending.label" + description_key = "settings.show_pending.description" + default = true + +[[panel]] +id = "panel" +entry = "panel.luau" +width = 400 +height = 500 +placement = "attached" +position = "auto" +open_near_click = true + +[[shortcut]] +id = "pause" +entry = "shortcut.luau" + +[[launcher_provider]] +id = "folders" +entry = "launcher.luau" +prefix = "st" +glyph = "cloud-network" +include_in_global_search = false + +[[desktop_widget]] +id = "desktop" +entry = "desktop.luau" diff --git a/syncthing/service.luau b/syncthing/service.luau new file mode 100644 index 00000000..3103adac --- /dev/null +++ b/syncthing/service.luau @@ -0,0 +1,623 @@ +--!nonstrict +-- Syncthing poller — the headless [[service]] entry. +-- +-- Talks to the Syncthing REST API directly with noctalia.http (no external +-- helper). Resolves the GUI URL and API key from the plugin settings, falling +-- back to Syncthing's config.xml. Publishes one JSON snapshot to +-- noctalia.state under "st.snapshot" and executes commands sent by the UI +-- entries through "st.cmd" (refresh, pause/resume all, per-folder +-- pause/resume/rescan). + +local DEFAULT_CONFIG_PATHS = { + "~/.local/state/syncthing/config.xml", + "~/.config/syncthing/config.xml", +} + +local ACTIVE_FOLDER_STATES = { + ["cleaning"] = true, + ["scan-waiting"] = true, + ["scanning"] = true, + ["sync-preparing"] = true, + ["syncing"] = true, +} + +local cfg = { + baseUrl = "", + apiKey = "", + insecure = false, + pollMs = 10000, + monitored = {}, -- set of folder ids; empty = all + monitoredCount = 0, + sources = { configPath = "", urlSource = "none", apiKeySource = "none" }, +} + +local busy = false +local pendingPoll = false +-- Previous published snapshot, for event notifications (state transitions). +local prevSnapshot = nil +-- Last connection totals, for transfer-rate computation between polls. +local lastTotals = nil + +-- ── Config resolution ──────────────────────────────────────────────────────── + +local function trim(s) + return noctalia.string.trim(s or "") +end + +local function normalizeBaseUrl(value, defaultScheme) + local url = trim(value) + if url == "" then + return "" + end + if not url:match("^https?://") then + url = `{defaultScheme}://{url}` + end + url = url:gsub("/+$", "") + url = url:gsub("/rest$", "") + return url +end + +local function parseConfigXml() + local explicit = trim(noctalia.getConfig("config_path")) + local candidates = explicit ~= "" and { explicit } or DEFAULT_CONFIG_PATHS + for _, candidate in candidates do + local xml = noctalia.readFile(candidate) + if xml then + local guiAttrs = xml:match("]*)>") or "" + local guiBlock = xml:match("]*>(.-)") or "" + return { + path = noctalia.expandPath(candidate), + enabled = guiAttrs:match('enabled%s*=%s*"false"') == nil, + tls = guiAttrs:match('tls%s*=%s*"true"') ~= nil, + address = trim(guiBlock:match("
([^<]*)
")), + apiKey = trim(guiBlock:match("([^<]*)")), + } + end + end + return nil +end + +local function resolveConfig() + local xml = parseConfigXml() + local scheme = (xml and xml.tls) and "https" or "http" + + local url = normalizeBaseUrl(noctalia.getConfig("url"), scheme) + local urlSource = "manual" + if url == "" then + if xml and xml.address ~= "" and xml.enabled then + url = normalizeBaseUrl(xml.address, scheme) + urlSource = "config" + else + urlSource = "none" + end + end + + local apiKey = trim(noctalia.getConfig("api_key")) + local apiKeySource = "manual" + if apiKey == "" then + apiKey = xml and xml.apiKey or "" + apiKeySource = apiKey ~= "" and "config" or "none" + end + + cfg.baseUrl = url + cfg.apiKey = apiKey + cfg.insecure = noctalia.getConfig("insecure_tls") == true + local seconds = tonumber(noctalia.getConfig("poll_interval")) or 10 + cfg.pollMs = math.clamp(seconds, 2, 300) * 1000 + cfg.monitored = {} + cfg.monitoredCount = 0 + for _, id in (noctalia.getConfig("folders") or {}) do + id = trim(id) + if id ~= "" then + cfg.monitored[id] = true + cfg.monitoredCount += 1 + end + end + cfg.sources = { + configPath = xml and xml.path or "", + urlSource = urlSource, + apiKeySource = apiKeySource, + } +end + +-- ── HTTP ───────────────────────────────────────────────────────────────────── + +-- fetch(path, opts, cb) → cb(ok, status, body). opts: method, body, noauth. +-- With insecure_tls on an https URL we shell out to curl -k, because +-- noctalia.http has no way to skip certificate verification. +local function fetch(path, opts, cb) + local url = cfg.baseUrl .. path + + if cfg.insecure and cfg.baseUrl:match("^https://") then + local parts = { "curl", "-ksS", "-m", "5", "-w", "'\\n%{http_code}'" } + if not opts.noauth and cfg.apiKey ~= "" then + table.insert(parts, `-H "X-API-Key: {cfg.apiKey}"`) + end + if opts.method and opts.method ~= "GET" then + table.insert(parts, `-X {opts.method}`) + end + if opts.body then + table.insert(parts, `-H "Content-Type: application/json" --data-raw '{opts.body}'`) + end + table.insert(parts, `"{url}"`) + local launched = noctalia.runAsync(table.concat(parts, " "), function(result) + if result.exitCode ~= 0 or result.timedOut then + cb(false, 0, "") + return + end + local body, code = result.stdout:match("^(.*)\n(%d+)%s*$") + cb(true, tonumber(code) or 0, body or "") + end, 8000) + if not launched then + cb(false, 0, "") + end + return + end + + local headers = nil + if not opts.noauth and cfg.apiKey ~= "" then + headers = { `X-API-Key: {cfg.apiKey}` } + end + local started = noctalia.http({ + url = url, + method = opts.method, + headers = headers, + body = opts.body, + }, function(res) + cb(res.ok, res.status, res.body) + end) + if not started then + cb(false, 0, "") + end +end + +-- ── Snapshot building ──────────────────────────────────────────────────────── + +local function emptySnapshot(state, detail) + return { + state = state, + detail = detail or "", + checkedAt = noctalia.formatTime("hh:mm:ss"), + sources = { + configPath = cfg.sources.configPath, + urlSource = cfg.sources.urlSource, + apiKeySource = cfg.sources.apiKeySource, + resolvedUrl = cfg.baseUrl, + }, + devices = { configured = 0, connected = 0, paused = 0 }, + totals = { + monitoredFolders = 0, pausedFolders = 0, syncingFolders = 0, + erroredFolders = 0, needItems = 0, needBytes = 0, + }, + folders = {}, + devicesList = {}, + recentErrors = {}, + syncPercent = -1, -- overall completion while syncing; -1 = not applicable + rateIn = 0, -- bytes/sec since previous poll + rateOut = 0, + } +end + +-- Desktop notifications on meaningful transitions (folder done/error, device +-- connect/disconnect). Only between two healthy snapshots, so a Syncthing +-- restart or plugin reload doesn't spray stale events. +local function notifyTransitions(old, new) + if noctalia.getConfig("notify_events") == false then + return + end + if not old then + return + end + local healthy = { idle = true, syncing = true, paused = true, disconnected = true, error = true } + if not healthy[old.state] or not healthy[new.state] then + return + end + + local oldFolders = {} + for _, f in old.folders do + oldFolders[f.id] = f + end + for _, f in new.folders do + local prev = oldFolders[f.id] + if prev then + if prev.state == "syncing" and f.state == "idle" then + noctalia.notify(noctalia.tr("toast.title"), noctalia.tr("notify.folder-done", { label = f.label })) + elseif f.state == "error" and prev.state ~= "error" then + noctalia.notifyError(noctalia.tr("toast.title"), noctalia.tr("notify.folder-error", { label = f.label })) + end + end + end + + local oldDevices = {} + for _, d in old.devicesList do + oldDevices[d.id] = d + end + for _, d in new.devicesList do + local prev = oldDevices[d.id] + if prev and prev.connected ~= d.connected then + local key = d.connected and "notify.device-connected" or "notify.device-disconnected" + noctalia.notify(noctalia.tr("toast.title"), noctalia.tr(key, { name = d.name })) + end + end +end + +local function publish(snapshot) + busy = false + notifyTransitions(prevSnapshot, snapshot) + prevSnapshot = snapshot + local encoded = noctalia.json.encode(snapshot) + if encoded then + noctalia.state.set("st.snapshot", encoded) + end + if pendingPoll then + pendingPoll = false + poll(true) + end +end + +local function summarizeFolder(folder, status) + local rawState = trim(status.state or "unknown") + if rawState == "" then rawState = "unknown" end + local needItems = tonumber(status.needTotalItems) or 0 + local needBytes = tonumber(status.needBytes) or 0 + local globalBytes = tonumber(status.globalBytes) or 0 + local pullErrors = tonumber(status.pullErrors) or 0 + local errorCount = tonumber(status.errors) or 0 + local watchError = trim(status.watchError) + local hardError = trim(status.error) + + local state + if folder.paused then + state = "paused" + elseif hardError ~= "" or watchError ~= "" or pullErrors > 0 or errorCount > 0 then + state = "error" + elseif ACTIVE_FOLDER_STATES[rawState] or needItems > 0 or needBytes > 0 then + state = "syncing" + else + state = "idle" + end + + return { + id = folder.id, + label = folder.label, + paused = folder.paused == true, + state = state, + rawState = rawState, + needItems = needItems, + needBytes = needBytes, + globalBytes = globalBytes, + -- Sync completion in percent, derived from db/status byte counters. + completion = globalBytes > 0 and math.floor(100 * (globalBytes - needBytes) / globalBytes) or 100, + lastActivity = folder.lastActivity or "", + detail = watchError ~= "" and watchError or hardError, + } +end + +local function classify(snapshot) + local totals = snapshot.totals + local devices = snapshot.devices + + if #snapshot.folders == 0 then + snapshot.state = "unconfigured" + if snapshot.detail == "" then + snapshot.detail = "no-folders" + end + elseif totals.pausedFolders == totals.monitoredFolders then + snapshot.state = "paused" + elseif totals.erroredFolders > 0 then + snapshot.state = "error" + elseif totals.syncingFolders > 0 or totals.needItems > 0 or totals.needBytes > 0 then + snapshot.state = "syncing" + elseif devices.configured > 0 and devices.connected == 0 and devices.paused < devices.configured then + snapshot.state = "disconnected" + else + snapshot.state = "idle" + end + return snapshot +end + +-- Third stage: per-folder db status, joined, then classify + publish. +local function fetchFolderStatuses(snapshot, monitoredFolders) + local remaining = 0 + local summaries = {} + + local function finalize() + for _, s in summaries do + table.insert(snapshot.folders, s) + local t = snapshot.totals + t.monitoredFolders += 1 + if s.state == "paused" then t.pausedFolders += 1 end + if s.state == "syncing" then t.syncingFolders += 1 end + if s.state == "error" then t.erroredFolders += 1 end + t.needItems += s.needItems + t.needBytes += s.needBytes + end + if #snapshot.recentErrors > 0 and snapshot.totals.erroredFolders == 0 and snapshot.detail == "" then + snapshot.detail = snapshot.recentErrors[1].message + end + -- Overall completion while syncing, weighted by bytes. + if snapshot.totals.syncingFolders > 0 then + local globalB, needB = 0, 0 + for _, s in snapshot.folders do + globalB += s.globalBytes + needB += s.needBytes + end + if globalB > 0 then + snapshot.syncPercent = math.floor(100 * (globalB - needB) / globalB) + end + end + publish(classify(snapshot)) + end + + for index, folder in monitoredFolders do + if folder.paused then + summaries[index] = summarizeFolder(folder, { state = "paused" }) + else + remaining += 1 + end + end + if remaining == 0 then + finalize() + return + end + + for index, folder in monitoredFolders do + if folder.paused then + continue + end + fetch(`/rest/db/status?folder={noctalia.string.urlEncode(folder.id)}`, {}, function(ok, status, body) + local parsed = ok and status < 400 and noctalia.json.decode(body) or nil + summaries[index] = summarizeFolder(folder, parsed or { state = "error", error = `folder status HTTP {status}` }) + remaining -= 1 + if remaining == 0 then + finalize() + end + end) + end +end + +-- Second stage: the four core endpoints in parallel, joined. +local function fetchCore() + local endpoints = { + system = "/rest/system/status", + config = "/rest/config", + connections = "/rest/system/connections", + errors = "/rest/system/error", + fstats = "/rest/stats/folder", + } + local results = {} + local remaining = 0 + local failedStatus = nil + + for key in endpoints do + remaining += 1 + end + + local function done() + if failedStatus then + if failedStatus == 401 or failedStatus == 403 then + publish(emptySnapshot("unauthorized")) + elseif failedStatus == 0 then + publish(emptySnapshot("offline")) + else + publish(emptySnapshot("error", `HTTP {failedStatus}`)) + end + return + end + + local snapshot = emptySnapshot("unknown") + + -- Transfer rates from the delta of total in/out bytes between two polls. + local totals = (results.connections or {}).total or {} + local nowClock = os.clock() + local inB = tonumber(totals.inBytesTotal) or 0 + local outB = tonumber(totals.outBytesTotal) or 0 + if lastTotals and nowClock > lastTotals.t and inB >= lastTotals.inB then + local dt = nowClock - lastTotals.t + snapshot.rateIn = math.max(0, (inB - lastTotals.inB) / dt) + snapshot.rateOut = math.max(0, (outB - lastTotals.outB) / dt) + end + lastTotals = { inB = inB, outB = outB, t = nowClock } + + -- Devices: remote only, split by connection state, plus the full list for + -- the panel's Devices tab. + local myID = trim(results.system.myID) + local connMap = (results.connections or {}).connections or {} + for _, device in (results.config.devices or {}) do + local id = trim(device.deviceID) + if id ~= "" and id ~= myID then + snapshot.devices.configured += 1 + local info = connMap[id] or {} + local paused = device.paused == true or info.paused == true + if info.paused then + snapshot.devices.paused += 1 + elseif info.connected then + snapshot.devices.connected += 1 + end + table.insert(snapshot.devicesList, { + name = trim(device.name) ~= "" and trim(device.name) or id:sub(1, 7), + id = id, + shortId = id:sub(1, 7), + paused = paused, + connected = info.connected == true, + address = trim(info.address), + version = trim(info.clientVersion), + }) + end + end + table.sort(snapshot.devicesList, function(a, b) + if a.connected ~= b.connected then + return a.connected + end + return a.name:lower() < b.name:lower() + end) + + -- Recent errors (last 5). + for _, item in ((results.errors or {}).errors or {}) do + local message = trim(item.message) + if message ~= "" and #snapshot.recentErrors < 5 then + table.insert(snapshot.recentErrors, { when = trim(item.when), message = message }) + end + end + + -- Folder list from config, filtered by the monitored set. + local monitoredFolders = {} + for _, folder in (results.config.folders or {}) do + local id = trim(folder.id) + if id == "" then + continue + end + if cfg.monitoredCount > 0 and not cfg.monitored[id] then + continue + end + -- Last activity from folder stats: prefer the last synced file's + -- timestamp, fall back to the last scan (skip Go zero dates). + local fs = (results.fstats or {})[id] + local lastActivity = "" + if fs then + local at = trim((fs.lastFile or {}).at) + if at == "" or at:sub(1, 4) == "0001" then + at = trim(fs.lastScan) + end + if at ~= "" and at:sub(1, 4) ~= "0001" then + lastActivity = at:sub(1, 16):gsub("T", " ") + end + end + table.insert(monitoredFolders, { + id = id, + label = trim(folder.label) ~= "" and trim(folder.label) or id, + paused = folder.paused == true, + lastActivity = lastActivity, + }) + end + table.sort(monitoredFolders, function(a, b) + return a.label:lower() < b.label:lower() + end) + + fetchFolderStatuses(snapshot, monitoredFolders) + end + + for key, path in endpoints do + fetch(path, {}, function(ok, status, body) + local parsed = ok and status < 400 and noctalia.json.decode(body) or nil + if parsed then + results[key] = parsed + elseif not failedStatus then + failedStatus = ok and status or 0 + end + remaining -= 1 + if remaining == 0 then + done() + end + end) + end +end + +function poll(force) + if busy then + pendingPoll = true + return + end + busy = true + resolveConfig() + + if cfg.baseUrl == "" then + publish(emptySnapshot("unconfigured", "no-url")) + return + end + + fetch("/rest/noauth/health", { noauth = true }, function(ok, status) + if not ok then + publish(emptySnapshot("offline")) + return + end + if status >= 400 and status ~= 404 then + publish(emptySnapshot("offline", `HTTP {status}`)) + return + end + if cfg.apiKey == "" then + publish(emptySnapshot("unconfigured", "no-api-key")) + return + end + fetchCore() + end) +end + +-- ── Commands from UI entries ───────────────────────────────────────────────── + +-- An action fires its request, then forces a fresh poll so the UI catches up. +local function actionThenRefresh(path, opts) + fetch(path, opts, function() + poll(true) + end) +end + +function openGui() + noctalia.log(`[syncthing] openGui: url={cfg.baseUrl}`) + if cfg.baseUrl == "" then + noctalia.notifyError(noctalia.tr("toast.title"), noctalia.tr("detail.no-url")) + return + end + -- gio, not xdg-open: with XDG_CURRENT_DESKTOP=KDE (set for Qt theming under + -- niri) but no KDE session, xdg-open takes the kfmclient path and no-ops + -- with exit 0. gio resolves the x-scheme-handler/http default directly. + noctalia.runAsync(`gio open "{cfg.baseUrl}" || xdg-open "{cfg.baseUrl}"`, function(result) + noctalia.log(`[syncthing] openGui exit={result.exitCode} err={result.stderr}`) + if result.exitCode ~= 0 then + noctalia.notifyError(noctalia.tr("toast.title"), `open failed: {noctalia.string.trim(result.stderr)}`) + end + end, 10000) +end + +noctalia.state.watch("st.cmd", function(raw) + local cmd = raw and noctalia.json.decode(raw) or nil + if not cmd or not cmd.action then + return + end + local folder = cmd.folder and noctalia.string.urlEncode(cmd.folder) or "" + + if cmd.action == "refresh" then + poll(true) + elseif cmd.action == "pause_all" then + actionThenRefresh("/rest/system/pause", { method = "POST" }) + elseif cmd.action == "resume_all" then + actionThenRefresh("/rest/system/resume", { method = "POST" }) + elseif cmd.action == "folder_pause" then + actionThenRefresh(`/rest/config/folders/{folder}`, { method = "PATCH", body = '{"paused":true}' }) + elseif cmd.action == "folder_resume" then + actionThenRefresh(`/rest/config/folders/{folder}`, { method = "PATCH", body = '{"paused":false}' }) + elseif cmd.action == "device_pause" then + actionThenRefresh(`/rest/config/devices/{folder}`, { method = "PATCH", body = '{"paused":true}' }) + elseif cmd.action == "device_resume" then + actionThenRefresh(`/rest/config/devices/{folder}`, { method = "PATCH", body = '{"paused":false}' }) + elseif cmd.action == "rescan" then + actionThenRefresh(`/rest/db/scan?folder={folder}`, { method = "POST" }) + elseif cmd.action == "open_gui" then + openGui() + else + noctalia.log(`[syncthing] unknown command: {cmd.action}`) + end +end) + +function onConfigChanged() + poll(true) +end + +function onIpc(event) + if event == "refresh" then + poll(true) + elseif event == "open" then + openGui() + elseif event == "pause" then + actionThenRefresh("/rest/system/pause", { method = "POST" }) + elseif event == "resume" then + actionThenRefresh("/rest/system/resume", { method = "POST" }) + end +end + +function update() + noctalia.setUpdateInterval(cfg.pollMs) + poll(false) +end + +resolveConfig() diff --git a/syncthing/shortcut.luau b/syncthing/shortcut.luau new file mode 100644 index 00000000..e64ce37d --- /dev/null +++ b/syncthing/shortcut.luau @@ -0,0 +1,43 @@ +--!nonstrict +-- Syncthing global pause — the [[shortcut]] control-center tile. +-- +-- Active while Syncthing is running normally; click to pause all devices +-- (Syncthing's global pause), click again to resume. Mirrors the state +-- published by the poller service. + +local snapshot = nil +local cmdNonce = 0 + +local function allPaused() + return snapshot ~= nil + and snapshot.devices.configured > 0 + and snapshot.devices.paused >= snapshot.devices.configured +end + +local function reachable() + if not snapshot then + return false + end + local s = snapshot.state + return s ~= "offline" and s ~= "unconfigured" and s ~= "unauthorized" +end + +local function render() + shortcut.setLabel(noctalia.tr("shortcut.label")) + shortcut.setIcon("cloud-network", "cloud-pause") + shortcut.setActive(reachable() and not allPaused()) + shortcut.setEnabled(reachable()) +end + +noctalia.state.watch("st.snapshot", function(encoded) + snapshot = encoded and noctalia.json.decode(encoded) or nil + render() +end) + +function onClick() + cmdNonce += 1 + local action = allPaused() and "resume_all" or "pause_all" + noctalia.state.set("st.cmd", noctalia.json.encode({ n = cmdNonce, action = action })) +end + +render() diff --git a/syncthing/thumbnail.webp b/syncthing/thumbnail.webp new file mode 100644 index 0000000000000000000000000000000000000000..46fd0a3958da3bcd4e35709f198894397d871051 GIT binary patch literal 5294 zcmb`KcT^NgvWJHt8OaDpMuG^;00INZ2q-y&M9CRsBu59yK^$_FJR^cKB$1pm3^_^8 znM=+Y_Puxa?!LS0?%8+FtG~MX^r^1%tFNoOH5FuKb14A;T^VV09d$5s>VJ(vU4RS> z?j|gHOh~LeLitM$8{Htp8$u>>yoxO5yPVs_nY>HCU5L*(K6zH+ zE%qSE?#=pj#wg)f+ShE{K1osBU^rU2tXW z?Y9IDeC$vKJ?)#hML&QKa#L%1>x@(X|EK2{k5kWmIU1N0io=HA{0k>^m1aa&Kl$T` zijlQ3P({f7lUM!IpJP@_$NzTPsyey+*K46I>$IQVY5jN1Zg0Qo6QaiKDXrJDl-!=- zRovO^6Fwqf&uvWjF9g^$B2d*)8f}y*e|~uds!H*#dZVUnTMp-}3Au7}DEsXfI+dOi zoFtL7FzT3rQQzW|%gatBmnt+UlJW0jS%%j9LRY?$pY9rGy&rzUV$CRhzfY9f3bUkG zzJDq;(LXC?WYWP| z#PgMX^y2+jynvlg&6Y>8pRtVHV)1bJa0dOShg2qpORCO)kgEMN0^fqkOm@T5p6fVX z1#e6|Sm}5@W;%zfRSNbogj5U5n(y;w{8mUU#h^K4Kzh8lHeU;I!YCTkizMSMrW^yBo0^WWE`a>jB@GjB^6SN zeH5cZ_J@A`fyn3Cun#`?J6>ujtt|CZp*~->j6z$+7ubNvzvq_dx${nDjfzeGP^`kF zeCI*Rt7!ZzHk8$s@sM|j{1_|>1qm1ZTUuk*ZT~CouTlJ+W?}0;GWk1qU+4tlYL9AeqP2n+{7p^gDTck0LPGG)5NKaTcv{_h7nFV1pW4bUVR$`jN!uiS|;kqd(J)vy$b zA6>yWUD(R3wKv{)#OjD&#a7Lx!?t*0?^jbvv5M-oQt)Ew&5#mtMk4&bG-iSa!yVLW zdT>f}YMhS*Z6uFfF$7BtrF;Im%O4Q)BY+kXZX258DWG1CMmaUs>J71i@z?&bl^B4&Z1~so+s!~sBB`FQ zWWHXM>o4cskte+b7A?FG9)eu<9|4kc&yuYb0`3iu)-j#U@~)lDk=wFix*h$hNA|L%>k&nVH=k+Pil8;Ut8u;!)uk+I>*5;iXU)x8S<7nApZ9@Cwt*7ft z2s08Ky)o|iClWGO{LTG)3N$bY!pN!IbUp8AAdpQ`ZL*;hs3pdxpc_MIPpeT2=;NFP$yJOwSnX?-kiBczRqS zU#t|OVRSU)=N3I6hs~4cd24(@$fBOIDy+Q>9*oJCT6CJ-_o4Z*W#`VomxCuX1JvZE zC76un5_a9&t~!3GNxJ*d%ze?6Z7|6JO$J1nxTnCiOCsV{UZqNIt-3nxIBmE|;`6NP z?A^ZqCRuw;iqt1mbJ}_Px*fYn}xGPl_R9y`> z=ChowW5RrqNK|ZQR_!iF0*x{2nZyV)nRM+(>$#`>W%V&6zz5yTr#!**Y2L$&0AG4` z8N0)Tv@EOs`66|k)$!G;TsKAEb@ulh%|CAjKHD@-Y&+X6o{Bj+DDY@2g}_}2FO$PQ z80ijbV0O{UTWVss&nGY-74~pM;PpBev`N%oR+F-#w++4l(>U4p)KUy~SDH2cA=c9J*VAZrPW@f!sk9usD9PP&SX z$*tzjF;(wF5tXBRKU^Bw)Ir0)u_it#mF&;mS=?2_OCYuM60KjJOxb+L?cHavsJs_WqDF_wN5PTz~Z4O|IIO}LCL4--h|43Q*GuUrZ;3R7vtpm(0P z#AmQ%u<-9~darKu-?}Ig_psM$ZlSQ+JuMh0W2O`8vUnbfPW1ic94E_>#Cc=d2xvi; zP}ne+^RR=q08tI?i){85hr!jXK%GkZSw1#%!dGufM`kn~^)l1OS(V=smQ*)aV)bts zHs61JOBq+!*O0Tfm}vdf{FA>VwUBSXlYCrfn&sd*R;R@oi4vvB7LVW3-w}?)A-wjE zMUQs^{cPcqkHS&?eG+Rmjq;7H^%KLp6LFF0?qeY=2YFF#HNv6raW<;>*Plj0>(;zj z4g-fDE*$IkVmUMJzl2uKhgVVjUh!Q*a+d zKNS@uyrvaZBzS><#$l%2y^W6W$Em|1kl4i8u==U)DDsLCJkM9~vpvCgE zWEs@UJ+~I1WHU2Y(^Iz$ZgY$bHI)s#I7hZ|;3ziK-ZkZ_wx*h>iyQ)NMQTSi*gZ}0 za8rg8vx(?RE=SE(>%+MxIr`=l+qoJ}haLfFX(?*ny*CE{co|H*=;SQ-LSOQ;d;v9r z@`)(J(+L1YuT+TyZ5z&lq=scu4`b1^q-={AffMSJN|h<|VZXBA3lh%Oe`QVC01Q1m z0A^;c-nR*6050nC^3ONz5ukDqDh#g-aw2a^n#S!78RRKPyRx0Dl5R{Y8bXX$59C(X zo!yxIGl?plZ$B2Sy~bBJayuK1P+u+$6uI$ZHHYY>T)&nR8l7%UL6hXPO2X)ckhhrBB@#1kTg?XySDEc6 z_2~Y%H1QXuV|_?qkwsL0c|XH9_Z)|1?4&}v-gd5$%DADdqUqc?`OOt^CiX^x7(*;? zYN7SgGt#(0qKB;5Bl!l%t4{6E;BHT68U-NAKpq0c$k#|$kl>|XDi%%Y`I3o zyTTon33Lrbc@JK>PxY`9Ol%rc_vt_rWOd#J&7z7G29b{_Pg@JkJJIJm=9brIaOZ?< zv${Tlr`9%%3*d0O$-4&PfW6_eyv<&8IWg?f>4)Ku5!)S$;^%g~RXt2Edm&k>)juJ9 zKg{@2$EtuA-{E9(R^om+LTB;rX`u$30E;r2xlvc$=~s8PdlAfwU79AC-`Ki*iFE3N zNLVi|!s%Q2x|q@*LUarKR$kbBR8jggg&zWKPL8{Lz4=Va|FJ<@1*O>$Te_Fc&#bi~ zZLaAfH%gRb1+jruTb~pE6wTn9Hkip!v=}ZZtT%`-vdgve#d-rcu_lM3dbgB=oW=@! zn?|+|Gm)TQLh*|bw^6uD~{Tj)6?`rWlZkLy{#d%mG^*G zTQ^S#69K!nt!g4w_^K6@zA*H0Hd;#|TH`C#1b|;p4{TJiSSqYM9Qk>L{6;yHx5ln^(on{G(In3led+~m!dmiSi30-D-3XGoy zT`2hAERVQvrf#af2LcWq3??yW{Zc_JBZg7-64PJOBb_v?$|Azc8u8>)i8N)u%jpZH zd(+sS%{3^=E1Q*}3r5B(%2Ob^OTvAU!J@$i}zcrF{{e zTT{XBy}Q(?!g3$oF*<7zRQV6pSc`9iY?K(Q;kq7uPu4G6Li;k272dNe-eJ0fk|A~k zA)4YT>cz$M+9lV!;f?qs$Fa!;S0b?oKq}k;0CAqPl{_P6ER+>~QkLVkXk<4>Z_zgF z(n8hqLcz7gBeXxj@ez;HyB}PL;S5XmbCs8vwgbI{fnl9~6;JWuO>+@P!D4i&CIK8- zMb8~-102z|t^%4-_D>`cgWL{e4~wDiEqXBN_(Ma4dBu>`z#!HvIUVEDsO9Z3u~@e^ z#@jTl1vTl>c0sG!rnFKkI$&Hc83G-fN}x{osdN_cX}6x$);@rG(g7%Xu`x;ETOFX$ zp|&+|8P5FD1e+{((X>=QujxfH1B1b0B=C%GZTqbuV)=7shZmp4(%DThgiajF#>QPm zqK(|Y#Yl|Cb$=yN2QyC9vLln7lVXu>0&qFndMb87G{!Q31W-EK1wRNw2bAQ%h(uKQ z4l&}OcTT7zt|qXsQkvdVN>o+>-eW2u%axQowSYV9d0~%M1M#(+>mIBEo$r*OBNFae z3M*3a>|Y5l7>_#ZIl;PDGHf9mAb~SP@I3uvosfC~}j=P75 z)UUC>r&&7Tp-=7@__a#O)9%F^_0FoptlFNXP}B>2WD`tGiK06kDn}dY8-??o`!nzT zaA=6)+f0oSNMG58X;?ETIF=lGoDhhRaJy4u3gO1XZTHV8ey`g;!wf2T$FMa*ab#wc zzfD4h&%FOCvSm}Vqi;uO<@+;Gc%S}#RxD!Eh;M*=x@RyhseJo8!q1x&^54H(uuxYu m!z@j;Nw4nlMmr;3jdpZX;bHWf0sumiEPs3$86y4vI{XW91CHSU literal 0 HcmV?d00001 diff --git a/syncthing/translations/en.json b/syncthing/translations/en.json new file mode 100644 index 00000000..d6914733 --- /dev/null +++ b/syncthing/translations/en.json @@ -0,0 +1,77 @@ +{ + "settings.url.label": "GUI URL", + "settings.url.description": "Syncthing GUI base URL. Leave empty to autodetect from config.xml.", + "settings.api_key.label": "API key", + "settings.api_key.description": "Syncthing REST API key. Leave empty to autodetect from config.xml.", + "settings.config_path.label": "config.xml path", + "settings.config_path.description": "Explicit path to Syncthing's config.xml for autodetection.", + "settings.poll_interval.label": "Poll interval (seconds)", + "settings.poll_interval.description": "How often the Syncthing status is refreshed.", + "settings.folders.label": "Monitored folder IDs", + "settings.folders.description": "Folder IDs to monitor. Leave empty to monitor all folders.", + "settings.insecure_tls.label": "Skip TLS verification", + "settings.insecure_tls.description": "Accept self-signed certificates on HTTPS GUI URLs (uses curl -k).", + "settings.show_pending.label": "Show pending count", + "settings.show_pending.description": "Show the number of pending items next to the icon while syncing.", + "state.idle": "Up to date", + "state.syncing": "Syncing", + "state.paused": "Paused", + "state.disconnected": "No devices connected", + "state.offline": "Syncthing unreachable", + "state.unauthorized": "API key rejected", + "state.error": "Error", + "state.unconfigured": "Not configured", + "state.disabled": "Disabled", + "summary.idle-devices": "Up to date — {connected}/{configured} devices connected.", + "summary.idle-no-devices": "Up to date. No remote devices configured.", + "summary.syncing-items": "Syncing, {count} items pending.", + "summary.syncing-folders": "Syncing {count} folders.", + "summary.paused": "All monitored folders are paused.", + "summary.disconnected": "No remote devices are connected.", + "summary.offline": "The Syncthing GUI cannot be reached.", + "summary.unauthorized": "Syncthing rejected the API key.", + "summary.error": "One or more folders reported errors.", + "summary.unconfigured": "Connection is not configured.", + "detail.no-url": "The GUI URL could not be resolved from settings or config.xml.", + "detail.no-api-key": "The API key is missing. Set it manually or expose it via config.xml.", + "detail.no-folders": "No monitored folders are configured.", + "tooltip.state": "State", + "tooltip.devices": "Devices", + "tooltip.folders": "Folders", + "tooltip.pending": "Pending", + "tooltip.checked": "Last check", + "panel.title": "Syncthing", + "panel.refresh": "Refresh", + "panel.pause-all": "Pause all devices", + "panel.resume-all": "Resume all devices", + "panel.devices": "Devices", + "panel.folders": "Folders", + "panel.pending": "Pending", + "panel.folder-state": "{state} — {count} items", + "panel.folder-pause": "Pause folder", + "panel.folder-resume": "Resume folder", + "panel.folder-rescan": "Rescan folder", + "panel.no-folders": "No monitored folders.", + "panel.last-check-info": "Last check: {value}", + "shortcut.label": "Syncthing", + "panel.tab-folders": "Folders", + "panel.tab-devices": "Devices", + "panel.no-devices": "No devices.", + "panel.device-pause": "Pause device", + "panel.device-resume": "Resume device", + "device.connected": "Connected", + "device.disconnected": "Disconnected", + "device.paused": "Paused", + "settings.notify_events.label": "Event notifications", + "settings.notify_events.description": "Notify when a folder finishes syncing, goes into error, or a device connects/disconnects.", + "notify.folder-done": "Folder “{label}” is up to date.", + "notify.folder-error": "Folder “{label}” reported an error.", + "notify.device-connected": "{name} connected.", + "notify.device-disconnected": "{name} disconnected.", + "panel.open-gui": "Open web GUI", + "panel.last-activity": "Last activity: {value}", + "tooltip.rates": "Transfer", + "toast.title": "Syncthing", + "launcher.rescan-hint": "Enter: rescan {id}", + "launcher.rescan-started": "Rescan of {id} started." +} diff --git a/syncthing/translations/it.json b/syncthing/translations/it.json new file mode 100644 index 00000000..d9b370ab --- /dev/null +++ b/syncthing/translations/it.json @@ -0,0 +1,77 @@ +{ + "settings.url.label": "URL GUI", + "settings.url.description": "URL base della GUI di Syncthing. Vuoto = rilevamento automatico da config.xml.", + "settings.api_key.label": "Chiave API", + "settings.api_key.description": "Chiave API REST di Syncthing. Vuota = rilevamento automatico da config.xml.", + "settings.config_path.label": "Percorso config.xml", + "settings.config_path.description": "Percorso esplicito del config.xml di Syncthing per il rilevamento automatico.", + "settings.poll_interval.label": "Intervallo di aggiornamento (secondi)", + "settings.poll_interval.description": "Frequenza di aggiornamento dello stato di Syncthing.", + "settings.folders.label": "ID cartelle monitorate", + "settings.folders.description": "ID delle cartelle da monitorare. Vuoto = tutte le cartelle.", + "settings.insecure_tls.label": "Salta verifica TLS", + "settings.insecure_tls.description": "Accetta certificati self-signed su URL HTTPS (usa curl -k).", + "settings.show_pending.label": "Mostra elementi in coda", + "settings.show_pending.description": "Mostra il numero di elementi in coda accanto all'icona durante la sincronizzazione.", + "state.idle": "Aggiornato", + "state.syncing": "Sincronizzazione", + "state.paused": "In pausa", + "state.disconnected": "Nessun dispositivo connesso", + "state.offline": "Syncthing non raggiungibile", + "state.unauthorized": "Chiave API rifiutata", + "state.error": "Errore", + "state.unconfigured": "Non configurato", + "state.disabled": "Disabilitato", + "summary.idle-devices": "Aggiornato — {connected}/{configured} dispositivi connessi.", + "summary.idle-no-devices": "Aggiornato. Nessun dispositivo remoto configurato.", + "summary.syncing-items": "Sincronizzazione, {count} elementi in coda.", + "summary.syncing-folders": "Sincronizzazione di {count} cartelle.", + "summary.paused": "Tutte le cartelle monitorate sono in pausa.", + "summary.disconnected": "Nessun dispositivo remoto connesso.", + "summary.offline": "La GUI di Syncthing non è raggiungibile.", + "summary.unauthorized": "Syncthing ha rifiutato la chiave API.", + "summary.error": "Una o più cartelle hanno segnalato errori.", + "summary.unconfigured": "Connessione non configurata.", + "detail.no-url": "Impossibile ricavare l'URL della GUI dalle impostazioni o da config.xml.", + "detail.no-api-key": "Chiave API mancante. Impostala manualmente o esponila via config.xml.", + "detail.no-folders": "Nessuna cartella monitorata configurata.", + "tooltip.state": "Stato", + "tooltip.devices": "Dispositivi", + "tooltip.folders": "Cartelle", + "tooltip.pending": "In coda", + "tooltip.checked": "Ultimo controllo", + "panel.title": "Syncthing", + "panel.refresh": "Aggiorna", + "panel.pause-all": "Metti in pausa tutti i dispositivi", + "panel.resume-all": "Riprendi tutti i dispositivi", + "panel.devices": "Dispositivi", + "panel.folders": "Cartelle", + "panel.pending": "In coda", + "panel.folder-state": "{state} — {count} elementi", + "panel.folder-pause": "Metti in pausa la cartella", + "panel.folder-resume": "Riprendi la cartella", + "panel.folder-rescan": "Riscansiona la cartella", + "panel.no-folders": "Nessuna cartella monitorata.", + "panel.last-check-info": "Ultimo controllo: {value}", + "shortcut.label": "Syncthing", + "panel.tab-folders": "Cartelle", + "panel.tab-devices": "Dispositivi", + "panel.no-devices": "Nessun dispositivo.", + "panel.device-pause": "Metti in pausa il dispositivo", + "panel.device-resume": "Riprendi il dispositivo", + "device.connected": "Connesso", + "device.disconnected": "Disconnesso", + "device.paused": "In pausa", + "settings.notify_events.label": "Notifiche eventi", + "settings.notify_events.description": "Notifica quando una cartella finisce di sincronizzare, va in errore o un dispositivo si connette/disconnette.", + "notify.folder-done": "Cartella “{label}” sincronizzata.", + "notify.folder-error": "La cartella “{label}” ha segnalato un errore.", + "notify.device-connected": "{name} connesso.", + "notify.device-disconnected": "{name} disconnesso.", + "panel.open-gui": "Apri GUI web", + "panel.last-activity": "Ultima attività: {value}", + "tooltip.rates": "Trasferimento", + "toast.title": "Syncthing", + "launcher.rescan-hint": "Invio: riscansiona {id}", + "launcher.rescan-started": "Riscansione di {id} avviata." +} From 5381649894a057310a7cefe7a0ebb445438e661a Mon Sep 17 00:00:00 2001 From: rylos Date: Tue, 21 Jul 2026 10:12:50 +0200 Subject: [PATCH 2/9] syncthing: ship en.json only per repo translation policy Co-Authored-By: Claude Fable 5 --- syncthing/translations/it.json | 77 ---------------------------------- 1 file changed, 77 deletions(-) delete mode 100644 syncthing/translations/it.json diff --git a/syncthing/translations/it.json b/syncthing/translations/it.json deleted file mode 100644 index d9b370ab..00000000 --- a/syncthing/translations/it.json +++ /dev/null @@ -1,77 +0,0 @@ -{ - "settings.url.label": "URL GUI", - "settings.url.description": "URL base della GUI di Syncthing. Vuoto = rilevamento automatico da config.xml.", - "settings.api_key.label": "Chiave API", - "settings.api_key.description": "Chiave API REST di Syncthing. Vuota = rilevamento automatico da config.xml.", - "settings.config_path.label": "Percorso config.xml", - "settings.config_path.description": "Percorso esplicito del config.xml di Syncthing per il rilevamento automatico.", - "settings.poll_interval.label": "Intervallo di aggiornamento (secondi)", - "settings.poll_interval.description": "Frequenza di aggiornamento dello stato di Syncthing.", - "settings.folders.label": "ID cartelle monitorate", - "settings.folders.description": "ID delle cartelle da monitorare. Vuoto = tutte le cartelle.", - "settings.insecure_tls.label": "Salta verifica TLS", - "settings.insecure_tls.description": "Accetta certificati self-signed su URL HTTPS (usa curl -k).", - "settings.show_pending.label": "Mostra elementi in coda", - "settings.show_pending.description": "Mostra il numero di elementi in coda accanto all'icona durante la sincronizzazione.", - "state.idle": "Aggiornato", - "state.syncing": "Sincronizzazione", - "state.paused": "In pausa", - "state.disconnected": "Nessun dispositivo connesso", - "state.offline": "Syncthing non raggiungibile", - "state.unauthorized": "Chiave API rifiutata", - "state.error": "Errore", - "state.unconfigured": "Non configurato", - "state.disabled": "Disabilitato", - "summary.idle-devices": "Aggiornato — {connected}/{configured} dispositivi connessi.", - "summary.idle-no-devices": "Aggiornato. Nessun dispositivo remoto configurato.", - "summary.syncing-items": "Sincronizzazione, {count} elementi in coda.", - "summary.syncing-folders": "Sincronizzazione di {count} cartelle.", - "summary.paused": "Tutte le cartelle monitorate sono in pausa.", - "summary.disconnected": "Nessun dispositivo remoto connesso.", - "summary.offline": "La GUI di Syncthing non è raggiungibile.", - "summary.unauthorized": "Syncthing ha rifiutato la chiave API.", - "summary.error": "Una o più cartelle hanno segnalato errori.", - "summary.unconfigured": "Connessione non configurata.", - "detail.no-url": "Impossibile ricavare l'URL della GUI dalle impostazioni o da config.xml.", - "detail.no-api-key": "Chiave API mancante. Impostala manualmente o esponila via config.xml.", - "detail.no-folders": "Nessuna cartella monitorata configurata.", - "tooltip.state": "Stato", - "tooltip.devices": "Dispositivi", - "tooltip.folders": "Cartelle", - "tooltip.pending": "In coda", - "tooltip.checked": "Ultimo controllo", - "panel.title": "Syncthing", - "panel.refresh": "Aggiorna", - "panel.pause-all": "Metti in pausa tutti i dispositivi", - "panel.resume-all": "Riprendi tutti i dispositivi", - "panel.devices": "Dispositivi", - "panel.folders": "Cartelle", - "panel.pending": "In coda", - "panel.folder-state": "{state} — {count} elementi", - "panel.folder-pause": "Metti in pausa la cartella", - "panel.folder-resume": "Riprendi la cartella", - "panel.folder-rescan": "Riscansiona la cartella", - "panel.no-folders": "Nessuna cartella monitorata.", - "panel.last-check-info": "Ultimo controllo: {value}", - "shortcut.label": "Syncthing", - "panel.tab-folders": "Cartelle", - "panel.tab-devices": "Dispositivi", - "panel.no-devices": "Nessun dispositivo.", - "panel.device-pause": "Metti in pausa il dispositivo", - "panel.device-resume": "Riprendi il dispositivo", - "device.connected": "Connesso", - "device.disconnected": "Disconnesso", - "device.paused": "In pausa", - "settings.notify_events.label": "Notifiche eventi", - "settings.notify_events.description": "Notifica quando una cartella finisce di sincronizzare, va in errore o un dispositivo si connette/disconnette.", - "notify.folder-done": "Cartella “{label}” sincronizzata.", - "notify.folder-error": "La cartella “{label}” ha segnalato un errore.", - "notify.device-connected": "{name} connesso.", - "notify.device-disconnected": "{name} disconnesso.", - "panel.open-gui": "Apri GUI web", - "panel.last-activity": "Ultima attività: {value}", - "tooltip.rates": "Trasferimento", - "toast.title": "Syncthing", - "launcher.rescan-hint": "Invio: riscansiona {id}", - "launcher.rescan-started": "Riscansione di {id} avviata." -} From d94cb3948eb4e16ac2c2e10e3707ad90293e0d22 Mon Sep 17 00:00:00 2001 From: rylos Date: Tue, 21 Jul 2026 10:14:33 +0200 Subject: [PATCH 3/9] syncthing: nested translation keys per CI schema Co-Authored-By: Claude Fable 5 --- syncthing/translations/en.json | 188 ++++++++++++++++++++------------- 1 file changed, 113 insertions(+), 75 deletions(-) diff --git a/syncthing/translations/en.json b/syncthing/translations/en.json index d6914733..f6b37773 100644 --- a/syncthing/translations/en.json +++ b/syncthing/translations/en.json @@ -1,77 +1,115 @@ { - "settings.url.label": "GUI URL", - "settings.url.description": "Syncthing GUI base URL. Leave empty to autodetect from config.xml.", - "settings.api_key.label": "API key", - "settings.api_key.description": "Syncthing REST API key. Leave empty to autodetect from config.xml.", - "settings.config_path.label": "config.xml path", - "settings.config_path.description": "Explicit path to Syncthing's config.xml for autodetection.", - "settings.poll_interval.label": "Poll interval (seconds)", - "settings.poll_interval.description": "How often the Syncthing status is refreshed.", - "settings.folders.label": "Monitored folder IDs", - "settings.folders.description": "Folder IDs to monitor. Leave empty to monitor all folders.", - "settings.insecure_tls.label": "Skip TLS verification", - "settings.insecure_tls.description": "Accept self-signed certificates on HTTPS GUI URLs (uses curl -k).", - "settings.show_pending.label": "Show pending count", - "settings.show_pending.description": "Show the number of pending items next to the icon while syncing.", - "state.idle": "Up to date", - "state.syncing": "Syncing", - "state.paused": "Paused", - "state.disconnected": "No devices connected", - "state.offline": "Syncthing unreachable", - "state.unauthorized": "API key rejected", - "state.error": "Error", - "state.unconfigured": "Not configured", - "state.disabled": "Disabled", - "summary.idle-devices": "Up to date — {connected}/{configured} devices connected.", - "summary.idle-no-devices": "Up to date. No remote devices configured.", - "summary.syncing-items": "Syncing, {count} items pending.", - "summary.syncing-folders": "Syncing {count} folders.", - "summary.paused": "All monitored folders are paused.", - "summary.disconnected": "No remote devices are connected.", - "summary.offline": "The Syncthing GUI cannot be reached.", - "summary.unauthorized": "Syncthing rejected the API key.", - "summary.error": "One or more folders reported errors.", - "summary.unconfigured": "Connection is not configured.", - "detail.no-url": "The GUI URL could not be resolved from settings or config.xml.", - "detail.no-api-key": "The API key is missing. Set it manually or expose it via config.xml.", - "detail.no-folders": "No monitored folders are configured.", - "tooltip.state": "State", - "tooltip.devices": "Devices", - "tooltip.folders": "Folders", - "tooltip.pending": "Pending", - "tooltip.checked": "Last check", - "panel.title": "Syncthing", - "panel.refresh": "Refresh", - "panel.pause-all": "Pause all devices", - "panel.resume-all": "Resume all devices", - "panel.devices": "Devices", - "panel.folders": "Folders", - "panel.pending": "Pending", - "panel.folder-state": "{state} — {count} items", - "panel.folder-pause": "Pause folder", - "panel.folder-resume": "Resume folder", - "panel.folder-rescan": "Rescan folder", - "panel.no-folders": "No monitored folders.", - "panel.last-check-info": "Last check: {value}", - "shortcut.label": "Syncthing", - "panel.tab-folders": "Folders", - "panel.tab-devices": "Devices", - "panel.no-devices": "No devices.", - "panel.device-pause": "Pause device", - "panel.device-resume": "Resume device", - "device.connected": "Connected", - "device.disconnected": "Disconnected", - "device.paused": "Paused", - "settings.notify_events.label": "Event notifications", - "settings.notify_events.description": "Notify when a folder finishes syncing, goes into error, or a device connects/disconnects.", - "notify.folder-done": "Folder “{label}” is up to date.", - "notify.folder-error": "Folder “{label}” reported an error.", - "notify.device-connected": "{name} connected.", - "notify.device-disconnected": "{name} disconnected.", - "panel.open-gui": "Open web GUI", - "panel.last-activity": "Last activity: {value}", - "tooltip.rates": "Transfer", - "toast.title": "Syncthing", - "launcher.rescan-hint": "Enter: rescan {id}", - "launcher.rescan-started": "Rescan of {id} started." + "settings": { + "url": { + "label": "GUI URL", + "description": "Syncthing GUI base URL. Leave empty to autodetect from config.xml." + }, + "api_key": { + "label": "API key", + "description": "Syncthing REST API key. Leave empty to autodetect from config.xml." + }, + "config_path": { + "label": "config.xml path", + "description": "Explicit path to Syncthing's config.xml for autodetection." + }, + "poll_interval": { + "label": "Poll interval (seconds)", + "description": "How often the Syncthing status is refreshed." + }, + "folders": { + "label": "Monitored folder IDs", + "description": "Folder IDs to monitor. Leave empty to monitor all folders." + }, + "insecure_tls": { + "label": "Skip TLS verification", + "description": "Accept self-signed certificates on HTTPS GUI URLs (uses curl -k)." + }, + "show_pending": { + "label": "Show pending count", + "description": "Show the number of pending items next to the icon while syncing." + }, + "notify_events": { + "label": "Event notifications", + "description": "Notify when a folder finishes syncing, goes into error, or a device connects/disconnects." + } + }, + "state": { + "idle": "Up to date", + "syncing": "Syncing", + "paused": "Paused", + "disconnected": "No devices connected", + "offline": "Syncthing unreachable", + "unauthorized": "API key rejected", + "error": "Error", + "unconfigured": "Not configured", + "disabled": "Disabled" + }, + "summary": { + "idle-devices": "Up to date — {connected}/{configured} devices connected.", + "idle-no-devices": "Up to date. No remote devices configured.", + "syncing-items": "Syncing, {count} items pending.", + "syncing-folders": "Syncing {count} folders.", + "paused": "All monitored folders are paused.", + "disconnected": "No remote devices are connected.", + "offline": "The Syncthing GUI cannot be reached.", + "unauthorized": "Syncthing rejected the API key.", + "error": "One or more folders reported errors.", + "unconfigured": "Connection is not configured." + }, + "detail": { + "no-url": "The GUI URL could not be resolved from settings or config.xml.", + "no-api-key": "The API key is missing. Set it manually or expose it via config.xml.", + "no-folders": "No monitored folders are configured." + }, + "tooltip": { + "state": "State", + "devices": "Devices", + "folders": "Folders", + "pending": "Pending", + "checked": "Last check", + "rates": "Transfer" + }, + "panel": { + "title": "Syncthing", + "refresh": "Refresh", + "pause-all": "Pause all devices", + "resume-all": "Resume all devices", + "devices": "Devices", + "folders": "Folders", + "pending": "Pending", + "folder-state": "{state} — {count} items", + "folder-pause": "Pause folder", + "folder-resume": "Resume folder", + "folder-rescan": "Rescan folder", + "no-folders": "No monitored folders.", + "last-check-info": "Last check: {value}", + "tab-folders": "Folders", + "tab-devices": "Devices", + "no-devices": "No devices.", + "device-pause": "Pause device", + "device-resume": "Resume device", + "open-gui": "Open web GUI", + "last-activity": "Last activity: {value}" + }, + "shortcut": { + "label": "Syncthing" + }, + "device": { + "connected": "Connected", + "disconnected": "Disconnected", + "paused": "Paused" + }, + "notify": { + "folder-done": "Folder “{label}” is up to date.", + "folder-error": "Folder “{label}” reported an error.", + "device-connected": "{name} connected.", + "device-disconnected": "{name} disconnected." + }, + "toast": { + "title": "Syncthing" + }, + "launcher": { + "rescan-hint": "Enter: rescan {id}", + "rescan-started": "Rescan of {id} started." + } } From 6d69156d9a46820b38e27b7a96fcbbe37a305a99 Mon Sep 17 00:00:00 2001 From: rylos Date: Mon, 27 Jul 2026 00:19:50 +0200 Subject: [PATCH 4/9] syncthing: drop the curl fallback, declare the openers, tidy the panel Review follow-up on #71: - every REST request now goes through noctalia.http with allow_insecure_tls (plugin_api 3 -> 7); the curl branch that interpolated the API key, base URL and body into a shell command line is gone, and the GUI opener shell-quotes its URL. - declare gio and xdg-open in dependencies and document all three commands in the README Requirements section. - format the last-check time with strftime, seed bar and desktop widgets from the current snapshot instead of waiting for the next publish, and give the panel room for four device rows without a border on each. --- syncthing/README.md | 17 ++++++++--- syncthing/bar.luau | 7 +++++ syncthing/desktop.luau | 6 ++++ syncthing/panel.luau | 12 ++++---- syncthing/plugin.toml | 6 ++-- syncthing/service.luau | 52 +++++++++++++--------------------- syncthing/translations/en.json | 2 +- 7 files changed, 55 insertions(+), 47 deletions(-) diff --git a/syncthing/README.md b/syncthing/README.md index 950aa99b..e4850986 100644 --- a/syncthing/README.md +++ b/syncthing/README.md @@ -17,9 +17,17 @@ directly — no helper scripts. ## Requirements +| Command | Needed for | +| --- | --- | +| `syncthing` | The daemon this plugin talks to over its REST API. | +| `gio` | The *Open web GUI* action (`glib2`). | +| `xdg-open` | Fallback for that action when `gio` is unavailable (`xdg-utils`). | + Install `syncthing` and have it running for this user. The plugin autodetects the GUI URL and API key from `~/.local/state/syncthing/config.xml` (or `~/.config/syncthing/config.xml`), so it usually works with zero configuration. +The two openers are only used by the *Open web GUI* button; everything else +goes through `noctalia.http`. ## Usage @@ -58,7 +66,7 @@ the GUI URL and API key from `~/.local/state/syncthing/config.xml` (or | `poll_interval` | `int` | `10` | Status refresh interval in seconds (2–300). | | `notify_events` | `bool` | `true` | Desktop notifications for folder done/error and device connect/disconnect. | | `folders` | `string_list` | `[]` | Folder IDs to monitor. Empty = all folders (advanced). | -| `insecure_tls` | `bool` | `false` | Accept self-signed certificates on HTTPS GUI URLs (advanced; uses `curl -k`). | +| `insecure_tls` | `bool` | `false` | Accept self-signed certificates on HTTPS GUI URLs (advanced). | | `show_pending` | `bool` | `true` | Bar widget: show completion %/pending count while syncing. | ## IPC @@ -79,9 +87,10 @@ noctalia msg panel-toggle rylos/syncthing:panel `/rest/stats/folder`, `/rest/db/status`, and the action endpoints (`/rest/config/folders|devices/` PATCH, `/rest/db/scan` POST, `/rest/system/pause|resume` POST). No other network access. -- **Processes**: `curl` is spawned only when `insecure_tls` is enabled on an - HTTPS URL (noctalia.http cannot skip certificate verification); `gio open` - (fallback `xdg-open`) is spawned by the *Open web GUI* action. +- **Processes**: the only spawned process is the *Open web GUI* action, which + runs `gio open ` (fallback `xdg-open `). Every REST request goes + through `noctalia.http`, including the `insecure_tls` case, which sets + `allow_insecure_tls` instead of shelling out to an external HTTP client. - **Filesystem**: reads Syncthing's `config.xml` for URL/API key autodetection. Nothing is written. - The API key never leaves the machine: it is only sent as the `X-API-Key` diff --git a/syncthing/bar.luau b/syncthing/bar.luau index 68b94182..5c42edd4 100644 --- a/syncthing/bar.luau +++ b/syncthing/bar.luau @@ -143,6 +143,13 @@ end) function update() noctalia.setUpdateInterval(2000) + -- state.watch only fires on the next publish, so a widget created after the + -- service's last poll would sit at "unconfigured" until then; read whatever + -- snapshot is already there. + if not snapshot then + local encoded = noctalia.state.get("st.snapshot") + snapshot = encoded and noctalia.json.decode(encoded) or nil + end render() end diff --git a/syncthing/desktop.luau b/syncthing/desktop.luau index 7a375f3e..151b8884 100644 --- a/syncthing/desktop.luau +++ b/syncthing/desktop.luau @@ -83,5 +83,11 @@ end) function update() noctalia.setUpdateInterval(5000) + -- See bar.luau: watch only fires on the next publish, so pick up the current + -- snapshot when this widget starts. + if not snapshot then + local encoded = noctalia.state.get("st.snapshot") + snapshot = encoded and noctalia.json.decode(encoded) or nil + end render() end diff --git a/syncthing/panel.luau b/syncthing/panel.luau index 945ffa6c..85bee653 100644 --- a/syncthing/panel.luau +++ b/syncthing/panel.luau @@ -11,6 +11,9 @@ -- folder id into a slot table the numbered globals read back. local MAX_FOLDER_SLOTS = 16 +-- Must match height = … in the [[panel]] entry of plugin.toml: the root +-- column's height is what actually sizes the panel content. +local PANEL_HEIGHT = 530 local snapshot = nil local isOpen = false @@ -227,8 +230,6 @@ local function folderRow(folder, slot) align = "center", fill = "surface_variant/0.5", radius = 8, - border = `{color}/0.5`, - borderWidth = 1, }, { ui.box({ width = 8, height = 8, radius = 4, fill = color }), ui.column({ gap = 2, flexGrow = 1 }, { @@ -280,8 +281,6 @@ local function deviceRow(device, slot) align = "center", fill = "surface_variant/0.5", radius = 8, - border = `{color}/0.5`, - borderWidth = 1, opacity = device.connected and 1 or 0.7, }, { ui.box({ width = 8, height = 8, radius = 4, fill = color }), @@ -366,8 +365,9 @@ local function render() end folderSlots = {} -- height pins the root to the [[panel]] size so the folder list scrolls - -- instead of growing past the panel background - panel.render(ui.column({ gap = 12, padding = 14, height = 500 }, { + -- instead of growing past the panel background; keep PANEL_HEIGHT and the + -- manifest's height = … in sync, the root wins over the manifest value. + panel.render(ui.column({ gap = 12, padding = 14, height = PANEL_HEIGHT }, { header(), ui.label({ text = statusSummary(), fontSize = 12, color = "on_surface/0.8" }), stats(), diff --git a/syncthing/plugin.toml b/syncthing/plugin.toml index 08322582..e50bcc39 100644 --- a/syncthing/plugin.toml +++ b/syncthing/plugin.toml @@ -6,10 +6,10 @@ id = "rylos/syncthing" name = "Syncthing" version = "2.1.0" -plugin_api = 3 +plugin_api = 7 author = "Marco Ziliani" license = "MIT" -dependencies = ["syncthing"] +dependencies = ["syncthing", "gio", "xdg-open"] tags = ["bar", "panel", "service", "network", "utility", "indicator"] icon = "cloud-network" description = "Syncthing status and control: bar widget, detail panel with per-folder pause/rescan, and a global pause shortcut." @@ -91,7 +91,7 @@ entry = "bar.luau" id = "panel" entry = "panel.luau" width = 400 -height = 500 +height = 530 placement = "attached" position = "auto" open_near_click = true diff --git a/syncthing/service.luau b/syncthing/service.luau index 3103adac..365b30f7 100644 --- a/syncthing/service.luau +++ b/syncthing/service.luau @@ -44,6 +44,13 @@ local function trim(s) return noctalia.string.trim(s or "") end +-- The runtime only takes a command *line*, so every dynamic argument has to be +-- shell-quoted by hand: wrap in single quotes and escape embedded ones. +local function shq(value) + local quoted = tostring(value or ""):gsub("'", "'\\''") + return `'{quoted}'` +end + local function normalizeBaseUrl(value, defaultScheme) local url = trim(value) if url == "" then @@ -123,46 +130,23 @@ end -- ── HTTP ───────────────────────────────────────────────────────────────────── -- fetch(path, opts, cb) → cb(ok, status, body). opts: method, body, noauth. --- With insecure_tls on an https URL we shell out to curl -k, because --- noctalia.http has no way to skip certificate verification. +-- Everything goes through noctalia.http; a self-signed GUI certificate is +-- handled by allow_insecure_tls (plugin API 7), never by an external client. local function fetch(path, opts, cb) - local url = cfg.baseUrl .. path - - if cfg.insecure and cfg.baseUrl:match("^https://") then - local parts = { "curl", "-ksS", "-m", "5", "-w", "'\\n%{http_code}'" } - if not opts.noauth and cfg.apiKey ~= "" then - table.insert(parts, `-H "X-API-Key: {cfg.apiKey}"`) - end - if opts.method and opts.method ~= "GET" then - table.insert(parts, `-X {opts.method}`) - end - if opts.body then - table.insert(parts, `-H "Content-Type: application/json" --data-raw '{opts.body}'`) - end - table.insert(parts, `"{url}"`) - local launched = noctalia.runAsync(table.concat(parts, " "), function(result) - if result.exitCode ~= 0 or result.timedOut then - cb(false, 0, "") - return - end - local body, code = result.stdout:match("^(.*)\n(%d+)%s*$") - cb(true, tonumber(code) or 0, body or "") - end, 8000) - if not launched then - cb(false, 0, "") - end - return - end - local headers = nil if not opts.noauth and cfg.apiKey ~= "" then headers = { `X-API-Key: {cfg.apiKey}` } end + if opts.body then + headers = headers or {} + table.insert(headers, "Content-Type: application/json") + end local started = noctalia.http({ - url = url, + url = cfg.baseUrl .. path, method = opts.method, headers = headers, body = opts.body, + allow_insecure_tls = cfg.insecure, }, function(res) cb(res.ok, res.status, res.body) end) @@ -177,7 +161,7 @@ local function emptySnapshot(state, detail) return { state = state, detail = detail or "", - checkedAt = noctalia.formatTime("hh:mm:ss"), + checkedAt = noctalia.formatTime("%H:%M:%S"), sources = { configPath = cfg.sources.configPath, urlSource = cfg.sources.urlSource, @@ -561,7 +545,9 @@ function openGui() -- gio, not xdg-open: with XDG_CURRENT_DESKTOP=KDE (set for Qt theming under -- niri) but no KDE session, xdg-open takes the kfmclient path and no-ops -- with exit 0. gio resolves the x-scheme-handler/http default directly. - noctalia.runAsync(`gio open "{cfg.baseUrl}" || xdg-open "{cfg.baseUrl}"`, function(result) + -- The runtime takes a command line, not an argv, so the URL is shell-quoted. + local url = shq(cfg.baseUrl) + noctalia.runAsync(`gio open {url} || xdg-open {url}`, function(result) noctalia.log(`[syncthing] openGui exit={result.exitCode} err={result.stderr}`) if result.exitCode ~= 0 then noctalia.notifyError(noctalia.tr("toast.title"), `open failed: {noctalia.string.trim(result.stderr)}`) diff --git a/syncthing/translations/en.json b/syncthing/translations/en.json index f6b37773..2ee3f13d 100644 --- a/syncthing/translations/en.json +++ b/syncthing/translations/en.json @@ -22,7 +22,7 @@ }, "insecure_tls": { "label": "Skip TLS verification", - "description": "Accept self-signed certificates on HTTPS GUI URLs (uses curl -k)." + "description": "Accept self-signed certificates on HTTPS GUI URLs." }, "show_pending": { "label": "Show pending count", From 587a0160ff41e167c33fd64eb3191e28f7a586be Mon Sep 17 00:00:00 2001 From: rylos Date: Mon, 27 Jul 2026 00:26:29 +0200 Subject: [PATCH 5/9] syncthing: keep the panel inside its frame, unwedge a stalled poller The root column's height is measured inside its padding, so pinning it to the manifest height pushed the footer past the panel edge; subtract the padding instead. publish() was the only place clearing `busy`, so an HTTP callback that never fired left the poller stuck with no further updates and a frozen last-check time. Restart the poll after a grace period. --- syncthing/panel.luau | 9 ++++++--- syncthing/service.luau | 19 +++++++++++++++++-- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/syncthing/panel.luau b/syncthing/panel.luau index 85bee653..b42e6f2f 100644 --- a/syncthing/panel.luau +++ b/syncthing/panel.luau @@ -11,9 +11,12 @@ -- folder id into a slot table the numbered globals read back. local MAX_FOLDER_SLOTS = 16 --- Must match height = … in the [[panel]] entry of plugin.toml: the root --- column's height is what actually sizes the panel content. +-- Must match height = … in the [[panel]] entry of plugin.toml. The root +-- column's height is what actually sizes the content, and it is measured +-- inside the padding, so the padding has to come off it or the last row is +-- clipped by the panel edge. local PANEL_HEIGHT = 530 +local PANEL_PADDING = 14 local snapshot = nil local isOpen = false @@ -367,7 +370,7 @@ local function render() -- height pins the root to the [[panel]] size so the folder list scrolls -- instead of growing past the panel background; keep PANEL_HEIGHT and the -- manifest's height = … in sync, the root wins over the manifest value. - panel.render(ui.column({ gap = 12, padding = 14, height = PANEL_HEIGHT }, { + panel.render(ui.column({ gap = 12, padding = PANEL_PADDING, height = PANEL_HEIGHT - PANEL_PADDING * 2 }, { header(), ui.label({ text = statusSummary(), fontSize = 12, color = "on_surface/0.8" }), stats(), diff --git a/syncthing/service.luau b/syncthing/service.luau index 365b30f7..e9acc35b 100644 --- a/syncthing/service.luau +++ b/syncthing/service.luau @@ -32,6 +32,8 @@ local cfg = { } local busy = false +-- Epoch seconds of the in-flight poll, for the watchdog in poll(). +local busySince = 0 local pendingPoll = false -- Previous published snapshot, for event notifications (state transitions). local prevSnapshot = nil @@ -227,6 +229,7 @@ end local function publish(snapshot) busy = false + busySince = 0 notifyTransitions(prevSnapshot, snapshot) prevSnapshot = snapshot local encoded = noctalia.json.encode(snapshot) @@ -497,12 +500,24 @@ local function fetchCore() end end +local function nowSeconds() + return tonumber(noctalia.formatTime("%s")) or 0 +end + function poll(force) + local now = nowSeconds() if busy then - pendingPoll = true - return + -- publish() is the only place that clears `busy`, so a request whose + -- callback never fires would wedge the poller silently. Give the in-flight + -- poll a generous grace period, then start over. + if busySince == 0 or (now - busySince) * 1000 < cfg.pollMs * 3 + 15000 then + pendingPoll = true + return + end + noctalia.log("[syncthing] poll watchdog: previous request never completed, restarting") end busy = true + busySince = now resolveConfig() if cfg.baseUrl == "" then From f2ad168c580fa1a06002ec411828a0219d61fe86 Mon Sep 17 00:00:00 2001 From: rylos Date: Mon, 27 Jul 2026 00:29:39 +0200 Subject: [PATCH 6/9] syncthing: give the device list room to breathe Four devices needed a scrollbar. The idle summary line repeated what the header and the device tile already say, so it now shows only for the states that add information; the column gap is tighter and the content leaves slack for the panel frame so the footer is not flush against it. --- syncthing/panel.luau | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/syncthing/panel.luau b/syncthing/panel.luau index b42e6f2f..030fa6b2 100644 --- a/syncthing/panel.luau +++ b/syncthing/panel.luau @@ -17,6 +17,9 @@ local MAX_FOLDER_SLOTS = 16 -- clipped by the panel edge. local PANEL_HEIGHT = 530 local PANEL_PADDING = 14 +-- The panel frame eats a few pixels below the content, so the last footer line +-- would otherwise sit flush against the bottom edge. +local PANEL_BOTTOM_SLACK = 10 local snapshot = nil local isOpen = false @@ -370,9 +373,17 @@ local function render() -- height pins the root to the [[panel]] size so the folder list scrolls -- instead of growing past the panel background; keep PANEL_HEIGHT and the -- manifest's height = … in sync, the root wins over the manifest value. - panel.render(ui.column({ gap = 12, padding = PANEL_PADDING, height = PANEL_HEIGHT - PANEL_PADDING * 2 }, { + panel.render(ui.column({ gap = 10, padding = PANEL_PADDING, height = PANEL_HEIGHT - PANEL_PADDING * 2 - PANEL_BOTTOM_SLACK }, { header(), - ui.label({ text = statusSummary(), fontSize = 12, color = "on_surface/0.8" }), + ui.label({ + text = statusSummary(), + fontSize = 12, + color = "on_surface/0.8", + -- Pure repetition while idle: the header already names the state and the + -- tiles already show the device count. Every other state says something + -- the rest of the panel does not. + visible = not snapshot or snapshot.state ~= "idle", + }), stats(), tabs(), folderList(), From c35dba8bec9777d87c753a7699885ff9319567fc Mon Sep 17 00:00:00 2001 From: rylos Date: Mon, 10 Aug 2026 12:18:36 +0200 Subject: [PATCH 7/9] syncthing: per-row callbacks capture their folder or device The panel bound each row to one of 16 predeclared callback slots, so both the folder and the device list were silently cut off past the sixteenth entry. Pointer props take a function, so each row's buttons now close over their own folder or device: MAX_FOLDER_SLOTS, the slot table, the *At helpers and the numbered onPause/onResume/onRescan globals are gone. plugin_api rises to 9, the level that introduced function callbacks. Co-Authored-By: Claude Opus 5 (1M context) --- syncthing/panel.luau | 124 ++++++++++-------------------------------- syncthing/plugin.toml | 2 +- 2 files changed, 29 insertions(+), 97 deletions(-) diff --git a/syncthing/panel.luau b/syncthing/panel.luau index 030fa6b2..7c6a9318 100644 --- a/syncthing/panel.luau +++ b/syncthing/panel.luau @@ -6,11 +6,9 @@ -- per-folder pause/resume and rescan actions, then details, recent errors, -- and connection info. -- --- Per-folder buttons use the predeclared-slot pattern (onPause0…, onRescan0…): --- ui.* callbacks are global function *names*, so each rendered row binds its --- folder id into a slot table the numbered globals read back. +-- Per-row buttons pass a closure that captures the row's folder or device, so +-- neither list has a length limit and there are no numbered callback globals. -local MAX_FOLDER_SLOTS = 16 -- Must match height = … in the [[panel]] entry of plugin.toml. The root -- column's height is what actually sizes the content, and it is measured -- inside the padding, so the padding has to come off it or the last row is @@ -26,8 +24,6 @@ local isOpen = false local cmdNonce = 0 -- Active list tab: "folders" | "devices" (Syncthing Tray-style tabs). local view = "folders" --- Slot table: index → { kind = "folder"|"device", id = }. -local folderSlots = {} local STATE_COLORS = { idle = "primary", @@ -102,6 +98,17 @@ local function allPaused() and snapshot.devices.paused >= snapshot.devices.configured end +-- ── Per-row actions ────────────────────────────────────────────────────────── + +local function togglePaused(kind, id, paused) + local verb = paused and "resume" or "pause" + sendCmd(`{kind}_{verb}`, id) +end + +local function rescanFolder(folder) + sendCmd("rescan", folder.id) +end + -- ── Sections ───────────────────────────────────────────────────────────────── local function header() @@ -226,8 +233,7 @@ local function tabs() }) end -local function folderRow(folder, slot) - folderSlots[slot] = { kind = "folder", id = folder.id } +local function folderRow(folder) local color = STATE_COLORS[folder.state] or "outline" return ui.row({ key = `folder-{folder.id}`, @@ -253,7 +259,9 @@ local function folderRow(folder, slot) variant = "ghost", controlSize = "sm", tooltip = folder.paused and tr("panel.folder-resume") or tr("panel.folder-pause"), - onClick = (folder.paused and "onResume" or "onPause") .. slot, + onClick = function() + togglePaused("folder", folder.id, folder.paused) + end, }), ui.button({ glyph = "refresh", @@ -261,13 +269,14 @@ local function folderRow(folder, slot) controlSize = "sm", enabled = not folder.paused, tooltip = tr("panel.folder-rescan"), - onClick = "onRescan" .. slot, + onClick = function() + rescanFolder(folder) + end, }), }) end -local function deviceRow(device, slot) - folderSlots[slot] = { kind = "device", id = device.id } +local function deviceRow(device) local color = device.connected and "primary" or "outline" local subtitle if device.paused then @@ -299,7 +308,9 @@ local function deviceRow(device, slot) variant = "ghost", controlSize = "sm", tooltip = device.paused and tr("panel.device-resume") or tr("panel.device-pause"), - onClick = (device.paused and "onResume" or "onPause") .. slot, + onClick = function() + togglePaused("device", device.id, device.paused) + end, }), }) end @@ -308,18 +319,12 @@ local function folderList() local rows = {} if snapshot then if view == "devices" then - for index, device in snapshot.devicesList do - if index > MAX_FOLDER_SLOTS then - break - end - table.insert(rows, deviceRow(device, index - 1)) + for _, device in snapshot.devicesList do + table.insert(rows, deviceRow(device)) end else - for index, folder in snapshot.folders do - if index > MAX_FOLDER_SLOTS then - break - end - table.insert(rows, folderRow(folder, index - 1)) + for _, folder in snapshot.folders do + table.insert(rows, folderRow(folder)) end end end @@ -369,7 +374,6 @@ local function render() if not isOpen then return end - folderSlots = {} -- height pins the root to the [[panel]] size so the folder list scrolls -- instead of growing past the panel background; keep PANEL_HEIGHT and the -- manifest's height = … in sync, the root wins over the manifest value. @@ -437,75 +441,3 @@ function onTabDevices() view = "devices" render() end - -local function pauseAt(slot) - local target = folderSlots[slot] - if target then - sendCmd(target.kind == "device" and "device_pause" or "folder_pause", target.id) - end -end - -local function resumeAt(slot) - local target = folderSlots[slot] - if target then - sendCmd(target.kind == "device" and "device_resume" or "folder_resume", target.id) - end -end - -local function rescanAt(slot) - local target = folderSlots[slot] - if target and target.kind == "folder" then - sendCmd("rescan", target.id) - end -end - -function onPause0() pauseAt(0) end -function onPause1() pauseAt(1) end -function onPause2() pauseAt(2) end -function onPause3() pauseAt(3) end -function onPause4() pauseAt(4) end -function onPause5() pauseAt(5) end -function onPause6() pauseAt(6) end -function onPause7() pauseAt(7) end -function onPause8() pauseAt(8) end -function onPause9() pauseAt(9) end -function onPause10() pauseAt(10) end -function onPause11() pauseAt(11) end -function onPause12() pauseAt(12) end -function onPause13() pauseAt(13) end -function onPause14() pauseAt(14) end -function onPause15() pauseAt(15) end - -function onResume0() resumeAt(0) end -function onResume1() resumeAt(1) end -function onResume2() resumeAt(2) end -function onResume3() resumeAt(3) end -function onResume4() resumeAt(4) end -function onResume5() resumeAt(5) end -function onResume6() resumeAt(6) end -function onResume7() resumeAt(7) end -function onResume8() resumeAt(8) end -function onResume9() resumeAt(9) end -function onResume10() resumeAt(10) end -function onResume11() resumeAt(11) end -function onResume12() resumeAt(12) end -function onResume13() resumeAt(13) end -function onResume14() resumeAt(14) end -function onResume15() resumeAt(15) end - -function onRescan0() rescanAt(0) end -function onRescan1() rescanAt(1) end -function onRescan2() rescanAt(2) end -function onRescan3() rescanAt(3) end -function onRescan4() rescanAt(4) end -function onRescan5() rescanAt(5) end -function onRescan6() rescanAt(6) end -function onRescan7() rescanAt(7) end -function onRescan8() rescanAt(8) end -function onRescan9() rescanAt(9) end -function onRescan10() rescanAt(10) end -function onRescan11() rescanAt(11) end -function onRescan12() rescanAt(12) end -function onRescan13() rescanAt(13) end -function onRescan14() rescanAt(14) end -function onRescan15() rescanAt(15) end diff --git a/syncthing/plugin.toml b/syncthing/plugin.toml index e50bcc39..9dd51d67 100644 --- a/syncthing/plugin.toml +++ b/syncthing/plugin.toml @@ -6,7 +6,7 @@ id = "rylos/syncthing" name = "Syncthing" version = "2.1.0" -plugin_api = 7 +plugin_api = 9 author = "Marco Ziliani" license = "MIT" dependencies = ["syncthing", "gio", "xdg-open"] From 4ca18716c0b6f59b074c1a4a26ced4ea78fbd248 Mon Sep 17 00:00:00 2001 From: rylos Date: Mon, 10 Aug 2026 12:39:19 +0200 Subject: [PATCH 8/9] syncthing: stop re-parsing config.xml on every poll, generation-tag the poll MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The poller kept stalling, and the log finally named it: 'update' exceeded its CPU budget, in parseConfigXml. Every poll re-read Syncthing's config.xml — tens of kilobytes — and located the block with a non-greedy pattern that rescans the tail of the file for each starting position. When the callback was cut off mid-flight, busy stayed set and the poller never recovered. config.xml is now parsed once and cached, by index rather than by pattern. The cache is dropped when the plugin settings change and while nothing is answering, so a Syncthing that gets installed or reconfigured later is still picked up. The watchdog it needed becomes a generation number instead: callbacks from a superseded poll are dropped rather than racing the current one, which the grace-period watchdog could not prevent. Co-Authored-By: Claude Opus 5 (1M context) --- syncthing/service.luau | 125 ++++++++++++++++++++++++++++------------- 1 file changed, 87 insertions(+), 38 deletions(-) diff --git a/syncthing/service.luau b/syncthing/service.luau index e9acc35b..9450253e 100644 --- a/syncthing/service.luau +++ b/syncthing/service.luau @@ -13,6 +13,15 @@ local DEFAULT_CONFIG_PATHS = { "~/.config/syncthing/config.xml", } +-- States that mean "we are not talking to Syncthing", where re-reading +-- config.xml on the next poll is worth the cost. +local NEEDS_CONFIG_STATES = { + ["unconfigured"] = true, + ["offline"] = true, + ["unauthorized"] = true, + ["error"] = true, +} + local ACTIVE_FOLDER_STATES = { ["cleaning"] = true, ["scan-waiting"] = true, @@ -31,10 +40,16 @@ local cfg = { sources = { configPath = "", urlSource = "none", apiKeySource = "none" }, } -local busy = false --- Epoch seconds of the in-flight poll, for the watchdog in poll(). -local busySince = 0 -local pendingPoll = false +-- A poll spans several requests, so a callback that never fires would wedge it +-- for good. Every poll carries a generation number: callbacks from a +-- superseded poll are dropped, and a poll still pending after this long is +-- considered lost, letting the next one take over. +local STUCK_MS = 45000 + +local pollGeneration = 0 +local pollPending = false +local pollStartedAt = 0 +local pollAgain = false -- Previous published snapshot, for event notifications (state transitions). local prevSnapshot = nil -- Last connection totals, for transfer-rate computation between polls. @@ -66,23 +81,44 @@ local function normalizeBaseUrl(value, defaultScheme) return url end +-- Parsed config.xml, or false once a run found no readable file. Reading and +-- scanning that file on every poll is what blew the callback CPU budget: it is +-- tens of kilobytes, the poll runs every few seconds, and `update` gets a small +-- slice. It changes about as often as Syncthing is reconfigured, so parse it +-- once and drop the cache when the plugin settings change. +local configXml = nil + local function parseConfigXml() + if configXml ~= nil then + return configXml or nil + end local explicit = trim(noctalia.getConfig("config_path")) local candidates = explicit ~= "" and { explicit } or DEFAULT_CONFIG_PATHS for _, candidate in candidates do local xml = noctalia.readFile(candidate) if xml then - local guiAttrs = xml:match("]*)>") or "" - local guiBlock = xml:match("]*>(.-)") or "" - return { + -- Locate the block by index rather than with a non-greedy pattern: + -- `(.-)` rescans the tail of the file for every starting position. + local _, attrsEnd, guiAttrs = xml:find("]*)>") + local guiBlock = "" + if attrsEnd then + local closeAt = xml:find("", attrsEnd + 1, true) + if closeAt then + guiBlock = xml:sub(attrsEnd + 1, closeAt - 1) + end + end + guiAttrs = guiAttrs or "" + configXml = { path = noctalia.expandPath(candidate), enabled = guiAttrs:match('enabled%s*=%s*"false"') == nil, tls = guiAttrs:match('tls%s*=%s*"true"') ~= nil, address = trim(guiBlock:match("
([^<]*)
")), apiKey = trim(guiBlock:match("([^<]*)")), } + return configXml end end + configXml = false return nil end @@ -227,17 +263,22 @@ local function notifyTransitions(old, new) end end -local function publish(snapshot) - busy = false - busySince = 0 +local function publish(snapshot, generation) + -- A late callback from a poll that was already given up on must not clobber + -- the snapshot a newer poll published. + if generation and generation ~= pollGeneration then + return + end + pollPending = false + pollStartedAt = 0 notifyTransitions(prevSnapshot, snapshot) prevSnapshot = snapshot local encoded = noctalia.json.encode(snapshot) if encoded then noctalia.state.set("st.snapshot", encoded) end - if pendingPoll then - pendingPoll = false + if pollAgain then + pollAgain = false poll(true) end end @@ -304,7 +345,7 @@ local function classify(snapshot) end -- Third stage: per-folder db status, joined, then classify + publish. -local function fetchFolderStatuses(snapshot, monitoredFolders) +local function fetchFolderStatuses(snapshot, monitoredFolders, generation) local remaining = 0 local summaries = {} @@ -333,7 +374,7 @@ local function fetchFolderStatuses(snapshot, monitoredFolders) snapshot.syncPercent = math.floor(100 * (globalB - needB) / globalB) end end - publish(classify(snapshot)) + publish(classify(snapshot), generation) end for index, folder in monitoredFolders do @@ -364,7 +405,7 @@ local function fetchFolderStatuses(snapshot, monitoredFolders) end -- Second stage: the four core endpoints in parallel, joined. -local function fetchCore() +local function fetchCore(generation) local endpoints = { system = "/rest/system/status", config = "/rest/config", @@ -383,11 +424,11 @@ local function fetchCore() local function done() if failedStatus then if failedStatus == 401 or failedStatus == 403 then - publish(emptySnapshot("unauthorized")) + publish(emptySnapshot("unauthorized"), generation) elseif failedStatus == 0 then - publish(emptySnapshot("offline")) + publish(emptySnapshot("offline"), generation) else - publish(emptySnapshot("error", `HTTP {failedStatus}`)) + publish(emptySnapshot("error", `HTTP {failedStatus}`), generation) end return end @@ -481,7 +522,7 @@ local function fetchCore() return a.label:lower() < b.label:lower() end) - fetchFolderStatuses(snapshot, monitoredFolders) + fetchFolderStatuses(snapshot, monitoredFolders, generation) end for key, path in endpoints do @@ -500,45 +541,50 @@ local function fetchCore() end end -local function nowSeconds() - return tonumber(noctalia.formatTime("%s")) or 0 -end - function poll(force) - local now = nowSeconds() - if busy then - -- publish() is the only place that clears `busy`, so a request whose - -- callback never fires would wedge the poller silently. Give the in-flight - -- poll a generous grace period, then start over. - if busySince == 0 or (now - busySince) * 1000 < cfg.pollMs * 3 + 15000 then - pendingPoll = true + if pollPending then + -- Still waiting on a poll that has not timed out: coalesce into it. + if noctalia.nowMs() - pollStartedAt < STUCK_MS then + pollAgain = true return end - noctalia.log("[syncthing] poll watchdog: previous request never completed, restarting") + -- Otherwise the poll is lost. Bumping the generation below makes its + -- callbacks no-ops if they ever do arrive. + end + pollPending = true + pollStartedAt = noctalia.nowMs() + pollGeneration += 1 + local generation = pollGeneration + -- While nothing is answering, re-read config.xml every poll: this is exactly + -- the case where the user is installing or fixing Syncthing and the file is + -- about to appear or change. Once a poll succeeds the cache stands. + if not prevSnapshot or NEEDS_CONFIG_STATES[prevSnapshot.state] then + configXml = nil end - busy = true - busySince = now resolveConfig() if cfg.baseUrl == "" then - publish(emptySnapshot("unconfigured", "no-url")) + publish(emptySnapshot("unconfigured", "no-url"), generation) return end fetch("/rest/noauth/health", { noauth = true }, function(ok, status) + if generation ~= pollGeneration then + return + end if not ok then - publish(emptySnapshot("offline")) + publish(emptySnapshot("offline"), generation) return end if status >= 400 and status ~= 404 then - publish(emptySnapshot("offline", `HTTP {status}`)) + publish(emptySnapshot("offline", `HTTP {status}`), generation) return end if cfg.apiKey == "" then - publish(emptySnapshot("unconfigured", "no-api-key")) + publish(emptySnapshot("unconfigured", "no-api-key"), generation) return end - fetchCore() + fetchCore(generation) end) end @@ -601,6 +647,9 @@ noctalia.state.watch("st.cmd", function(raw) end) function onConfigChanged() + -- config_path may now point elsewhere, and a user who just fixed their + -- Syncthing setup expects the next poll to pick it up. + configXml = nil poll(true) end From a71fd9183adfd63b6013c04a59d59934efa8bc73 Mon Sep 17 00:00:00 2001 From: rylos Date: Sat, 15 Aug 2026 18:00:54 +0200 Subject: [PATCH 9/9] syncthing: put the folder state in the launcher subtitle, not the badge A LauncherResult badge is drawn in the icon slot and scaled up to fill it, so "Up to date" covered the whole row and hid the state glyph. The state now reads as the first half of the subtitle, the way the rest of the launcher rows are built. Co-Authored-By: Claude Opus 5 (1M context) --- syncthing/launcher.luau | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/syncthing/launcher.luau b/syncthing/launcher.luau index 5ca7d1cb..dc2e9465 100644 --- a/syncthing/launcher.luau +++ b/syncthing/launcher.luau @@ -35,12 +35,16 @@ function onQuery(text) continue end end + -- The state goes in the subtitle, not in `badge`: a badge is drawn in the + -- icon slot and scaled up to fill it, so a whole phrase like "Up to date" + -- swallows the row and hides the state glyph. + local status = noctalia.tr(`state.{folder.state}`) + local action = noctalia.tr("launcher.rescan-hint", { id = folder.id }) table.insert(results, { id = folder.id, title = folder.label, - subtitle = noctalia.tr("launcher.rescan-hint", { id = folder.id }), + subtitle = `{status} · {action}`, glyph = STATE_GLYPHS[folder.state] or "cloud-question", - badge = noctalia.tr(`state.{folder.state}`), score = score, }) end