Skip to content
Merged
102 changes: 102 additions & 0 deletions syncthing/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# 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

| 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

- **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 <query>` 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). |
| `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/<id>` PATCH, `/rest/db/scan` POST,
`/rest/system/pause|resume` POST). No other network access.
- **Processes**: the only spawned process is the *Open web GUI* action, which
runs `gio open <url>` (fallback `xdg-open <url>`). 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`
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.
168 changes: 168 additions & 0 deletions syncthing/bar.luau
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
--!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)
-- 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

function onClick()
noctalia.togglePanel("rylos/syncthing:panel")
end

function onRightClick()
sendCmd("refresh")
end

function onIpc(event)
if event == "refresh" then
sendCmd("refresh")
end
end
93 changes: 93 additions & 0 deletions syncthing/desktop.luau
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
--!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)
-- 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
25 changes: 25 additions & 0 deletions syncthing/icon-light.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading