diff --git a/plugins/registry.json b/plugins/registry.json index ac9e1443..403b1816 100644 --- a/plugins/registry.json +++ b/plugins/registry.json @@ -1381,6 +1381,36 @@ ], "lastUpdated": "2026-11-08", "upstream": "https://github.com/mysweetpea/prayer-times" + }, + { + "id": "zimaos", + "name": "ZimaOS", + "version": "0.1.16", + "path": "plugins/zimaos", + "author": "Felix ", + "upstream": "https://github.com/leandrofelix-dev/ryoku-zima-client", + "summary": "QS Bar card for a ZimaOS server: device name, link rate, storage, and a shortcut to the dashboard.", + "description": "QS Bar card for a ZimaOS server: device name, link rate, storage, and a shortcut to the dashboard.", + "tags": [ + "zimaos", + "nas", + "bar-widget" + ], + "accent": "#8f8378", + "surface": "#101010", + "preview": "assets/preview-widget.png", + "screenshots": [ + "assets/preview-widget.png" + ], + "manifest": "product-manifest.json", + "manifestSha256": "60419e62d301345829f8579a2e3f2cb86c42be55252659cf00e0b0e298916fa8", + "official": false, + "tagline": "QS Bar card for a ZimaOS server: device name, link rate, storage, and a shortcut to the dashboard.", + "icon": "archive", + "hosts": [ + "topbarGlyph" + ], + "lastUpdated": "2026-11-09" } ] } diff --git a/plugins/zimaos/.gitignore b/plugins/zimaos/.gitignore new file mode 100644 index 00000000..e77ff271 --- /dev/null +++ b/plugins/zimaos/.gitignore @@ -0,0 +1,5 @@ +.DS_Store +*.log +*.tmp +__pycache__/ +*.pyc diff --git a/plugins/zimaos/AGENTS.md b/plugins/zimaos/AGENTS.md new file mode 100644 index 00000000..2a23ee96 --- /dev/null +++ b/plugins/zimaos/AGENTS.md @@ -0,0 +1,98 @@ +# Agent guide for the zimaos plugin + +This folder is a Ryoku shell plugin. If you are an AI agent editing it, follow +every rule below. They are the same rules `ryoku plugin validate` enforces, so +work that breaks one will not install. This file is self-contained: you do not +need any other document to work here. + +## The rules + +R1 Place. A plugin is one folder named after its id. Author it under +`$(xdg-user-dir DOCUMENTS)/ryoku-plugins//` (fallback `~/ryoku-plugins//`); +`ryoku plugin new ` creates it there. Never write into +`~/.local/share/ryoku/plugins/` (the store's install root, receipt-owned) or +`~/.config/quickshell/` (the shipped shell). + +R2 Shape. `manifest.json` at the root; `service/Main.qml` (logic, no UI); +`content/Widget.qml` (the one view); optional `content/Panel.qml` (a bar panel); +`README.md`; `LICENSE`; `assets/preview-widget.png` (a real capture); scripts +only under `bin/` with a shebang and the exec bit. Every extra file listed in +manifest `files`. + +R3 Id. Lowercase `[a-z0-9][a-z0-9-]*`, unique, not a built-in widget id. + +R4 Imports. Only `QtQuick*`, `Quickshell*`, `Ryoku.PluginKit`, +`Ryoku.PluginKit.Singletons`, and files inside the plugin folder. Never +`shell.*`, `Ryoku.Ui*` internals, or a relative import that climbs out of the +folder. + +R5 Settings. Declared in `metadata.settings` (types the bar renders: toggle, +choice, multi, int, text); read through `pluginApi.pluginSettings` behind a +default; written only through `pluginApi.saveSetting(key, value)`. Never edit +`shell.json` or `plugins.json` directly. + +R6 Commands. Every external program the plugin runs is either in its own `bin/` +or listed in `dependencies.commands`. No `sudo`, `doas`, `su`. A privileged +action is allowed only through `pkexec`, only on an explicit click, only when +listed in manifest `capabilities.privileged` (exact command strings) and +explained in README. + +R7 Network. Every host the plugin talks to is listed in +`capabilities.network`. No `curl … | sh`, no downloading and running code. + +R8 Files. Write only under `pluginApi.stateDir` +(`$XDG_STATE_HOME/ryoku/plugins/`), `$XDG_CACHE_HOME/ryoku/plugins/`, or +a temp dir. Never touch `~/.ssh`, `/etc`, shell rc files, or another plugin's +folder. + +R9 Shell. No `sh -c` with a string built from settings or program output +(injection). Pass argv arrays. + +R10 Secrets and binaries. No tokens, keys or credentials in the tree; no +compiled binaries (ELF/Mach-O/.so); scripts only. + +R11 Honesty. `official` is never true for a community plugin. `author` is +`Name `. README says what runs, what it reads, what it writes, and every +privileged/network capability. + +## The workflow + +1. `ryoku plugin new [--bar|--desktop|--popout|--sidebar]` scaffolds this + folder (already done). This scaffold selected `topbarGlyph`. +2. Edit `service/Main.qml` for logic and `content/Widget.qml` for the view. + Edit `content/Panel.qml` only for a bar plugin. Capture + `assets/preview-widget.png`. +3. `ryoku plugin validate ` and fix every blocking finding (warnings are + advisory but read them). +4. `ryoku plugin add --yes`, then enable and place it under + **Ryoku Settings > Add-ons**. For a bar plugin, `--bar` enables it on the + current bar immediately. +5. `ryoku plugin share ` ONLY when the user asks to publish. Never share a + plugin unless asked. + +## What the host sets + +Ryoku ships four hosts: `topbarGlyph`, `desktopWidget`, `framePopout`, and +`sidebarCard`. They all mount `content/Widget.qml`; only a bar plugin may add +`content/Panel.qml`. + +The bar, desktop, and popout hosts set `pluginApi`, `s`, `active`, `density`, and +`widthBudget`. Read them; never assign them. + +For `sidebarCard`, the widget root is an `Item` with +`pragma ComponentBehavior: Bound`. The host sets `s`, `open`, `reveal`, +`tabActive`, `width`, and `pluginApi`; the card reports `implicitHeight` and +declares `signal requestClose()`. Read host-set values and never assign them. +There is no `content/Sidebar.qml`. Ryoku draws the card plate and owns its +position, size, motion, and chrome. + +`pluginApi` exposes: + +- `mainInstance`: the live `service/Main.qml` instance every view shares. +- `pluginSettings`: the resolved settings object; read behind a default. +- `pluginDir`: this plugin's folder. +- `stateDir`: `$XDG_STATE_HOME/ryoku/plugins/`; the only place to write. +- `saveSetting(key, value)`: persist one setting. +- `panelOpen` (bool), `openPanel()`, `closePanel()`, `togglePanel()`: the bar + panel controls (present but inert off the bar). The sanctioned bar-widget + click is `onClicked: pluginApi.togglePanel()`. diff --git a/plugins/zimaos/CLAUDE.md b/plugins/zimaos/CLAUDE.md new file mode 100644 index 00000000..3c35c327 --- /dev/null +++ b/plugins/zimaos/CLAUDE.md @@ -0,0 +1,4 @@ +# CLAUDE.md + +Read `AGENTS.md` in this folder before editing anything here. +It holds every rule this plugin must follow and the full authoring workflow. diff --git a/plugins/zimaos/LICENSE b/plugins/zimaos/LICENSE new file mode 100644 index 00000000..de70bacb --- /dev/null +++ b/plugins/zimaos/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Felix + +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/plugins/zimaos/README.md b/plugins/zimaos/README.md new file mode 100644 index 00000000..018f3c9d --- /dev/null +++ b/plugins/zimaos/README.md @@ -0,0 +1,39 @@ +# ZimaOS + +Plugin da QS Bar para um servidor ZimaOS. O ícone na barra é o nome ZIMA, num tom só, igual aos outros glifos, e abre o painel. Antes de entrar, o painel pede o endereço, o usuário e a senha. Depois de entrar, mostra o cartão do aparelho. + +## O que aparece + +Antes do login: **IP**, **Usuário** e **Senha**, e o botão **Entrar**. O IP fica preenchido quando já existe um endereço salvo. O exemplo do campo é só um placeholder. + +Depois do login, no ZimaOS 1.8: + +- Nome do aparelho, versão e modelo, lidos de `GET /v2/zimaos/device/info`. A foto do aparelho vem de `device_image` nesse mesmo JSON e é baixada de `/v2/zimaos/device/image?model=…&type=client`. O PNG fica em `$XDG_STATE_HOME/ryoku/plugins/zimaos/device.png` e só é baixado de novo quando o modelo muda. +- Interface e endereço IPv4 do caminho em uso (`GET /v2/zimaos/network/connection-status`). Os quatro ícones ao lado de LAN são remoto, Wi-Fi, Ethernet e Thunderbolt: o acento marca o caminho em uso, o tom claro marca um caminho disponível, e o tom apagado marca o que está desligado. +- Atalho **Ir ao painel** para a página inicial do servidor. +- Atalho **Abrir** o armazenamento (`/modules/icewhale_files/`), com o espaço livre do maior volume. O nome do volume só aparece depois do login. +- **Backup**, quando o app grid devolve um link; senão abre o painel. +- **PeerDrop** abre o módulo do servidor em `/modules/icewhale_peerdrop/`. +- **Sair** volta à mesma tela de entrar, com o IP salvo preenchido e a senha vazia. +- O cartão usa a superfície da própria barra: a mesma borda e o mesmo vidro do painel de música. Os blocos de dentro são um escurecimento leve, não uma placa opaca. + +## O que corre, lê e escreve + +- `bin/zima` (Python 3, biblioteca padrão). Um IP ou nome da rede local, sem esquema, continua em HTTP (por exemplo `192.168.0.180`). `http://` é o mesmo. `https://` usa HTTPS e verifica o certificado; a verificação não é desligada. Recusa caminho, `@` e qualquer outro esquema. Não segue redirecionamentos, para o token não ir para outro host. +- Caminhos: `POST /v1/users/login`, `POST /v1/users/refresh`, `GET /v1/sys/utilization`, `GET /v2/zimaos/network/interfaces`, `GET /v2/zimaos/network/connection-status`, `GET /v2/local_storage/storages`, `GET /v2/app_management/web/appgrid` e o device info acima. +- `xdg-open`, só quando você clica num atalho. +- O endereço é gravado com `pluginApi.saveSetting` na chave `host`. A senha não é gravada nem registrada. +- A sessão (usuário e tokens) fica em `$XDG_STATE_HOME/ryoku/plugins/zimaos/session.json`, modo `0600`. A foto do aparelho fica ao lado, em `device.png`. + +Não há comando privilegiado. Não há host fixo em `capabilities.network`: o validador do Ryoku só exige essa lista para endereços `http://` ou `https://` escritos no código, e este plugin não tem nenhum. O servidor é o que a pessoa digita. + +## Instalar + +Outra pessoa com Ryoku copia esta pasta e roda, dentro dela: + +``` +ryoku plugin validate . +ryoku plugin add . --bar --yes +``` + +O glifo entra na barra. Abra o painel, informe o IP do ZimaOS, o usuário e a senha, e toque em **Entrar**. O mesmo plugin fica em **QS Bar Settings → Community**. diff --git a/plugins/zimaos/assets/preview-widget.png b/plugins/zimaos/assets/preview-widget.png new file mode 100644 index 00000000..24689bff Binary files /dev/null and b/plugins/zimaos/assets/preview-widget.png differ diff --git a/plugins/zimaos/bin/zima b/plugins/zimaos/bin/zima new file mode 100755 index 00000000..3082faf5 --- /dev/null +++ b/plugins/zimaos/bin/zima @@ -0,0 +1,776 @@ +#!/usr/bin/env python3 +"""Talk to one ZimaOS box the way its own web UI does. + +Paths were taken from the UI shipped on the Zotac (ZimaOS v1.8): + GET /v2/zimaos/device/info public device card + GET /v2/zimaos/device/image product render (type=client) + POST /v1/users/login username + password -> session + POST /v1/users/refresh {"refresh_token"} + GET /v1/sys/utilization cpu, memory, interface counters + GET /v2/zimaos/network/interfaces interface name and IPv4 + GET /v2/zimaos/network/connection-status path in use: LAN, Remote, Thunderbolt + GET /v2/local_storage/storages pools such as ZimaOS-HD + GET /v2/app_management/web/appgrid installed apps, for a Backup link + GET /modules/icewhale_peerdrop/ PeerDrop page opened from the card + +Authorization is the raw access token, the same header the dashboard sends. +The session file is only written when its path is the plugin state file. +""" + +import json +import os +import re +import ssl +import sys +import urllib.error +import urllib.parse +import urllib.request + +TIMEOUT = 4 +STORAGE_TIMEOUT = 6 + + +def die(message, code=1): + sys.stdout.write(json.dumps({"ok": False, "error": message}, ensure_ascii=False)) + sys.stdout.write("\n") + raise SystemExit(code) + + +def normalize_host(raw): + text = (raw or "").strip() + # A typed LAN host has no scheme and stays http. https:// is opt-in and + # keeps certificate verification. Anything else is rejected. + scheme = "" + lowered = text.lower() + if lowered.startswith("https://"): + scheme = "https" + text = text[8:] + elif lowered.startswith("http://"): + scheme = "http" + text = text[7:] + if text == "" or "@" in text or "://" in text or "/" in text or "\\" in text or " " in text: + die("invalid address") + host, port = text, None + if host.startswith("["): + end = host.find("]") + if end < 0: + die("invalid address") + port = host[end + 1 :] + host = host[: end + 1] + if port.startswith(":"): + port = port[1:] + elif port != "": + die("invalid address") + elif host.count(":") == 1: + host, port = host.split(":", 1) + elif host.count(":") > 1: + die("invalid address") + ipv4 = re.fullmatch(r"(?:\d{1,3}\.){3}\d{1,3}", host or "") + name = re.fullmatch( + r"[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*", + host or "", + ) + if ipv4: + parts = [int(p) for p in host.split(".")] + if any(p > 255 for p in parts): + die("invalid address") + elif not name: + die("invalid address") + if port is not None and port != "": + if not re.fullmatch(r"\d{1,5}", port) or not (1 <= int(port) <= 65535): + die("invalid address") + location = host + ":" + str(int(port)) + else: + location = host + if scheme == "https": + return "https://" + location + return location + + +def request_origin(host): + """Return (scheme, location) for a host from normalize_host. + + A bare address is http, so a LAN host such as 192.168.0.180 is not upgraded. + An explicit http:// prefix is the same. https:// keeps the scheme so the + default TLS context can verify the server. + """ + if host.startswith("https://"): + return "https", host[len("https://") :] + if host.startswith("http://"): + return "http", host[len("http://") :] + return "http", host + + +def origin_base(host): + scheme, location = request_origin(host) + return scheme + "://" + location + + +def state_path(raw): + path = os.path.realpath(raw or "") + marker = "/ryoku/plugins/zimaos/" + if marker not in path or not path.endswith("/session.json"): + die("refusing to touch a file outside the plugin state directory") + parent = os.path.dirname(path) + if not os.path.isdir(parent): + os.makedirs(parent, mode=0o700, exist_ok=True) + return path + + +class _NoRedirect(urllib.request.HTTPRedirectHandler): + """Do not follow redirects. urllib would resend Authorization to the new host.""" + + def redirect_request(self, req, fp, code, msg, headers, newurl): + return None + + def http_error_302(self, req, fp, code, msg, headers): + return fp + + http_error_301 = http_error_303 = http_error_307 = http_error_308 = http_error_302 + + +# Default context verifies certificates. It is only used when a URL is https. +# LAN hosts stay http:// — ZimaOS on the local network does not speak TLS. +_HTTPS = ssl.create_default_context() + + +def _opener(): + return urllib.request.build_opener( + _NoRedirect(), + urllib.request.HTTPSHandler(context=_HTTPS), + ) + + +def request_bytes(host, method, path, token=None, body=None, timeout=TIMEOUT): + # Same opener for both schemes: redirects stay off, so Authorization is not + # forwarded, and HTTPS uses the default context (verify the certificate). + if not path.startswith("/"): + path = "/" + path + url = origin_base(host) + path + data = None + headers = {"Accept": "*/*"} + if body is not None: + data = json.dumps(body).encode("utf-8") + headers["Content-Type"] = "application/json" + if token: + headers["Authorization"] = token + req = urllib.request.Request(url, data=data, headers=headers, method=method) + try: + with _opener().open(req, timeout=timeout) as res: + return res.status, res.read() + except urllib.error.HTTPError as exc: + return exc.code, exc.read() + except Exception: + return 0, b"" + + +def request(host, method, path, token=None, body=None, timeout=TIMEOUT): + status, raw = request_bytes(host, method, path, token=token, body=body, timeout=timeout) + if status == 0: + return 0, {"_error": "no route to ZimaOS"} + if not raw: + return status, None + try: + return status, json.loads(raw.decode("utf-8", "replace")) + except json.JSONDecodeError: + return status, {"_error": "not json", "_body": raw[:180].decode("utf-8", "replace")} + + +def unwrap(payload): + if isinstance(payload, dict) and "data" in payload and ( + "success" in payload or "Success" in payload or "message" in payload + ): + return payload.get("data") + return payload + + +def as_number(value): + if isinstance(value, bool) or value is None: + return None + if isinstance(value, (int, float)): + return float(value) + if isinstance(value, str): + try: + return float(value) + except ValueError: + return None + return None + + +def first(obj, keys): + if not isinstance(obj, dict): + return None + for key in keys: + if key in obj and obj[key] not in (None, ""): + return obj[key] + return None + + +def load_session(path): + try: + with open(path, "r", encoding="utf-8") as handle: + data = json.load(handle) + except (OSError, json.JSONDecodeError): + return {} + return data if isinstance(data, dict) else {} + + +def save_session(path, data): + stored = {key: value for key, value in data.items() if key != "password"} + tmp = path + ".tmp" + fd = os.open(tmp, os.O_CREAT | os.O_WRONLY | os.O_TRUNC, 0o600) + owned = True + try: + os.fchmod(fd, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as handle: + owned = False + json.dump(stored, handle, ensure_ascii=False) + handle.write("\n") + except Exception: + if owned: + try: + os.close(fd) + except OSError: + pass + try: + os.unlink(tmp) + except OSError: + pass + raise + os.replace(tmp, path) + os.chmod(path, 0o600) + + +def session_for_host(path, host): + """Return the session only when it was issued for this host. + + A token saved for another server is discarded and never sent. The password + is never kept in the file. + """ + session = load_session(path) + if not session: + return {} + had_password = "password" in session + session.pop("password", None) + if session.get("host") != host: + try: + os.remove(path) + except OSError: + pass + return {} + if had_password: + save_session(path, session) + return session + + +def dig_token(payload): + if not isinstance(payload, dict): + return None + if "access_token" in payload and payload.get("access_token"): + return payload + nested = payload.get("token") + if isinstance(nested, dict) and nested.get("access_token"): + return nested + data = payload.get("data") + if isinstance(data, dict): + return dig_token(data) + return None + + +def message_of(payload): + if isinstance(payload, dict): + for key in ("message", "Message", "_error"): + if payload.get(key): + return str(payload[key]) + data = payload.get("data") + if isinstance(data, dict): + return message_of(data) + return "request failed" + + +def login(host, path): + try: + incoming = json.loads(sys.stdin.read() or "{}") + except json.JSONDecodeError: + die("invalid login payload") + username = str(incoming.get("username") or "").strip() + password = str(incoming.get("password") or "") + if username == "" or password == "": + die("username and password are required") + status, payload = request( + host, + "POST", + "/v1/users/login", + body={"username": username, "password": password}, + ) + token = dig_token(payload) + if status != 200 or not token: + die(message_of(payload) or "login failed") + save_session( + path, + { + "host": host, + "username": username, + "access_token": token.get("access_token") or "", + "refresh_token": token.get("refresh_token") or "", + }, + ) + sys.stdout.write(json.dumps({"ok": True, "username": username}, ensure_ascii=False)) + sys.stdout.write("\n") + + +def logout(path): + try: + os.remove(path) + except FileNotFoundError: + pass + sys.stdout.write(json.dumps({"ok": True}) + "\n") + + +def refresh(host, session): + refresh_token = session.get("refresh_token") or "" + if not refresh_token: + return False + status, payload = request( + host, "POST", "/v1/users/refresh", body={"refresh_token": refresh_token} + ) + token = dig_token(payload) + if status != 200 or not token or not token.get("access_token"): + return False + session["access_token"] = token.get("access_token") or "" + if token.get("refresh_token"): + session["refresh_token"] = token.get("refresh_token") + return True + + +def authed_get(host, path, session, timeout=TIMEOUT): + token = session.get("access_token") or "" + if not token: + return 401, None + status, payload = request(host, "GET", path, token=token, timeout=timeout) + if status != 401: + return status, payload + if not refresh(host, session): + session["access_token"] = "" + session["refresh_token"] = "" + return 401, payload + status, payload = request( + host, "GET", path, token=session.get("access_token") or "", timeout=timeout + ) + return status, payload + + +def device_view(payload): + data = unwrap(payload) + if not isinstance(data, dict): + return None + name = first(data, ("device_name", "name", "hostname")) + if not name: + return None + memory = data.get("memory") if isinstance(data.get("memory"), dict) else {} + cpu = data.get("cpu") if isinstance(data.get("cpu"), dict) else {} + model = str(data.get("device_model") or "") + if model.strip("X") == "" or model.lower() in ("unknown", "generic"): + model = "" + return { + "deviceName": str(name), + "osVersion": str(data.get("os_version") or ""), + "deviceModel": model, + "cpuModel": str(cpu.get("model") or ""), + "memoryBytes": as_number(memory.get("total_byte")), + } + + +def pick_ipv4(value): + if isinstance(value, str) and re.fullmatch(r"(?:\d{1,3}\.){3}\d{1,3}", value): + return value + if isinstance(value, list): + for item in value: + found = pick_ipv4(item if not isinstance(item, dict) else first(item, ("address", "ip", "ipv4"))) + if found: + return found + if isinstance(value, dict): + return pick_ipv4(first(value, ("address", "ip", "ipv4", "ipv4_address"))) + return "" + + +def iface_label(item): + text = str(first(item, ("connection", "connection_name", "label", "type", "name")) or "") + lowered = text.lower() + if lowered in ("ethernet", "lan", "wired"): + return "Ethernet" + if "wi" in lowered or "wlan" in lowered: + return "Wi-Fi" + return text or "LAN" + + +def iface_address(item): + return pick_ipv4(first(item, ("ipv4", "ip", "address", "addresses", "ipv4_address", "addr"))) or "" + + +def connect_kind(item): + text = str(item.get("connect_type") or "").lower() + name = str(item.get("name") or "").lower() + if "thunder" in text or "thunder" in name: + return "thunderbolt" + if "remote" in text or name.startswith("zt"): + return "remote" + if "wi" in text or "wlan" in name or name.startswith("wl"): + return "wifi" + if "ethernet" in text or name.startswith("eth") or name.startswith("en"): + return "ethernet" + return "" + + +def counter_kind(name): + low = str(name or "").lower() + if low.startswith("wlan") or low.startswith("wl") or "wifi" in low: + return "wifi" + if "thunder" in low: + return "thunderbolt" + if low.startswith("eth") or low.startswith("en"): + return "ethernet" + return "" + + +def link_up(item, states): + name = str(item.get("name") or "") + state = states.get(name, "") + if state == "down": + return False + if state == "up": + return True + if iface_address(item): + return True + speed = as_number(item.get("negotiated_speed")) + return speed is not None and speed > 0 + + +def network_view(payload, counters, connection): + data = unwrap(payload) + items = data if isinstance(data, list) else [] + if isinstance(data, dict): + for key in ("interfaces", "list", "nets"): + if isinstance(data.get(key), list): + items = data[key] + break + nets = [net for net in (counters or []) if isinstance(net, dict)] + states = {} + for net in nets: + name = str(net.get("name") or "") + if name: + states[name] = str(net.get("state") or "").lower() + remote = wifi = ethernet = thunderbolt = False + for item in items: + if not isinstance(item, dict): + continue + kind = connect_kind(item) + if kind and link_up(item, states): + if kind == "remote": + remote = True + elif kind == "wifi": + wifi = True + elif kind == "ethernet": + ethernet = True + elif kind == "thunderbolt": + thunderbolt = True + for name, state in states.items(): + if state != "up": + continue + kind = counter_kind(name) + if kind == "wifi": + wifi = True + elif kind == "ethernet": + ethernet = True + elif kind == "thunderbolt": + thunderbolt = True + conn = unwrap(connection) + if not isinstance(conn, dict): + conn = {} + status = str(conn.get("status") or "") + active = "" + if status == "Remote": + active = "remote" + remote = True + elif status == "Thunderbolt": + active = "thunderbolt" + thunderbolt = True + elif status == "LAN": + if ethernet and not wifi: + active = "ethernet" + elif ethernet and wifi: + active = "lan" + else: + active = "wifi" + wifi = True + chosen = None + for item in items: + if not isinstance(item, dict): + continue + if iface_address(item): + chosen = item + break + if chosen is None and items and isinstance(items[0], dict): + chosen = items[0] + label = status if status in ("LAN", "Remote", "Thunderbolt") else (iface_label(chosen) if chosen else "LAN") + ip = pick_ipv4(conn.get("ip")) or (iface_address(chosen) if chosen else "") + wanted = active if active in ("wifi", "ethernet", "thunderbolt") else "" + preferred = None + for net in nets: + name = str(net.get("name") or "") + if name in ("lo", "docker0"): + continue + if wanted and counter_kind(name) == wanted and states.get(name) == "up": + preferred = net + break + if preferred is None: + preferred = net + sent = recv = stamp = None + if preferred: + sent = as_number(first(preferred, ("bytesSent", "bytes_sent", "tx", "transmit"))) + recv = as_number(first(preferred, ("bytesRecv", "bytes_recv", "rx", "receive"))) + stamp = as_number(first(preferred, ("time", "timestamp"))) + return { + "label": label, + "ip": ip, + "bytesSent": sent, + "bytesRecv": recv, + "time": stamp, + "remote": remote, + "wifi": wifi, + "ethernet": ethernet, + "thunderbolt": thunderbolt, + "active": active, + } + + +def storage_bytes(item): + total = as_number(first(item, ("size", "total", "total_size", "bytes", "capacity"))) + avail = as_number(first(item, ("avail", "available", "free", "free_size"))) + used = as_number(first(item, ("used", "used_size", "usage"))) + if total is None and used is not None and avail is not None: + total = used + avail + if used is None and total is not None and avail is not None: + used = max(0, total - avail) + return total, used, avail + + +def storage_view(payload): + data = unwrap(payload) + items = [] + if isinstance(data, list): + items = data + elif isinstance(data, dict): + for key in ("storages", "disks", "list", "children"): + if isinstance(data.get(key), list): + items = data[key] + break + if not items and first(data, ("name", "label", "mount_point")): + items = [data] + best = None + best_size = -1 + for item in items: + if not isinstance(item, dict): + continue + name = str(first(item, ("name", "label", "mount_point")) or "") + if name in ("", "/", "loop"): + continue + total, used, avail = storage_bytes(item) + size = total if total is not None else -1 + if size >= best_size: + best = {"name": name, "total": total, "used": used, "avail": avail} + best_size = size + return best + + +def find_backup(payload, host): + found = [] + scheme, location = request_origin(host) + + def walk(node): + if isinstance(node, dict): + bits = [] + for key in ("name", "id", "app", "scheme", "hostname", "title", "en_us", "en_US"): + value = node.get(key) + if isinstance(value, str): + bits.append(value) + title = node.get("title") + if isinstance(title, dict): + bits.extend(str(v) for v in title.values() if isinstance(v, str)) + blob = " ".join(bits).lower() + url = node.get("url") if isinstance(node.get("url"), str) else "" + if not url.startswith("http"): + index = node.get("index") if isinstance(node.get("index"), str) else "" + port = node.get("port") or node.get("port_map") + raw_host = node.get("hostname") if isinstance(node.get("hostname"), str) else location + if "://" not in raw_host and "/" not in raw_host and "\\" not in raw_host: + if raw_host.startswith("[") and "]" in raw_host: + bare = raw_host[: raw_host.find("]") + 1] + else: + bare = raw_host.split(":")[0] + if bare and isinstance(port, str) and port.isdigit() and index.startswith("/"): + url = scheme + "://" + bare + ":" + port + index + elif bare and isinstance(port, int) and index.startswith("/"): + url = scheme + "://" + bare + ":" + str(port) + index + if "backup" in blob and url.startswith("http"): + found.append(url) + for value in node.values(): + walk(value) + elif isinstance(node, list): + for value in node: + walk(value) + + walk(payload) + return found[0] if found else "" + + +def utilization_view(payload): + data = unwrap(payload) + if not isinstance(data, dict): + return {} + cpu = data.get("cpu") if isinstance(data.get("cpu"), dict) else {} + mem = data.get("mem") if isinstance(data.get("mem"), dict) else {} + return { + "cpuPercent": as_number(first(cpu, ("percent", "usage", "usedPercent"))), + "memPercent": as_number(first(mem, ("usedPercent", "percent", "usage"))), + "memUsed": as_number(first(mem, ("used", "used_bytes"))), + "memTotal": as_number(first(mem, ("total", "total_bytes"))), + "net": data.get("net") if isinstance(data.get("net"), list) else [], + } + + +def client_image_path(payload): + data = unwrap(payload) + if not isinstance(data, dict): + return "" + raw = data.get("device_image") or data.get("device_image_path") or "" + if not isinstance(raw, str): + return "" + parsed = urllib.parse.urlparse(raw) + if parsed.scheme or parsed.netloc or parsed.path != "/v2/zimaos/device/image": + return "" + query = urllib.parse.parse_qs(parsed.query, keep_blank_values=False) + model = (query.get("model") or [""])[0] + if not re.fullmatch(r"[A-Za-z0-9_-]{1,64}", model): + return "" + return "/v2/zimaos/device/image?model=" + model + "&type=client" + + +def ensure_device_image(host, info_payload, session_file): + rel = client_image_path(info_payload) + parent = os.path.dirname(session_file) + dest = os.path.join(parent, "device.png") + stamp = os.path.join(parent, "device-image.txt") + if not rel: + return dest if os.path.isfile(dest) else "" + try: + if os.path.isfile(dest) and os.path.getsize(dest) > 32: + with open(stamp, "r", encoding="utf-8") as handle: + if handle.read().strip() == rel: + return dest + except OSError: + pass + status, raw = request_bytes(host, "GET", rel, timeout=8) + if status != 200 or not raw.startswith(b"\x89PNG\r\n\x1a\n"): + return dest if os.path.isfile(dest) else "" + tmp = dest + ".tmp" + with open(tmp, "wb") as handle: + handle.write(raw) + os.chmod(tmp, 0o644) + os.replace(tmp, dest) + with open(stamp, "w", encoding="utf-8") as handle: + handle.write(rel + "\n") + return dest + + +def status(host, path): + code, payload = request(host, "GET", "/v2/zimaos/device/info") + device = device_view(payload) if code == 200 else None + if not device: + detail = message_of(payload) if isinstance(payload, dict) else "no route to ZimaOS" + die(detail or "ZimaOS did not answer") + session = session_for_host(path, host) + result = { + "ok": True, + "authed": False, + "authError": "", + "username": session.get("username") or "", + "dashboardUrl": origin_base(host) + "/", + "filesUrl": origin_base(host) + "/modules/icewhale_files/", + "peerDropUrl": origin_base(host) + "/modules/icewhale_peerdrop/", + "backupUrl": "", + } + result.update(device) + result["deviceImage"] = ensure_device_image(host, payload, path) + if not session.get("access_token") and not session.get("refresh_token"): + sys.stdout.write(json.dumps(result, ensure_ascii=False) + "\n") + return + u_code, u_payload = authed_get(host, "/v1/sys/utilization", session) + n_code, n_payload = authed_get(host, "/v2/zimaos/network/interfaces", session) + c_code, c_payload = authed_get(host, "/v2/zimaos/network/connection-status", session) + s_code, s_payload = authed_get( + host, "/v2/local_storage/storages", session, timeout=STORAGE_TIMEOUT + ) + if u_code == 401 or n_code == 401 or c_code == 401: + result["authError"] = "session expired" + if session.get("access_token"): + save_session(path, session) + sys.stdout.write(json.dumps(result, ensure_ascii=False) + "\n") + return + result["authed"] = True + usage = utilization_view(u_payload if u_code == 200 else {}) + net = network_view( + n_payload if n_code == 200 else {}, + usage.pop("net", []), + c_payload if c_code == 200 else {}, + ) + result.update(usage) + result["network"] = net + storage = storage_view(s_payload) if s_code == 200 else None + if storage: + result["storage"] = storage + g_code, g_payload = authed_get(host, "/v2/app_management/web/appgrid", session) + if g_code == 200: + result["backupUrl"] = find_backup(g_payload, host) + # App grid is the last authenticated call and can rotate the refresh token. + # Persist after it, or the next poll keeps the tokens from before that call. + if session.get("access_token"): + save_session(path, session) + sys.stdout.write(json.dumps(result, ensure_ascii=False) + "\n") + + +def main(argv): + if len(argv) < 2: + die("usage: zima device|login|logout|status") + cmd = argv[1] + if cmd == "device": + if len(argv) != 3: + die("usage: zima device ") + host = normalize_host(argv[2]) + code, payload = request(host, "GET", "/v2/zimaos/device/info") + device = device_view(payload) if code == 200 else None + if not device: + die(message_of(payload) if isinstance(payload, dict) else "ZimaOS did not answer") + device["ok"] = True + sys.stdout.write(json.dumps(device, ensure_ascii=False) + "\n") + return + if cmd == "login": + if len(argv) != 4: + die("usage: zima login ") + login(normalize_host(argv[2]), state_path(argv[3])) + return + if cmd == "logout": + if len(argv) != 3: + die("usage: zima logout ") + logout(state_path(argv[2])) + return + if cmd == "status": + if len(argv) != 4: + die("usage: zima status ") + status(normalize_host(argv[2]), state_path(argv[3])) + return + die("unknown command") + + +if __name__ == "__main__": + main(sys.argv) diff --git a/plugins/zimaos/content/I18n.qml b/plugins/zimaos/content/I18n.qml new file mode 100644 index 00000000..368d0f72 --- /dev/null +++ b/plugins/zimaos/content/I18n.qml @@ -0,0 +1,86 @@ +pragma ComponentBehavior: Bound + +import QtQuick + +QtObject { + id: i18n + + readonly property string lang: { + var n = Qt.locale().name; + if (i18n.catalogs[n]) + return n; + var base = n.split("_")[0]; + return i18n.catalogs[base] ? base : "en"; + } + + readonly property var catalogs: ({ + "pt": { + "Go to Dashboard": "Ir ao painel", + "Open": "Abrir", + "Backup": "Backup", + "PeerDrop": "PeerDrop", + "System": "Sistema", + "CPU": "CPU", + "RAM": "RAM", + "Sign in": "Entrar", + "Sign out": "Sair", + "Username": "Utilizador", + "Password": "Palavra-passe", + "IP": "IP", + "Enter the ZimaOS address.": "Indique o endereço do ZimaOS.", + "That address is not valid. Use a hostname or IPv4, with an optional port.": "Endereço inválido. Use um nome ou IPv4, com porta opcional.", + "Enter the username and the password.": "Indique o utilizador e a palavra-passe.", + "Could not sign in.": "Não foi possível entrar.", + "Offline": "Sem resposta", + "Session expired": "Sessão expirada", + "Connecting": "A ligar", + "LAN": "LAN", + "Remote": "Remoto", + "Wi-Fi": "Wi-Fi", + "Ethernet": "Ethernet", + "Thunderbolt": "Thunderbolt", + "In use": "Em uso", + "Available": "Disponível", + "storage": "armazenamento" + }, + "pt_BR": { + "Go to Dashboard": "Ir ao painel", + "Open": "Abrir", + "Backup": "Backup", + "PeerDrop": "PeerDrop", + "System": "Sistema", + "CPU": "CPU", + "RAM": "RAM", + "Sign in": "Entrar", + "Sign out": "Sair", + "Username": "Usuário", + "Password": "Senha", + "IP": "IP", + "Enter the ZimaOS address.": "Informe o endereço do ZimaOS.", + "That address is not valid. Use a hostname or IPv4, with an optional port.": "Endereço inválido. Use um nome ou IPv4, com porta opcional.", + "Enter the username and the password.": "Informe o usuário e a senha.", + "Could not sign in.": "Não foi possível entrar.", + "Offline": "Sem resposta", + "Session expired": "Sessão expirada", + "Connecting": "Conectando", + "LAN": "LAN", + "Remote": "Remoto", + "Wi-Fi": "Wi-Fi", + "Ethernet": "Ethernet", + "Thunderbolt": "Thunderbolt", + "In use": "Em uso", + "Available": "Disponível", + "storage": "armazenamento" + } + }) + + function tr(s) { + if (s === undefined || s === null || s === "") + return s; + var map = i18n.catalogs[i18n.lang]; + if (!map) + return s; + var v = map["" + s]; + return v === undefined ? s : v; + } +} diff --git a/plugins/zimaos/content/Panel.qml b/plugins/zimaos/content/Panel.qml new file mode 100644 index 00000000..62547ccf --- /dev/null +++ b/plugins/zimaos/content/Panel.qml @@ -0,0 +1,684 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls +import QtQuick.Shapes +import Ryoku.PluginKit +import Ryoku.PluginKit.Singletons + +// ZimaOS device card. Same facts as the Zima client card: name, link rates, +// address, dashboard, storage, backup, and PeerDrop. +Item { + id: root + + property var pluginApi + property string density: "full" + property real s: 1 + property real widthBudget: 380 + property bool active: false + + readonly property var service: pluginApi ? pluginApi.mainInstance : null + readonly property real w: widthBudget > 0 ? widthBudget : 380 + + implicitWidth: root.w + implicitHeight: col.implicitHeight + + I18n { id: i18n } + + property string formError: "" + + // The shell already draws the card: the same stroked glass as the + // now-playing panel. These are the chip tints that sit on it. + readonly property color ink: Theme.cream + readonly property color sumi: Theme.dim + readonly property color seal: Theme.accent + readonly property color sep: Theme.border + readonly property color fillIdle: Qt.rgba(0, 0, 0, 0.12) + readonly property color fillHover: Qt.rgba(Theme.accent.r, Theme.accent.g, Theme.accent.b, 0.10) + readonly property color fillActive: Theme.accent + + onActiveChanged: { + if (!root.active) { + passField.text = ""; + root.formError = ""; + } else { + root.fillAddress(); + } + } + + component Card: Rectangle { + property real pad: 12 + radius: 16 + color: root.fillIdle + border.width: 1 + border.color: root.sep + } + + // Gear and the PeerDrop person are not in the shell glyph set. + component LineIcon: Item { + id: mark + property string name: "" + property color color: Theme.iconDim + property real stroke: 1.6 + + readonly property var paths: ({ + "peer": "M10.4 8.3a2.2 2.2 0 1 1-4.4 0 2.2 2.2 0 0 1 4.4 0 M4.1 18c.6-2.6 2-3.9 4.1-3.9s3.5 1.3 4.1 3.9 M14.4 9.2a2.6 2.6 0 0 1 0 3.8 M16.6 7.1a5.4 5.4 0 0 1 0 8", + "globe": "M12 3.2a8.8 8.8 0 1 0 0 17.6 8.8 8.8 0 0 0 0-17.6z M3.2 12h17.6 M12 3.2c2.3 2.5 3.5 5.6 3.5 8.8s-1.2 6.3-3.5 8.8c-2.3-2.5-3.5-5.6-3.5-8.8s1.2-6.3 3.5-8.8z", + "bolt": "M13.2 2.8 6 13.2h5.2L10 21.2 18.4 10.2h-5.5z", + "gear": "M12 15.2a3.2 3.2 0 1 0 0-6.4 3.2 3.2 0 0 0 0 6.4z M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" + }) + readonly property string d: mark.paths[mark.name] !== undefined ? mark.paths[mark.name] : "" + readonly property real u: Math.min(width, height) / 24 + + Shape { + width: 24 + height: 24 + scale: mark.u + transformOrigin: Item.TopLeft + antialiasing: true + preferredRendererType: Shape.CurveRenderer + + ShapePath { + strokeColor: mark.color + fillColor: "transparent" + strokeWidth: mark.stroke + capStyle: ShapePath.RoundCap + joinStyle: ShapePath.RoundJoin + PathSvg { path: mark.d } + } + } + } + + // Remote, Wi-Fi, Ethernet, Thunderbolt. Accent is the path in use. + component PathMark: Item { + id: path + property string line: "" + property string glyph: "" + property string pathName: "" + property string title: "" + + readonly property string activeName: root.service ? root.service.linkActive : "" + readonly property bool onPath: path.activeName === path.pathName + || (path.activeName === "lan" && (path.pathName === "wifi" || path.pathName === "ethernet")) + readonly property bool up: { + if (!root.service) + return false; + if (path.pathName === "remote") + return root.service.linkRemote; + if (path.pathName === "wifi") + return root.service.linkWifi; + if (path.pathName === "ethernet") + return root.service.linkEthernet; + return root.service.linkThunderbolt; + } + + width: 14 * root.s + height: 14 * root.s + + LineIcon { + anchors.fill: parent + visible: path.line.length > 0 + name: path.line + color: path.onPath ? root.seal : (path.up ? root.ink : root.sumi) + opacity: path.onPath ? 1 : (path.up ? 0.85 : 0.34) + stroke: 1.6 + } + GlyphIcon { + anchors.fill: parent + visible: path.glyph.length > 0 + name: path.glyph + color: path.onPath ? root.seal : (path.up ? root.ink : root.sumi) + opacity: path.onPath ? 1 : (path.up ? 0.85 : 0.34) + stroke: 1.6 + } + + MouseArea { + id: pathHover + anchors.fill: parent + anchors.margins: -4 + hoverEnabled: true + acceptedButtons: Qt.NoButton + ToolTip.visible: pathHover.containsMouse + ToolTip.delay: 350 + ToolTip.text: { + var label = i18n.tr(path.title); + if (path.onPath) + return label + " · " + i18n.tr("In use"); + if (path.up) + return label + " · " + i18n.tr("Available"); + return label; + } + } + } + + Column { + id: col + width: root.w + spacing: 14 * root.s + + Item { + width: parent.width + implicitHeight: nameText.implicitHeight + + Text { + id: nameText + anchors.left: parent.left + anchors.right: logoutMark.left + anchors.rightMargin: 12 * root.s + text: { + if (!root.service || !root.service.authed) + return "ZimaOS"; + if (root.service.deviceName.length > 0) + return root.service.deviceName; + return root.service.online ? "ZimaOS" : i18n.tr("Offline"); + } + color: root.ink + font.family: Theme.mono + font.pixelSize: 18 * root.s + font.weight: Font.Medium + elide: Text.ElideRight + } + + GlyphIcon { + id: logoutMark + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + visible: root.service && root.service.authed + width: 16 * root.s + height: 16 * root.s + name: "logout" + color: logoutMa.containsMouse ? root.ink : root.sumi + stroke: 1.6 + + MouseArea { + id: logoutMa + anchors.fill: parent + anchors.margins: -8 + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: if (root.service) root.service.logout() + } + } + } + + Item { + visible: root.service && root.service.deviceImage.length > 0 + width: parent.width + implicitHeight: 148 * root.s + + Image { + anchors.centerIn: parent + width: Math.min(parent.width * 0.72, 210 * root.s) + height: 132 * root.s + source: root.service && root.service.deviceImage.length > 0 + ? ("file://" + root.service.deviceImage) : "" + fillMode: Image.PreserveAspectFit + asynchronous: true + cache: false + } + } + + Text { + visible: root.service && root.service.authed && root.service.errorText.length > 0 + width: parent.width + text: root.service ? root.service.errorText : "" + color: Theme.sun + font.family: Theme.mono + font.pixelSize: 11 * root.s + wrapMode: Text.WordWrap + } + + Column { + visible: root.service && root.service.authed + width: parent.width + spacing: 8 * root.s + + Item { + width: parent.width + implicitHeight: lanText.implicitHeight + + Text { + id: lanText + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + text: i18n.tr(root.service && root.service.ifaceLabel.length > 0 + ? root.service.ifaceLabel : "LAN") + color: root.ink + font.family: Theme.mono + font.pixelSize: 12 * root.s + font.weight: Font.Medium + } + + Row { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 10 * root.s + + PathMark { line: "globe"; pathName: "remote"; title: "Remote" } + PathMark { glyph: "wifi"; pathName: "wifi"; title: "Wi-Fi" } + PathMark { glyph: "reboot"; pathName: "ethernet"; title: "Ethernet" } + PathMark { line: "bolt"; pathName: "thunderbolt"; title: "Thunderbolt" } + } + } + + Item { + width: parent.width + implicitHeight: Math.max(ipText.implicitHeight, dashBtn.height) + + Text { + id: ipText + anchors.left: parent.left + anchors.right: dashBtn.left + anchors.rightMargin: 12 * root.s + anchors.verticalCenter: parent.verticalCenter + text: root.service && root.service.ipAddress.length > 0 ? root.service.ipAddress : "" + color: root.sumi + font.family: Theme.mono + font.pixelSize: 12 * root.s + elide: Text.ElideRight + } + + Rectangle { + id: dashBtn + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + width: dashLabel.implicitWidth + 16 * root.s + height: dashLabel.implicitHeight + 10 * root.s + radius: height / 2 + color: dashMa.containsMouse ? Theme.vermDeep : root.seal + border.width: 0 + + Text { + id: dashLabel + anchors.centerIn: parent + text: i18n.tr("Go to Dashboard") + color: Theme.cardBot + font.family: Theme.mono + font.pixelSize: 10 * root.s + font.weight: Font.Medium + font.letterSpacing: 0.4 + } + + MouseArea { + id: dashMa + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.openLink(root.service ? root.service.dashboardUrl : "") + } + } + } + } + + Card { + id: diskCard + visible: root.service && root.service.authed + width: parent.width + implicitHeight: 104 * root.s + color: diskMa.containsMouse ? root.fillHover : root.fillIdle + border.color: diskMa.containsMouse ? root.seal : root.sep + + Text { + anchors.left: parent.left + anchors.top: parent.top + anchors.right: folderMark.left + anchors.leftMargin: 16 * root.s + anchors.topMargin: 14 * root.s + anchors.rightMargin: 8 * root.s + text: i18n.tr("Open") + " " + (root.service && root.service.storageTitle.length > 0 + ? root.service.storageTitle + : i18n.tr("storage")) + color: root.ink + font.family: Theme.mono + font.pixelSize: 13 * root.s + font.weight: Font.Medium + elide: Text.ElideRight + } + + LineIcon { + anchors.left: parent.left + anchors.bottom: parent.bottom + anchors.leftMargin: 16 * root.s + anchors.bottomMargin: 14 * root.s + width: 18 * root.s + height: 18 * root.s + name: "gear" + color: root.sumi + stroke: 1.6 + } + + GlyphIcon { + id: folderMark + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.rightMargin: 18 * root.s + width: 36 * root.s + height: 36 * root.s + name: "folder" + color: diskMa.containsMouse ? root.ink : root.sumi + stroke: 1.5 + } + + MouseArea { + id: diskMa + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.openLink(root.service ? root.service.filesUrl : "") + } + } + + Row { + visible: root.service && root.service.authed + width: parent.width + spacing: 10 * root.s + + Card { + id: backupCard + width: (parent.width - parent.spacing) / 2 + implicitHeight: 108 * root.s + color: backupMa.containsMouse ? root.fillHover : root.fillIdle + border.color: backupMa.containsMouse ? root.seal : root.sep + + GlyphIcon { + x: 16 * root.s + y: 16 * root.s + width: 20 * root.s + height: 20 * root.s + name: "reboot" + color: backupMa.containsMouse ? root.ink : root.sumi + stroke: 1.6 + } + + Text { + anchors.left: parent.left + anchors.bottom: parent.bottom + anchors.leftMargin: 16 * root.s + anchors.bottomMargin: 14 * root.s + text: i18n.tr("Backup") + color: root.ink + font.family: Theme.mono + font.pixelSize: 12 * root.s + font.weight: Font.Medium + } + + MouseArea { + id: backupMa + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + if (!root.service) + return; + var url = root.service.backupUrl.length > 0 + ? root.service.backupUrl + : root.service.dashboardUrl; + root.openLink(url); + } + } + } + + Card { + id: peerCard + width: (parent.width - parent.spacing) / 2 + implicitHeight: 108 * root.s + color: peerMa.containsMouse ? root.fillHover : root.fillIdle + border.color: peerMa.containsMouse ? root.seal : root.sep + + LineIcon { + x: 16 * root.s + y: 16 * root.s + width: 20 * root.s + height: 20 * root.s + name: "peer" + color: peerMa.containsMouse ? root.ink : root.sumi + stroke: 1.6 + } + + Text { + anchors.left: parent.left + anchors.bottom: parent.bottom + anchors.leftMargin: 16 * root.s + anchors.bottomMargin: 14 * root.s + text: i18n.tr("PeerDrop") + color: root.ink + font.family: Theme.mono + font.pixelSize: 12 * root.s + font.weight: Font.Medium + } + + MouseArea { + id: peerMa + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.openLink(root.service ? root.service.peerDropUrl : "") + } + } + } + + Column { + width: parent.width + spacing: 8 * root.s + visible: root.service && !root.service.authed + + Text { + width: parent.width + text: { + if (root.formError.length > 0) + return root.formError; + if (!root.service) + return ""; + if (root.service.authError === "session expired") + return i18n.tr("Session expired"); + if (root.service.authError === "username and password are required") + return i18n.tr("Enter the username and the password."); + if (root.service.authError === "login failed" || root.service.authError === "invalid login payload") + return i18n.tr("Could not sign in."); + if (root.service.authError.length > 0) + return root.service.authError; + return root.service.errorText; + } + visible: text.length > 0 + color: Theme.sun + font.family: Theme.mono + font.pixelSize: 11 * root.s + wrapMode: Text.WordWrap + } + + Text { + text: i18n.tr("IP") + color: root.sumi + font.family: Theme.mono + font.pixelSize: 10 * root.s + font.letterSpacing: 1 + font.capitalization: Font.AllUppercase + } + + TextField { + id: ipField + width: parent.width + placeholderText: "192.168.0.180" + color: root.ink + placeholderTextColor: root.sumi + font.family: Theme.mono + font.pixelSize: 12 * root.s + padding: 8 * root.s + inputMethodHints: Qt.ImhNoPredictiveText | Qt.ImhNoAutoUppercase + background: Rectangle { + radius: 6 + color: root.fillIdle + border.width: 1 + border.color: ipField.activeFocus ? root.seal : root.sep + } + onAccepted: root.submitLogin() + } + + Text { + text: i18n.tr("Username") + color: root.sumi + font.family: Theme.mono + font.pixelSize: 10 * root.s + font.letterSpacing: 1 + font.capitalization: Font.AllUppercase + } + + TextField { + id: userField + width: parent.width + placeholderText: i18n.tr("Username") + color: root.ink + placeholderTextColor: root.sumi + font.family: Theme.mono + font.pixelSize: 12 * root.s + padding: 8 * root.s + background: Rectangle { + radius: 6 + color: root.fillIdle + border.width: 1 + border.color: userField.activeFocus ? root.seal : root.sep + } + } + + Text { + text: i18n.tr("Password") + color: root.sumi + font.family: Theme.mono + font.pixelSize: 10 * root.s + font.letterSpacing: 1 + font.capitalization: Font.AllUppercase + } + + TextField { + id: passField + width: parent.width + placeholderText: i18n.tr("Password") + echoMode: TextInput.Password + color: root.ink + placeholderTextColor: root.sumi + font.family: Theme.mono + font.pixelSize: 12 * root.s + padding: 8 * root.s + background: Rectangle { + radius: 6 + color: root.fillIdle + border.width: 1 + border.color: passField.activeFocus ? root.seal : root.sep + } + onAccepted: root.submitLogin() + } + + Rectangle { + width: parent.width + height: signLabel.implicitHeight + 12 * root.s + radius: 6 + color: signMa.containsMouse ? Theme.vermDeep : Theme.accent + border.width: 0 + + Text { + id: signLabel + anchors.centerIn: parent + text: root.service && root.service.busy ? i18n.tr("Connecting") : i18n.tr("Sign in") + color: Theme.cardBot + font.family: Theme.mono + font.pixelSize: 11 * root.s + font.weight: Font.Medium + font.letterSpacing: 1 + } + + MouseArea { + id: signMa + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.submitLogin() + } + } + } + + Row { + width: parent.width + visible: root.service && root.service.authed + spacing: 8 * root.s + + Text { + anchors.verticalCenter: parent.verticalCenter + width: parent.width - signOut.implicitWidth - 8 * root.s + text: root.service ? root.service.username : "" + color: root.sumi + font.family: Theme.mono + font.pixelSize: 10 * root.s + font.letterSpacing: 1 + elide: Text.ElideRight + } + + Text { + id: signOut + anchors.verticalCenter: parent.verticalCenter + text: i18n.tr("Sign out") + color: signOutMa.containsMouse ? root.ink : root.sumi + font.family: Theme.mono + font.pixelSize: 10 * root.s + font.letterSpacing: 1 + + MouseArea { + id: signOutMa + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + passField.text = ""; + if (root.service) + root.service.logout(); + } + } + } + } + } + + readonly property string watchedHost: root.service && root.service.host ? root.service.host : "" + readonly property string watchedUser: root.service && root.service.username ? root.service.username : "" + onWatchedHostChanged: root.fillAddress() + onWatchedUserChanged: { + if (userField.text.length === 0 && root.watchedUser.length > 0) + userField.text = root.watchedUser; + } + + function openLink(url) { + if (!root.service || !url) + return; + if (url.indexOf("http://") !== 0 && url.indexOf("https://") !== 0) + return; + root.service.openUrl(url); + if (root.pluginApi) + root.pluginApi.closePanel(); + } + + function fillAddress() { + if (ipField.text.length === 0 && root.service && root.service.host.length > 0) + ipField.text = root.service.host; + } + + function submitLogin() { + if (!root.service || root.service.busy) + return; + var addr = ipField.text.trim(); + if (addr.length === 0) { + root.formError = i18n.tr("Enter the ZimaOS address."); + return; + } + if (root.service.normalizeHost(addr) === "") { + root.formError = i18n.tr("That address is not valid. Use a hostname or IPv4, with an optional port."); + return; + } + if (userField.text.trim().length === 0 || passField.text.length === 0) { + root.formError = i18n.tr("Enter the username and the password."); + return; + } + root.formError = ""; + if (!root.service.useHost(addr)) + return; + root.service.login(userField.text.trim(), passField.text); + passField.text = ""; + } +} diff --git a/plugins/zimaos/content/Widget.qml b/plugins/zimaos/content/Widget.qml new file mode 100644 index 00000000..cd13ea61 --- /dev/null +++ b/plugins/zimaos/content/Widget.qml @@ -0,0 +1,83 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Effects +import Ryoku.PluginKit +import Ryoku.PluginKit.Singletons + +// Bar glyph. A click only opens the ZimaOS card. +Item { + id: root + + property var pluginApi + property var screen + property bool active: false + property string density: "glyph" + property real s: 1 + property real widthBudget: 0 + property bool open: false + property real reveal: 0 + property bool tabActive: false + signal requestClose() + + readonly property var service: pluginApi ? pluginApi.mainInstance : null + readonly property bool panelOpen: pluginApi ? pluginApi.panelOpen : false + readonly property bool online: service ? service.online : false + readonly property bool lit: root.panelOpen || hover.hovered + + implicitWidth: 22 * root.s + implicitHeight: 22 * root.s + + Rectangle { + anchors.centerIn: parent + width: 20 * root.s + height: 20 * root.s + radius: 0 + color: root.lit ? Theme.sheen : "transparent" + border.width: root.panelOpen ? 1 : 0 + border.color: Theme.hair + Behavior on color { ColorAnimation { duration: 120 } } + } + + Item { + id: markBox + anchors.centerIn: parent + width: 16 * root.s + height: 16 * root.s + + Image { + id: mark + anchors.fill: parent + source: Qt.resolvedUrl("zima-mark.png") + fillMode: Image.PreserveAspectFit + smooth: true + mipmap: true + visible: false + } + + MultiEffect { + anchors.fill: parent + source: mark + colorization: 1 + colorizationColor: root.panelOpen ? Theme.cream : (hover.hovered ? Theme.cream : Theme.iconDim) + opacity: root.online || root.panelOpen ? 1 : 0.45 + Behavior on opacity { NumberAnimation { duration: 120 } } + } + } + + HoverHandler { + id: hover + cursorShape: Qt.PointingHandCursor + } + + TapHandler { + onTapped: { + if (!root.pluginApi) + return; + if (root.open) + root.requestClose(); + else if (root.pluginApi.togglePanel) + root.pluginApi.togglePanel(); + } + } +} diff --git a/plugins/zimaos/content/zima-mark.png b/plugins/zimaos/content/zima-mark.png new file mode 100644 index 00000000..6093ac94 Binary files /dev/null and b/plugins/zimaos/content/zima-mark.png differ diff --git a/plugins/zimaos/manifest.json b/plugins/zimaos/manifest.json new file mode 100644 index 00000000..157f65f7 --- /dev/null +++ b/plugins/zimaos/manifest.json @@ -0,0 +1,74 @@ +{ + "id": "zimaos", + "name": "ZimaOS", + "version": "0.1.16", + "author": "Felix ", + "description": "QS Bar card for a ZimaOS server: device name, link rate, storage, and a shortcut to the dashboard.", + "license": "MIT", + "upstream": "https://github.com/leandrofelix-dev/ryoku-zima-client", + "official": false, + "tags": [ + "zimaos", + "nas", + "bar-widget" + ], + "hosts": [ + "topbarGlyph" + ], + "entryPoints": { + "main": "service/Main.qml", + "content": "content/Widget.qml", + "panel": "content/Panel.qml" + }, + "panel": { + "width": 380 + }, + "files": [ + "content/I18n.qml", + "bin/zima", + "assets/preview-widget.png", + "content/zima-mark.png" + ], + "capabilities": { + "densities": [ + "glyph", + "full" + ], + "network": [] + }, + "defaults": { + "host": "topbarGlyph", + "icon": "archive", + "label": "ZimaOS", + "bar": { + "section": "right" + } + }, + "dependencies": { + "commands": [ + "python3", + "xdg-open" + ] + }, + "metadata": { + "settings": [ + { + "key": "host", + "type": "text", + "label": "ZimaOS address", + "group": "ZimaOS", + "default": "", + "placeholder": "192.168.0.180" + }, + { + "key": "poll", + "type": "int", + "label": "Refresh seconds", + "group": "ZimaOS", + "default": 8, + "min": 3, + "max": 60 + } + ] + } +} diff --git a/plugins/zimaos/product-manifest.json b/plugins/zimaos/product-manifest.json new file mode 100644 index 00000000..67bd8e3d --- /dev/null +++ b/plugins/zimaos/product-manifest.json @@ -0,0 +1,113 @@ +{ + "schema": 1, + "id": "zimaos", + "category": "plugins", + "version": "0.1.16", + "destination": "ryoku/plugins/zimaos", + "files": [ + { + "source": ".gitignore", + "destination": ".gitignore", + "sha256": "6b9f20b26bcd5a7db7411faba95d63155e7a096902e18b9de8e4d9876ddba6ee", + "mode": "0644", + "size": 41, + "install": true + }, + { + "source": "AGENTS.md", + "destination": "AGENTS.md", + "sha256": "e311f126eec8ad68644db0704a6ba815d915f90ab8dcf537d58a90df1c06a5da", + "mode": "0644", + "size": 4625, + "install": true + }, + { + "source": "CLAUDE.md", + "destination": "CLAUDE.md", + "sha256": "09ffb8ebdb8adf50bf4bde26dfe2109b4af3a4a8cc3f30cc536be42bb912c033", + "mode": "0644", + "size": 152, + "install": true + }, + { + "source": "LICENSE", + "destination": "LICENSE", + "sha256": "97483adbcfff65229fdf21ea76178690f1bfe3ccd5fbde97e344e04d625dc4bd", + "mode": "0644", + "size": 1062, + "install": true + }, + { + "source": "README.md", + "destination": "README.md", + "sha256": "effedc9bd5ab9b8a75596923bd23a59e1ba8b8679132a1d65d1503144d3afb55", + "mode": "0644", + "size": 3215, + "install": false + }, + { + "source": "assets/preview-widget.png", + "destination": "assets/preview-widget.png", + "sha256": "8a91b27c6503c5a750b00784b3d1e0652e28c244fa6da11c2945e41172991e87", + "mode": "0644", + "size": 161842, + "install": false + }, + { + "source": "bin/zima", + "destination": "bin/zima", + "sha256": "eaeab05fd5f39c6dc7ff20023745269346c67f9051c5e79f988de62e709c72c9", + "mode": "0755", + "size": 26747, + "install": true + }, + { + "source": "content/I18n.qml", + "destination": "content/I18n.qml", + "sha256": "74ff808ff4ef20ca385e9bbd72ef4ae003751f9801482e96d93404ead611111c", + "mode": "0644", + "size": 3013, + "install": true + }, + { + "source": "content/Panel.qml", + "destination": "content/Panel.qml", + "sha256": "9f2973be4b64ce1b6dc216ddc6ed3b9c9f208fdd614be266e997a88733ca7496", + "mode": "0644", + "size": 25417, + "install": true + }, + { + "source": "content/Widget.qml", + "destination": "content/Widget.qml", + "sha256": "6ec38de78f22de1fc528cdd16df076f1b2d8b4c1c094e740378c68937be02c01", + "mode": "0644", + "size": 2271, + "install": true + }, + { + "source": "content/zima-mark.png", + "destination": "content/zima-mark.png", + "sha256": "e67752d7533cee5453c398ca5aaf19ee25e17c4b36f5896904d771623997cd70", + "mode": "0644", + "size": 6014, + "install": true + }, + { + "source": "manifest.json", + "destination": "manifest.json", + "sha256": "8f73fc3b4d69c94b9ac7cd45b5e2715b30ce881d57d082d95aa7694c936a412f", + "mode": "0644", + "size": 1450, + "install": true + }, + { + "source": "service/Main.qml", + "destination": "service/Main.qml", + "sha256": "e99ad7e8e128450a7b072a8bbb4cb2c4ccfc10cfb69c752a63c88f62713e25ef", + "mode": "0644", + "size": 17806, + "install": true + } + ] +} diff --git a/plugins/zimaos/service/Main.qml b/plugins/zimaos/service/Main.qml new file mode 100644 index 00000000..0bc25b79 --- /dev/null +++ b/plugins/zimaos/service/Main.qml @@ -0,0 +1,509 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell +import Quickshell.Io + +// One ZimaOS box. Public device info needs no login. Network rates, the +// storage pool and CPU/RAM come from the same calls the ZimaOS dashboard makes, +// after a session written only under pluginApi.stateDir. +Item { + id: svc + + property var pluginApi + readonly property var settings: pluginApi ? pluginApi.pluginSettings : null + + // Typed on the login screen, then saved. Empty until the user sets one. + property string activeHost: "" + readonly property string savedHost: { + var raw = settings && settings.host !== undefined && settings.host !== null + ? ("" + settings.host).trim() : ""; + return raw; + } + readonly property string host: svc.activeHost.length > 0 ? svc.activeHost : svc.savedHost + readonly property bool hostReady: svc.normalizeHost(svc.host).length > 0 + readonly property int pollSec: { + var n = settings && settings.poll !== undefined ? Number(settings.poll) : 8; + if (!isFinite(n)) + return 8; + return Math.max(3, Math.min(60, Math.round(n))); + } + readonly property string binPath: pluginApi && pluginApi.pluginDir + ? pluginApi.pluginDir + "/bin/zima" : "" + readonly property string stateFile: pluginApi && pluginApi.stateDir + ? pluginApi.stateDir + "/session.json" : "" + + property bool online: false + property bool authed: false + property bool busy: false + property string errorText: "" + property string authError: "" + property string username: "" + property string deviceName: "" + property string osVersion: "" + property string deviceModel: "" + property string cpuModel: "" + property real memoryBytes: -1 + property real cpuPercent: -1 + property real memPercent: -1 + property string ifaceLabel: "LAN" + property string ipAddress: "" + property bool linkRemote: false + property bool linkWifi: false + property bool linkEthernet: false + property bool linkThunderbolt: false + property string linkActive: "" + property real downBps: 0 + property real upBps: 0 + property string storageName: "" + property real storageTotal: -1 + property real storageUsed: -1 + property real storageAvail: -1 + property string dashboardUrl: "" + property string filesUrl: "" + property string backupUrl: "" + property string peerDropUrl: "" + property string deviceImage: "" + + property real _prevSent: -1 + property real _prevRecv: -1 + property real _prevStamp: -1 + property var _queue: [] + property bool _statusAfterLogin: false + property bool _statusAgain: false + + function formatBytes(n) { + if (n === undefined || n === null || !isFinite(n) || n < 0) + return ""; + var units = ["B", "KB", "MB", "GB", "TB"]; + var v = n; + var i = 0; + while (v >= 1024 && i < units.length - 1) { + v /= 1024; + i++; + } + var digits = (i === 0 || v >= 100) ? 0 : (v >= 10 ? 1 : 2); + return v.toFixed(digits) + " " + units[i]; + } + + function formatRate(bps) { + if (!isFinite(bps) || bps < 1) + return "0 B"; + return svc.formatBytes(bps); + } + + readonly property string downText: svc.authed ? svc.formatRate(svc.downBps) : "—" + readonly property string upText: svc.authed ? svc.formatRate(svc.upBps) : "—" + readonly property string cpuText: svc.cpuPercent >= 0 ? Math.round(svc.cpuPercent) + "%" : "—" + readonly property string ramText: svc.memPercent >= 0 + ? Math.round(svc.memPercent) + "%" + : (svc.memoryBytes > 0 ? svc.formatBytes(svc.memoryBytes) : "—") + readonly property string storageTitle: svc.storageName.length > 0 ? svc.storageName : "" + readonly property string storageLine: { + if (svc.storageAvail >= 0 && svc.storageTotal >= 0) + return svc.formatBytes(svc.storageAvail) + " · " + svc.formatBytes(svc.storageTotal); + if (svc.storageTotal >= 0) + return svc.formatBytes(svc.storageTotal); + return ""; + } + readonly property real storageFraction: { + if (svc.storageTotal <= 0 || svc.storageUsed < 0) + return 0; + return Math.max(0, Math.min(1, svc.storageUsed / svc.storageTotal)); + } + + function enqueue(op) { + var next = svc._queue.slice(); + next.push(op); + svc._queue = next; + svc.pump(); + } + + function pump() { + if (proc.running || svc._queue.length === 0 || svc.binPath === "") + return; + var op = svc._queue[0]; + svc._queue = svc._queue.slice(1); + proc.opKind = op.kind; + proc.opHost = op.host || ""; + proc.payload = op.payload || ""; + proc.command = op.command; + proc.running = true; + } + + function normalizeHost(raw) { + var text = (raw || "").trim(); + // Bare LAN addresses stay http on the Python side. https:// is kept so + // that call verifies the certificate. http:// is stored without a scheme + // so an existing session for the same host still matches. + var scheme = ""; + var lower = text.toLowerCase(); + if (lower.indexOf("https://") === 0) { + scheme = "https"; + text = text.substring(8); + } else if (lower.indexOf("http://") === 0) { + scheme = "http"; + text = text.substring(7); + } + if (text.length === 0 || text.indexOf("@") >= 0 || text.indexOf("://") >= 0 + || text.indexOf("/") >= 0 || text.indexOf("\\") >= 0 || text.indexOf(" ") >= 0) + return ""; + var host = text; + var port = ""; + if (host.charAt(0) === "[") { + var end = host.indexOf("]"); + if (end < 0) + return ""; + port = host.substring(end + 1); + host = host.substring(0, end + 1); + if (port.charAt(0) === ":") + port = port.substring(1); + else if (port.length > 0) + return ""; + } else { + var pieces = host.split(":"); + if (pieces.length === 2) { + host = pieces[0]; + port = pieces[1]; + } else if (pieces.length > 2) { + return ""; + } + } + var ipv4 = /^(?:\d{1,3}\.){3}\d{1,3}$/.test(host); + var name = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*$/.test(host); + if (ipv4) { + var nums = host.split("."); + for (var i = 0; i < nums.length; ++i) { + if (Number(nums[i]) > 255) + return ""; + } + } else if (!name) { + return ""; + } + if (port.length > 0) { + if (!/^\d{1,5}$/.test(port)) + return ""; + var n = Number(port); + if (n < 1 || n > 65535) + return ""; + return scheme === "https" ? ("https://" + host + ":" + n) : (host + ":" + n); + } + return scheme === "https" ? ("https://" + host) : host; + } + + function useHost(raw) { + var clean = svc.normalizeHost(raw); + if (clean === "") + return false; + svc.activeHost = clean; + if (svc.pluginApi && svc.savedHost !== clean) + svc.pluginApi.saveSetting("host", clean); + return true; + } + + function statusOp() { + return { + kind: "status", + host: svc.host, + command: [svc.binPath, "status", svc.host, svc.stateFile] + }; + } + + function refresh() { + if (!svc.hostReady || svc.binPath === "" || svc.stateFile === "") + return; + // A status already running for another host must not swallow this + // refresh. Its result is ignored, then this host is fetched. + if (proc.running && proc.opKind === "status") { + if (proc.opHost !== svc.host) + svc._statusAgain = true; + return; + } + var kept = []; + var hasCurrent = false; + for (var i = 0; i < svc._queue.length; i++) { + var item = svc._queue[i]; + if (item.kind === "status") { + if (item.host === svc.host && !hasCurrent) { + kept.push(item); + hasCurrent = true; + } + continue; + } + kept.push(item); + } + svc._queue = kept; + if (hasCurrent) + return; + svc.enqueue(svc.statusOp()); + } + + // useHost() starts a status poll before login() is queued. That poll, or + // one already sitting in the queue, used to make the post-login refresh() + // return early, so the sign-in form stayed up until the next timer tick. + function refreshAfterLogin() { + if (!svc.hostReady || svc.binPath === "" || svc.stateFile === "") + return; + var kept = []; + for (var i = 0; i < svc._queue.length; i++) { + if (svc._queue[i].kind !== "status") + kept.push(svc._queue[i]); + } + svc._queue = kept; + if (proc.running && proc.opKind === "status") { + svc._statusAfterLogin = true; + return; + } + svc._statusAfterLogin = false; + svc.enqueue(svc.statusOp()); + } + + function takeLoginStatus() { + if (!svc._statusAfterLogin) + return; + svc._statusAfterLogin = false; + svc.enqueue(svc.statusOp()); + } + + function login(username, password) { + if (!svc.hostReady || svc.binPath === "" || svc.stateFile === "") + return; + svc.busy = true; + svc.authError = ""; + svc.enqueue({ + kind: "login", + payload: JSON.stringify({ username: username, password: password }), + command: [svc.binPath, "login", svc.host, svc.stateFile] + }); + } + + function logout() { + if (svc.binPath === "" || svc.stateFile === "") + return; + svc.enqueue({ + kind: "logout", + command: [svc.binPath, "logout", svc.stateFile] + }); + } + + function openUrl(url) { + if (!url) + return; + if (url.indexOf("http://") !== 0 && url.indexOf("https://") !== 0) + return; + Quickshell.execDetached(["xdg-open", url]); + } + + function num(value) { + if (value === null || value === undefined) + return -1; + var n = Number(value); + return isFinite(n) ? n : -1; + } + + function noteRates(sent, recv, stamp) { + var now = Date.now() / 1000; + var mark = (stamp !== null && stamp !== undefined && isFinite(Number(stamp)) && Number(stamp) > 0) + ? Number(stamp) : now; + if (svc._prevStamp > 0 && sent >= 0 && recv >= 0 && svc._prevSent >= 0) { + var dt = mark - svc._prevStamp; + if (dt <= 0 || dt > 120) + dt = now - svc._prevWall; + if (dt > 0 && dt < 120) { + svc.upBps = Math.max(0, (sent - svc._prevSent) / dt); + svc.downBps = Math.max(0, (recv - svc._prevRecv) / dt); + } + } + svc._prevSent = sent; + svc._prevRecv = recv; + svc._prevStamp = mark; + svc._prevWall = now; + } + + property real _prevWall: 0 + + function applyStatus(parsed) { + if (!parsed || parsed.ok !== true) { + svc.online = false; + svc.errorText = parsed && parsed.error ? ("" + parsed.error) : "offline"; + return; + } + svc.online = true; + svc.errorText = ""; + svc.authError = parsed.authError ? ("" + parsed.authError) : ""; + svc.authed = parsed.authed === true; + svc.username = parsed.username ? ("" + parsed.username) : ""; + svc.deviceName = parsed.deviceName ? ("" + parsed.deviceName) : ""; + svc.osVersion = parsed.osVersion ? ("" + parsed.osVersion) : ""; + svc.deviceModel = parsed.deviceModel ? ("" + parsed.deviceModel) : ""; + svc.cpuModel = parsed.cpuModel ? ("" + parsed.cpuModel) : ""; + svc.memoryBytes = svc.num(parsed.memoryBytes); + svc.dashboardUrl = parsed.dashboardUrl ? ("" + parsed.dashboardUrl) : ""; + svc.filesUrl = parsed.filesUrl ? ("" + parsed.filesUrl) : ""; + svc.backupUrl = parsed.backupUrl ? ("" + parsed.backupUrl) : ""; + svc.peerDropUrl = parsed.peerDropUrl ? ("" + parsed.peerDropUrl) : ""; + if (parsed.deviceImage) + svc.deviceImage = "" + parsed.deviceImage; + if (!svc.authed) { + svc.cpuPercent = -1; + svc.memPercent = -1; + svc.downBps = 0; + svc.upBps = 0; + svc._prevStamp = -1; + svc.storageName = ""; + svc.storageTotal = -1; + svc.storageUsed = -1; + svc.storageAvail = -1; + svc.ifaceLabel = "LAN"; + svc.ipAddress = svc.host; + svc.linkRemote = false; + svc.linkWifi = false; + svc.linkEthernet = false; + svc.linkThunderbolt = false; + svc.linkActive = ""; + return; + } + svc.cpuPercent = svc.num(parsed.cpuPercent); + svc.memPercent = svc.num(parsed.memPercent); + var net = parsed.network || {}; + svc.ifaceLabel = net.label ? ("" + net.label) : "LAN"; + svc.ipAddress = net.ip ? ("" + net.ip) : svc.host; + svc.linkRemote = net.remote === true; + svc.linkWifi = net.wifi === true; + svc.linkEthernet = net.ethernet === true; + svc.linkThunderbolt = net.thunderbolt === true; + svc.linkActive = net.active ? ("" + net.active) : ""; + svc.noteRates(svc.num(net.bytesSent), svc.num(net.bytesRecv), net.time); + var disk = parsed.storage || null; + if (disk) { + svc.storageName = disk.name ? ("" + disk.name) : ""; + svc.storageTotal = svc.num(disk.total); + svc.storageUsed = svc.num(disk.used); + svc.storageAvail = svc.num(disk.avail); + } else { + svc.storageName = ""; + svc.storageTotal = -1; + svc.storageUsed = -1; + svc.storageAvail = -1; + } + } + + function applyResult(kind, text) { + var parsed = null; + try { + parsed = JSON.parse((text || "").trim()); + } catch (e) { + parsed = { ok: false, error: "bad response" }; + } + if (kind === "status") { + // Drop a result fetched for a host that is no longer active, or the + // panel keeps the previous server after the address changes. + if (proc.opHost === svc.host) { + svc.applyStatus(parsed); + svc._statusAgain = false; + } else { + svc._statusAgain = true; + } + svc.takeLoginStatus(); + if (svc._statusAgain) { + svc._statusAgain = false; + svc.refresh(); + } + } else if (kind === "login") { + svc.busy = false; + if (parsed && parsed.ok === true) { + svc.authError = ""; + svc.refreshAfterLogin(); + } else { + svc.authError = parsed && parsed.error ? ("" + parsed.error) : "login failed"; + } + } else if (kind === "logout") { + svc.authed = false; + svc.username = ""; + svc.authError = ""; + svc.cpuPercent = -1; + svc.memPercent = -1; + svc.downBps = 0; + svc.upBps = 0; + svc._prevStamp = -1; + svc.storageName = ""; + svc.linkRemote = false; + svc.linkWifi = false; + svc.linkEthernet = false; + svc.linkThunderbolt = false; + svc.linkActive = ""; + svc.refresh(); + } + } + + onHostChanged: { + svc._prevStamp = -1; + svc.downBps = 0; + svc.upBps = 0; + svc.online = false; + svc.authed = false; + svc.deviceName = ""; + svc.osVersion = ""; + svc.deviceModel = ""; + svc.deviceImage = ""; + svc.dashboardUrl = ""; + svc.filesUrl = ""; + svc.backupUrl = ""; + svc.peerDropUrl = ""; + svc.username = ""; + svc.authError = ""; + svc.errorText = ""; + svc.ipAddress = ""; + svc.storageName = ""; + svc.storageTotal = -1; + svc.storageUsed = -1; + svc.storageAvail = -1; + if (!svc.hostReady) + return; + svc.refresh(); + } + + Timer { + interval: svc.pollSec * 1000 + repeat: true + running: svc.hostReady && svc.binPath !== "" && svc.stateFile !== "" + triggeredOnStart: true + onTriggered: svc.refresh() + } + + Process { + id: proc + running: false + property string opKind: "" + property string opHost: "" + property string payload: "" + property bool outputSeen: false + + onRunningChanged: { + if (!proc.running) + return; + proc.outputSeen = false; + proc.stdinEnabled = true; + if (proc.payload.length > 0) + proc.write(proc.payload); + proc.payload = ""; + proc.stdinEnabled = false; + } + + stdout: StdioCollector { + id: out + onStreamFinished: { + proc.outputSeen = true; + svc.applyResult(proc.opKind, out.text); + } + } + + onExited: { + if (!proc.outputSeen) + svc.applyResult(proc.opKind, out.text); + else if (proc.opKind === "status") + svc.takeLoginStatus(); + Qt.callLater(svc.pump); + } + } +}