diff --git a/warp/LICENSE b/warp/LICENSE new file mode 100644 index 00000000..316303c7 --- /dev/null +++ b/warp/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Levi + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/warp/README.md b/warp/README.md new file mode 100644 index 00000000..c382a65a --- /dev/null +++ b/warp/README.md @@ -0,0 +1,43 @@ +# Cloudflare WARP + +Control Cloudflare WARP from Noctalia and inspect the live tunnel connection, traffic, and security statistics. + +## Plugin + +| Field | Value | +| --- | --- | +| ID | `levi/warp` | +| Entries | Bar widget: `warp`; panel: `panel`; service: `service`; Control Center shortcut: `toggle` | + +## Requirements + +Install `warp-cli` from Cloudflare WARP and keep the `warp-svc` service running. The WARP client must be registered. + +## Usage + +Add `levi/warp:warp` as a bar widget or `levi/warp:toggle` as a Control Center shortcut in Noctalia Settings. + +- Left-click the bar widget to open the panel. +- Right-click the bar widget, or click the Control Center shortcut, to connect or disconnect WARP. +- Use the panel selector to switch between WARP, DoH, DoT, proxy, and tunnel-only modes. The panel shows a description of each mode and keeps the current selection visible while `warp-cli` applies it. +- Use the refresh button to request fresh status and tunnel statistics. + +Open the panel directly with: + +```sh +noctalia msg panel-toggle levi/warp:panel +``` + +## IPC + +```sh +noctalia msg plugin levi/warp:service all toggle +noctalia msg plugin levi/warp:service all refresh +noctalia msg plugin levi/warp:service all set-mode warp+doh +``` + +The `set-mode` event accepts `warp`, `doh`, `warp+doh`, `dot`, `warp+dot`, `proxy`, or `tunnel_only`. + +## Notes + +The plugin runs `warp-cli status`, `warp-cli settings list`, and `warp-cli tunnel stats` for read-only data. User actions run `warp-cli connect`, `warp-cli disconnect`, or `warp-cli mode `. It does not make network requests or write files itself; Cloudflare WARP owns the tunnel and its configuration. diff --git a/warp/panel.luau b/warp/panel.luau new file mode 100644 index 00000000..6ecb89c1 --- /dev/null +++ b/warp/panel.luau @@ -0,0 +1,263 @@ +--!nonstrict +-- Connection controls and live tunnel statistics. + +local status = { + state = "checking", + available = false, + busy = false, + message = "", + mode = "", + protocol = "", + endpoint = "", + colo = "", + latencyMs = nil, + loss = nil, + bytesSent = nil, + bytesReceived = nil, + handshakeSecs = nil, + tlsVersion = "", + tlsCurve = "", + postQuantum = false, + alwaysOn = false, + switchLocked = false, + splitTunnelMode = "", + splitTunnelCount = 0, + statsAvailable = false, +} + +local MODE_IDS = { "warp", "doh", "warp+doh", "dot", "warp+dot", "proxy", "tunnel_only" } + +local function modeOptions() + return { + "WARP", + "DoH", + "WARP + DoH", + "DoT", + "WARP + DoT", + "SOCKS5 proxy", + "Tunnel only", + } +end + +local function modeDescription() + local descriptions = { + warp = "panel.mode_warp_description", + doh = "panel.mode_doh_description", + ["warp+doh"] = "panel.mode_warp_doh_description", + dot = "panel.mode_dot_description", + ["warp+dot"] = "panel.mode_warp_dot_description", + proxy = "panel.mode_proxy_description", + tunnel_only = "panel.mode_tunnel_only_description", + } + return noctalia.tr(descriptions[status.mode] or "panel.mode-description") +end + +local function modeIndex() + for index, id in ipairs(MODE_IDS) do + if id == status.mode then + return index - 1 + end + end + return 0 +end + +local function send(action, mode) + noctalia.state.set("warp.command", { action = action, mode = mode }) +end + +local function stateLabel(state) + local known = { + checking = true, + connected = true, + connecting = true, + disconnecting = true, + disconnected = true, + unavailable = true, + error = true, + } + return noctalia.tr(if known[state] then `status.{state}` else "status.error") +end + +local function valueOrDash(value) + if value == nil or value == "" then + return "—" + end + return tostring(value) +end + +local function formatBytes(value) + local bytes = tonumber(value) + if bytes == nil then + return "—" + end + + local units = { "B", "KiB", "MiB", "GiB", "TiB" } + local index = 1 + while bytes >= 1024 and index < #units do + bytes /= 1024 + index += 1 + end + local decimals = if index == 1 or bytes >= 10 then 0 else 1 + return string.format(`%.{decimals}f %s`, bytes, units[index]) +end + +local function formatLatency(value) + local latency = tonumber(value) + return if latency == nil then "—" else string.format("%.0f ms", latency) +end + +local function formatLoss(value) + local loss = tonumber(value) + return if loss == nil then "—" else string.format("%.2f%%", loss * 100) +end + +local function formatDuration(value) + local seconds = tonumber(value) + if seconds == nil then + return "—" + elseif seconds < 60 then + return string.format("%.0f s", seconds) + end + return string.format("%dm %02ds", math.floor(seconds / 60), math.floor(seconds % 60)) +end + +local function statRow(label, value, color) + return ui.row({ gap = 8, justify = "space_between", align = "center" }, { + ui.label({ text = label, color = "on_surface_variant", fontSize = 12 }), + ui.label({ text = valueOrDash(value), color = color or "on_surface", fontSize = 12, fontWeight = "bold", textAlign = "right", maxWidth = 240 }), + }) +end + +local function section(title, rows) + local children = { + ui.label({ text = title, color = "primary", fontSize = 12, fontWeight = "bold" }), + } + for _, row in ipairs(rows) do + table.insert(children, row) + end + return ui.column({ gap = 7, padding = 10, fill = "surface_variant/0.35", radius = 8 }, children) +end + +local function render() + local connected = status.state == "connected" or status.state == "connecting" + local stateColor = if status.state == "connected" then "primary" + elseif status.state == "error" or status.state == "unavailable" then "error" + else "on_surface_variant" + local splitTunnel = if status.splitTunnelMode == "" then "—" + else `{status.splitTunnelMode} · {status.splitTunnelCount}` + local pq = if not status.statsAvailable then "—" + elseif status.postQuantum then noctalia.tr("panel.enabled") + else noctalia.tr("panel.disabled") + + local details = { + section(noctalia.tr("panel.connection"), { + ui.column({ gap = 4 }, { + ui.row({ gap = 8, justify = "space_between", align = "center" }, { + ui.label({ text = noctalia.tr("panel.mode"), color = "on_surface_variant", fontSize = 12 }), + ui.select({ + options = modeOptions(), + selectedIndex = modeIndex(), + enabled = status.available and not status.busy and not status.switchLocked, + onChange = "onModeChanged", + width = 220, + }), + }), + ui.label({ text = modeDescription(), color = "on_surface_variant", fontSize = 11, maxLines = 2 }), + }), + statRow(noctalia.tr("panel.protocol"), status.protocol), + statRow(noctalia.tr("panel.endpoint"), status.endpoint), + statRow(noctalia.tr("panel.edge"), status.colo), + statRow(noctalia.tr("panel.latency"), formatLatency(status.latencyMs)), + statRow(noctalia.tr("panel.loss"), formatLoss(status.loss)), + }), + section(noctalia.tr("panel.traffic"), { + statRow(noctalia.tr("panel.received"), formatBytes(status.bytesReceived)), + statRow(noctalia.tr("panel.sent"), formatBytes(status.bytesSent)), + statRow(noctalia.tr("panel.handshake"), formatDuration(status.handshakeSecs)), + }), + section(noctalia.tr("panel.security"), { + statRow(noctalia.tr("panel.tls"), status.tlsVersion), + statRow(noctalia.tr("panel.key-exchange"), status.tlsCurve), + statRow(noctalia.tr("panel.post-quantum"), pq, if status.postQuantum then "primary" else "on_surface_variant"), + }), + section(noctalia.tr("panel.policy"), { + statRow(noctalia.tr("panel.always-on"), if status.alwaysOn then noctalia.tr("panel.enabled") else noctalia.tr("panel.disabled")), + statRow(noctalia.tr("panel.split-tunnel"), splitTunnel), + }), + } + + panel.render(ui.column({ gap = 10, padding = 12, flexGrow = 1 }, { + ui.row({ gap = 8, align = "center" }, { + ui.glyph({ name = "cloud-lock", size = 20, color = stateColor }), + ui.label({ text = noctalia.tr("title"), fontSize = 16, fontWeight = "bold", flexGrow = 1 }), + ui.button({ glyph = "refresh", variant = "ghost", tooltip = noctalia.tr("panel.refresh"), onClick = "onRefresh" }), + ui.button({ glyph = "close", variant = "ghost", onClick = "onCloseClicked" }), + }), + ui.column({ gap = 8, padding = 12, fill = "surface_variant/0.5", radius = 10 }, { + ui.row({ justify = "space_between", align = "center" }, { + ui.column({ gap = 2 }, { + ui.label({ text = stateLabel(status.state), color = stateColor, fontSize = 15, fontWeight = "bold" }), + ui.label({ text = status.message, color = "error", fontSize = 11, maxLines = 2, maxWidth = 300 }), + }), + ui.toggle({ checked = connected, enabled = status.available and not status.busy, onChange = "onToggle" }), + }), + }), + ui.scroll({ flexGrow = 1, gap = 8 }, details), + })) +end + +local function applyStatus(value) + if type(value) == "table" then + status.state = value.state or "checking" + status.available = value.available == true + status.busy = value.busy == true + status.message = value.message or "" + status.mode = value.mode or "" + status.protocol = value.protocol or "" + status.endpoint = value.endpoint or "" + status.colo = value.colo or "" + status.latencyMs = value.latencyMs + status.loss = value.loss + status.bytesSent = value.bytesSent + status.bytesReceived = value.bytesReceived + status.handshakeSecs = value.handshakeSecs + status.tlsVersion = value.tlsVersion or "" + status.tlsCurve = value.tlsCurve or "" + status.postQuantum = value.postQuantum == true + status.alwaysOn = value.alwaysOn == true + status.switchLocked = value.switchLocked == true + status.splitTunnelMode = value.splitTunnelMode or "" + status.splitTunnelCount = value.splitTunnelCount or 0 + status.statsAvailable = value.statsAvailable == true + end + render() +end + +function onOpen(_context) + applyStatus(noctalia.state.get("warp.status")) + send("refresh") +end + +function onToggle(_value) + if status.available and not status.busy then + send("toggle") + end +end + +function onModeChanged(index, _label) + local selected = MODE_IDS[(tonumber(index) or 0) + 1] + if selected ~= nil and not status.busy and not status.switchLocked then + send("set-mode", selected) + end +end + +function onRefresh() + send("refresh") +end + +function onCloseClicked() + panel.close() +end + +applyStatus(noctalia.state.get("warp.status")) +noctalia.state.watch("warp.status", applyStatus) diff --git a/warp/plugin.toml b/warp/plugin.toml new file mode 100644 index 00000000..82365347 --- /dev/null +++ b/warp/plugin.toml @@ -0,0 +1,31 @@ +id = "levi/warp" +name = "Cloudflare WARP" +version = "1.2.0" +plugin_api = 3 +author = "Levi" +license = "MIT" +dependencies = ["warp-cli"] +tags = ["bar", "indicator", "network", "privacy", "shortcut"] +icon = "cloud-lock" +description = "Toggle Cloudflare WARP from the bar or Control Center." + +[[service]] +id = "service" +entry = "service.luau" + +[[widget]] +id = "warp" +entry = "widget.luau" + +[[shortcut]] +id = "toggle" +entry = "shortcut.luau" + +[[panel]] +id = "panel" +entry = "panel.luau" +width = 420 +height = 520 +placement = "attached" +position = "auto" +open_near_click = true diff --git a/warp/service.luau b/warp/service.luau new file mode 100644 index 00000000..8f2a277c --- /dev/null +++ b/warp/service.luau @@ -0,0 +1,387 @@ +--!nonstrict +-- Owns warp-cli access and shares one status with every UI entry. + +local POLL_INTERVAL_MS = 10000 +local CLI = "warp-cli --accept-tos --json --no-paginate --no-ansi" +local MODES = { + warp = true, + doh = true, + ["warp+doh"] = true, + dot = true, + ["warp+dot"] = true, + proxy = true, + tunnel_only = true, +} + +local status = { + state = "checking", + available = false, + busy = false, + message = "", + mode = "", + protocol = "", + endpoint = "", + colo = "", + latencyMs = nil, + loss = nil, + bytesSent = nil, + bytesReceived = nil, + handshakeSecs = nil, + tlsVersion = "", + tlsCurve = "", + postQuantum = false, + alwaysOn = false, + switchLocked = false, + splitTunnelMode = "", + splitTunnelCount = 0, + statsAvailable = false, +} +local statusInFlight = false +local settingsInFlight = false +local statsInFlight = false +local actionInFlight = false +local lastPublished = "" +local refreshStatus + +local function publish() + local key = table.concat({ + status.state, + tostring(status.available), + tostring(status.busy), + status.message, + status.mode, + status.protocol, + status.endpoint, + status.colo, + tostring(status.latencyMs), + tostring(status.loss), + tostring(status.bytesSent), + tostring(status.bytesReceived), + tostring(status.handshakeSecs), + status.tlsVersion, + status.tlsCurve, + tostring(status.postQuantum), + tostring(status.alwaysOn), + tostring(status.switchLocked), + status.splitTunnelMode, + tostring(status.splitTunnelCount), + tostring(status.statsAvailable), + }, "|") + if key == lastPublished then + return + end + + lastPublished = key + noctalia.state.set("warp.status", { + state = status.state, + available = status.available, + busy = status.busy, + message = status.message, + mode = status.mode, + protocol = status.protocol, + endpoint = status.endpoint, + colo = status.colo, + latencyMs = status.latencyMs, + loss = status.loss, + bytesSent = status.bytesSent, + bytesReceived = status.bytesReceived, + handshakeSecs = status.handshakeSecs, + tlsVersion = status.tlsVersion, + tlsCurve = status.tlsCurve, + postQuantum = status.postQuantum, + alwaysOn = status.alwaysOn, + switchLocked = status.switchLocked, + splitTunnelMode = status.splitTunnelMode, + splitTunnelCount = status.splitTunnelCount, + statsAvailable = status.statsAvailable, + }) +end + +local function patchStatus(values) + for key, value in pairs(values) do + status[key] = value + end + publish() +end + +local function commandError(result, decoded) + if type(decoded) == "table" then + if type(decoded.error) == "string" and decoded.error ~= "" then + return decoded.error + end + if type(decoded.code) == "string" and decoded.code ~= "" then + return decoded.code + end + end + + if type(result.stderr) == "string" and result.stderr ~= "" then + return result.stderr:gsub("%s+$", "") + end + return noctalia.tr("error.command-failed") +end + +local function normalizedState(value) + local raw = string.lower(tostring(value or "")) + if raw == "connected" then + return "connected" + elseif raw == "connecting" then + return "connecting" + elseif raw == "disconnecting" then + return "disconnecting" + elseif raw == "disconnected" then + return "disconnected" + end + return "error" +end + +local function listCount(value) + return if type(value) == "table" then #value else 0 +end + +local function refreshSettings() + if settingsInFlight or not noctalia.commandExists("warp-cli") then + return + end + + settingsInFlight = true + local accepted = noctalia.runAsync(`{CLI} settings list`, function(result) + settingsInFlight = false + if actionInFlight then + return + end + local decoded = noctalia.json.decode(result.stdout or "") + local settings = if type(decoded) == "table" then decoded.settings or decoded else nil + if result.exitCode ~= 0 or type(settings) ~= "table" or settings.error ~= nil or settings.code ~= nil then + return + end + + patchStatus({ + mode = tostring(settings.operation_mode or settings.mode or ""), + protocol = if status.protocol ~= "" then status.protocol else tostring(settings.warp_tunnel_protocol or ""), + alwaysOn = settings.always_on == true, + switchLocked = settings.switch_locked == true, + splitTunnelMode = tostring(settings.split_tunnel_mode or ""), + splitTunnelCount = listCount(settings.split_tunnel_ips) + listCount(settings.split_tunnel_hosts), + }) + end, 8000) + if not accepted then + settingsInFlight = false + end +end + +local function refreshStats() + if statsInFlight or not noctalia.commandExists("warp-cli") then + return + end + + statsInFlight = true + local accepted = noctalia.runAsync(`{CLI} tunnel stats`, function(result) + statsInFlight = false + local decoded = noctalia.json.decode(result.stdout or "") + local stats = if type(decoded) == "table" then decoded.stats or decoded else nil + if result.exitCode ~= 0 or type(stats) ~= "table" or stats.error ~= nil or stats.code ~= nil then + patchStatus({ statsAvailable = false }) + return + end + + local tls = if type(stats.tls) == "table" then stats.tls else {} + local edge = if type(stats.edge) == "table" then stats.edge else {} + local endpoint = stats.v4_endpoint or stats.endpoint or stats.peer_endpoint or "" + if endpoint == "" or endpoint == "::" then + endpoint = stats.v6_endpoint or "" + end + + patchStatus({ + protocol = tostring(stats.protocol or stats.tunnel_protocol or status.protocol or ""), + endpoint = tostring(endpoint), + colo = tostring(edge.colo or stats.colo or ""), + latencyMs = tonumber(stats.estimated_latency_ms or stats.latency_ms or stats.latency), + loss = tonumber(stats.estimated_loss or stats.loss), + bytesSent = tonumber(stats.bytes_sent or stats.tx_bytes), + bytesReceived = tonumber(stats.bytes_received or stats.rx_bytes), + handshakeSecs = tonumber(stats.secs_since_last_handshake), + tlsVersion = tostring(tls.version or ""), + tlsCurve = tostring(tls.curve or ""), + postQuantum = tls.post_quantum_enabled == true, + statsAvailable = true, + }) + end, 8000) + if not accepted then + statsInFlight = false + patchStatus({ statsAvailable = false }) + end +end + +local function refreshDetails() + refreshSettings() + refreshStats() +end + +local function setMode(mode) + if actionInFlight or type(mode) ~= "string" or not MODES[mode] then + return + end + if not noctalia.commandExists("warp-cli") then + refreshStatus() + return + end + if status.switchLocked then + noctalia.notifyError(noctalia.tr("title"), noctalia.tr("error.mode-locked")) + return + end + if mode == status.mode then + return + end + + actionInFlight = true + patchStatus({ busy = true, mode = mode, message = "" }) + local accepted = noctalia.runAsync(`{CLI} mode {mode}`, function(result) + actionInFlight = false + local decoded = noctalia.json.decode(result.stdout or "") + local failed = result.exitCode ~= 0 or (type(decoded) == "table" and (decoded.error ~= nil or decoded.code ~= nil)) + if failed then + local message = commandError(result, decoded) + patchStatus({ busy = false, message = message }) + noctalia.notifyError(noctalia.tr("title"), message) + refreshStatus() + refreshDetails() + return + end + patchStatus({ busy = false }) + refreshStatus() + refreshDetails() + end, 15000) + if not accepted then + actionInFlight = false + patchStatus({ busy = false, message = noctalia.tr("error.command-failed") }) + refreshStatus() + refreshDetails() + end +end + +refreshStatus = function() + if statusInFlight or actionInFlight then + return + end + + status.available = noctalia.commandExists("warp-cli") + if not status.available then + patchStatus({ + state = "unavailable", + busy = false, + message = noctalia.tr("error.cli-missing"), + }) + return + end + + statusInFlight = true + local accepted = noctalia.runAsync(`{CLI} status`, function(result) + statusInFlight = false + local decoded = noctalia.json.decode(result.stdout or "") + + if result.exitCode == 0 and type(decoded) == "table" and type(decoded.status) == "string" then + patchStatus({ + state = normalizedState(decoded.status), + available = true, + busy = false, + message = "", + }) + refreshDetails() + return + end + + patchStatus({ + state = "error", + available = true, + busy = false, + message = commandError(result, decoded), + }) + end, 8000) + if not accepted then + statusInFlight = false + patchStatus({ + state = "error", + busy = false, + message = noctalia.tr("error.command-failed"), + }) + end +end + +local function toggle() + if actionInFlight then + return + end + if not noctalia.commandExists("warp-cli") then + refreshStatus() + refreshDetails() + noctalia.notifyError(noctalia.tr("title"), noctalia.tr("error.cli-missing")) + return + end + + local command = if status.state == "connected" or status.state == "connecting" then "disconnect" else "connect" + local pendingState = if command == "connect" then "connecting" else "disconnecting" + + actionInFlight = true + patchStatus({ + state = pendingState, + available = true, + busy = true, + message = "", + }) + + local accepted = noctalia.runAsync(`{CLI} {command}`, function(result) + actionInFlight = false + local decoded = noctalia.json.decode(result.stdout or "") + local failed = result.exitCode ~= 0 or (type(decoded) == "table" and (decoded.error ~= nil or decoded.code ~= nil)) + + if failed then + local message = commandError(result, decoded) + patchStatus({ state = "error", busy = false, message = message }) + noctalia.notifyError(noctalia.tr("title"), message) + return + end + + refreshStatus() + end, 15000) + if not accepted then + actionInFlight = false + patchStatus({ + state = "error", + busy = false, + message = noctalia.tr("error.command-failed"), + }) + end +end + +noctalia.setUpdateInterval(POLL_INTERVAL_MS) + +noctalia.state.watch("warp.command", function(command) + local action = if type(command) == "table" then command.action else command + if action == "toggle" then + toggle() + elseif action == "refresh" then + refreshStatus() + refreshDetails() + elseif action == "set-mode" and type(command) == "table" then + setMode(command.mode) + end +end) + +function update() + refreshStatus() + refreshDetails() +end + +function onIpc(event, _payload) + if event == "toggle" then + toggle() + elseif event == "refresh" or event == "status" then + refreshStatus() + refreshDetails() + elseif event == "set-mode" then + setMode(_payload) + end +end + +refreshStatus() +refreshDetails() diff --git a/warp/shortcut.luau b/warp/shortcut.luau new file mode 100644 index 00000000..9a73875f --- /dev/null +++ b/warp/shortcut.luau @@ -0,0 +1,52 @@ +--!nonstrict +-- Control Center tile backed by the same service as the bar widget. + +local status = { + state = "checking", + available = false, + busy = false, +} + +local function render() + local state = status.state + local label = noctalia.tr("shortcut.off") + + if state == "connected" then + label = noctalia.tr("shortcut.on") + elseif state == "connecting" then + label = noctalia.tr("status.connecting") + elseif state == "disconnecting" then + label = noctalia.tr("status.disconnecting") + elseif state == "error" then + label = noctalia.tr("shortcut.error") + elseif state == "unavailable" then + label = noctalia.tr("shortcut.unavailable") + end + + shortcut.setLabel(label) + shortcut.setIcon("cloud-lock", "cloud-off") + shortcut.setActive(state == "connected" or state == "connecting") + shortcut.setEnabled(status.available and not status.busy) +end + +local function applyStatus(value) + if type(value) == "table" then + status.state = value.state or "checking" + status.available = value.available == true + status.busy = value.busy == true + end + render() +end + +function onClick() + if status.available and not status.busy then + noctalia.state.set("warp.command", { action = "toggle" }) + end +end + +function onRightClick() + noctalia.state.set("warp.command", { action = "refresh" }) +end + +applyStatus(noctalia.state.get("warp.status")) +noctalia.state.watch("warp.status", applyStatus) diff --git a/warp/thumbnail.webp b/warp/thumbnail.webp new file mode 100644 index 00000000..e4825eca Binary files /dev/null and b/warp/thumbnail.webp differ diff --git a/warp/translations/en.json b/warp/translations/en.json new file mode 100644 index 00000000..8a836353 --- /dev/null +++ b/warp/translations/en.json @@ -0,0 +1,54 @@ +{ + "error": { + "cli-missing": "warp-cli is not installed", + "command-failed": "Cloudflare WARP command failed", + "mode-locked": "The WARP mode is locked by policy" + }, + "panel": { + "always-on": "Always on", + "connection": "Connection", + "disabled": "Disabled", + "edge": "Cloudflare edge", + "enabled": "Enabled", + "endpoint": "Endpoint", + "handshake": "Last handshake", + "key-exchange": "Key exchange", + "latency": "Latency", + "loss": "Packet loss", + "mode": "Mode", + "mode-description": "Select how WARP should route traffic.", + "mode_doh_description": "DNS over HTTPS only; no WARP tunnel.", + "mode_dot_description": "DNS over TLS only; no WARP tunnel.", + "mode_proxy_description": "Expose a local SOCKS5 proxy without a system tunnel.", + "mode_tunnel_only_description": "Encrypt traffic through WARP without WARP DNS.", + "mode_warp_description": "WARP tunnel with the standard WARP resolver.", + "mode_warp_doh_description": "WARP tunnel plus DNS over HTTPS.", + "mode_warp_dot_description": "WARP tunnel plus DNS over TLS.", + "policy": "Policy", + "post-quantum": "Post-quantum", + "protocol": "Protocol", + "received": "Received", + "refresh": "Refresh statistics", + "security": "Security", + "sent": "Sent", + "split-tunnel": "Split tunnel", + "tls": "TLS", + "traffic": "Traffic" + }, + "shortcut": { + "error": "WARP error", + "off": "WARP Off", + "on": "WARP On", + "unavailable": "WARP unavailable" + }, + "status": { + "checking": "Checking", + "connected": "Connected", + "connecting": "Connecting", + "disconnected": "Disconnected", + "disconnecting": "Disconnecting", + "error": "Error", + "unavailable": "Unavailable" + }, + "title": "Cloudflare WARP" +} diff --git a/warp/widget.luau b/warp/widget.luau new file mode 100644 index 00000000..ffa30ced --- /dev/null +++ b/warp/widget.luau @@ -0,0 +1,84 @@ +--!nonstrict +-- Bar widget: left click opens the status panel; right click toggles WARP. + +local status = { + state = "checking", + available = false, + busy = false, + message = "", +} + +local function send(action) + noctalia.state.set("warp.command", { action = action }) +end + +local function stateLabel(state) + local known = { + checking = true, + connected = true, + connecting = true, + disconnecting = true, + disconnected = true, + unavailable = true, + error = true, + } + return noctalia.tr(if known[state] then `status.{state}` else "status.error") +end + +local function render() + local state = status.state + local glyph = "cloud-question" + local color = "on_surface_variant" + + if state == "connected" then + glyph = "cloud-lock" + color = "primary" + elseif state == "connecting" or state == "disconnecting" then + glyph = "loader-2" + color = "secondary" + elseif state == "disconnected" then + glyph = "cloud-off" + elseif state == "unavailable" or state == "error" then + glyph = "cloud-exclamation" + color = "error" + end + + local tooltip = `{noctalia.tr("title")}: {stateLabel(state)}` + if status.message ~= "" then + tooltip ..= `\n{status.message}` + end + + barWidget.setGlyph(glyph) + barWidget.setGlyphColor(color) + barWidget.setTooltip(tooltip) + barWidget.setVisible(true) +end + +local function applyStatus(value) + if type(value) == "table" then + status.state = value.state or "checking" + status.available = value.available == true + status.busy = value.busy == true + status.message = value.message or "" + end + render() +end + +function onClick() + noctalia.togglePanel("levi/warp:panel") +end + +function onRightClick() + if not status.busy then + send("toggle") + end +end + +function onIpc(event, _payload) + if event == "toggle" or event == "refresh" then + send(event) + end +end + +applyStatus(noctalia.state.get("warp.status")) +noctalia.state.watch("warp.status", applyStatus)