diff --git a/assets/desktop-contract.json b/assets/desktop-contract.json index 704d9310..5d5e0d65 100644 --- a/assets/desktop-contract.json +++ b/assets/desktop-contract.json @@ -1,6 +1,7 @@ { "version": 1, "shell": { + "font": "mono", "wall": "snow-capped-mountains-with-full-moon-lo.jpg", "wallDir": "~/Pictures/Wallpapers" }, diff --git a/assets/scripts/cybexos-runtime b/assets/scripts/cybexos-runtime index b1a19a97..fd690c8f 100755 --- a/assets/scripts/cybexos-runtime +++ b/assets/scripts/cybexos-runtime @@ -224,6 +224,20 @@ exec_runtime() { "$selected" >&2 return 1 } + # The authentication UI belongs to the selected shell. Stop the old + # agent synchronously before QML registers its listener; this also + # handles an upgrade while the previous agent is still running. + # Older releases remain usable when rolling back or leaving dev mode. + if [[ ${CYBEXOS_RUNTIME_TESTING:-0} != 1 ]] && { + systemctl --user is-active --quiet hyprpolkitagent.service || + [[ $(systemctl --user show hyprpolkitagent.service -p LoadState --value 2>/dev/null) == loaded ]]; + }; then + if [[ -f $selected/PolkitWindow.qml ]]; then + systemctl --user stop hyprpolkitagent.service + else + systemctl --user start --no-block hyprpolkitagent.service + fi + fi export CYBEXOS_USER_CONFIG_ROOT=$config_root export CYBEXOS_THEME_ROOT=$data_root/themes export CYBEXOS_PLUGIN_ROOT=$data_root/plugins diff --git a/docs/authentication-dialog.md b/docs/authentication-dialog.md new file mode 100644 index 00000000..035767e9 --- /dev/null +++ b/docs/authentication-dialog.md @@ -0,0 +1,75 @@ +# Authentication dialog + +CybexOS handles Polkit requests in the managed Quickshell process. The built-in +`PolkitWindow.qml` owns one `Quickshell.Services.Polkit.PolkitAgent`, including +when a plugin replaces the bar. `PolkitPrompt.qml` presents its current flow. +Polkit and the system's PAM configuration still decide which identities may +authenticate and whether their responses succeed; the shell changes no policy. + +The compact card uses `Common/Theme.qml` fonts, density, light/dark colors and +wallpaper palette. It opens on the focused monitor and stays there until the +request ends, with a fallback if that output disappears. Account names wrap, +multiple eligible identities get a selector, and the action ID is available +under Show details. The card scrolls on small outputs or with large text. + +Enter submits the current response, Escape or Cancel aborts the request, and +Tab reaches the controls. Clicking the dimmed background does not dismiss it. +The launcher, drawers and shortcut sheet close during authentication so they +cannot take the password field's keyboard focus. The Network overlay temporarily +hides and releases its focus grab while keeping its requesting helper alive; +it returns when the authentication flow ends. A normal settings window can +remain open behind a request it initiated. + +Passwords start masked, never echo the last character, and can be revealed +with the eye button. PAM prompts that request visible responses are supported, +as are informational messages while waiting for fingerprint or other methods. +The response is cleared on submission, cancellation, account/prompt changes, +completion and replacement by another request. Closing the window destroys +the input. Responses go directly to the backend; no shell command, IPC method, +log or persistent setting carries them. This does not promise secure erasure +of Qt's or the authentication library's internal memory. + +## Startup and rollback + +`cybexos-runtime exec quickshell` stops a loaded or active +`hyprpolkitagent.service` before registering the integrated agent. Source +deployment removes the old session enablement and managed unit; new images +neither require nor start the standalone agent. Existing RPMs are left +installed. Selecting an older runtime without `PolkitWindow.qml` starts the +legacy agent when its unit is available, including after a deployment rollback. + +`cybexos-runtime ipc polkit status` returns only `registered` and `active`. +There is no diagnostic method to read or submit a response. Registration must +be true before considering a deployment healthy. After switching from an old +installed runtime resolver to development source, use the checkout's resolver +for the managed service too, or stop the old agent before restarting Quickshell. +Do not start a second `qs` instance to test a dialog. + +## Verification + +`tests/run` checks account-label handling, QML, startup integration and icon +coverage. `tests/ownership-layering.py` verifies the agent transition and legacy +fallback with isolated service/executable fixtures. The production-component +lifecycle harness exercises submission, duplicate-submit prevention, retry, +account switching, visible PAM responses, local/remote cancellation, completion +and clearing state between requests. Its Polkit fixture never authenticates +against the host. The real-engine harness runs in CI without an active shell; +on a workstation use `tests/lib/quickshell-live` before and after any controlled +test that stops and restores the service. + +For live acceptance, verify registration, trigger a real Polkit authorization +check, cancel with the keyboard and confirm the requesting process is denied. +Also check details expansion, password visibility, Tab order, background clicks, +blocked competing overlays, large text, both color modes and output removal. +Successful authentication and fingerprint/other PAM methods require an operator +with the relevant credential or hardware; never collect their password through +agent tools. + +On 2026-09-26 the installed Quickshell 0.2.1 build registered successfully and +displayed a real `com.1password.1Password.unlock` authorization check. Escape +returned a dismissed result to `pkcheck`; Tab navigation, visibility, details +and blocking the competing launcher were exercised. The isolated real-engine +conversation fixture and the image's 29 installed-policy/user-parity tests +passed. The managed service finished as the sole `qs` process, with no QML +errors in its current invocation and the old agent inactive. Successful real +authentication, fingerprint hardware and monitor removal were not exercised. diff --git a/docs/omarchy-plugin-compatibility.md b/docs/omarchy-plugin-compatibility.md index 8250dc72..dcf4a97d 100644 --- a/docs/omarchy-plugin-compatibility.md +++ b/docs/omarchy-plugin-compatibility.md @@ -259,10 +259,10 @@ accessibility scale, spacing density, border and corner settings described in [Shell appearance](shell-appearance.md). Appearance settings apply individually; there are no appearance presets. -The defaults use JetBrainsMono Nerd Font at 12px, standard spacing, no panel +The defaults use JetBrainsMono Nerd Font at 14px, standard spacing, no panel border and 16px panel corners. At the default accessibility scale, Model Usage's `Style.space(420)` -is 420 logical pixels (840 image pixels on a 200% output). Qt applies monitor +is 490 logical pixels (980 image pixels on a 200% output). Qt applies monitor scaling; the shell never multiplies geometry by monitor scale itself. Settings → Appearance → Plugins provides an additional interface scale (75–200%). Border mode diff --git a/docs/quickshell-notes.md b/docs/quickshell-notes.md index a93797f3..d61062b2 100644 --- a/docs/quickshell-notes.md +++ b/docs/quickshell-notes.md @@ -33,6 +33,10 @@ you need the reasoning behind a particular change; `git log --oneline ## Testing without a GUI +The built-in [authentication dialog](authentication-dialog.md) uses Quickshell's +Polkit service. Its presentation and startup checks are covered below; its +authentication backend remains the system Polkit/PAM stack. + Run `./tests/run` first; it needs no live shell. External widget tests require `sway` for a disposable headless Wayland compositor (also installed by CI); this is a test dependency, not a change to the desktop's compositor. diff --git a/docs/remote-server-widget.md b/docs/remote-server-widget.md new file mode 100644 index 00000000..fa216ab2 --- /dev/null +++ b/docs/remote-server-widget.md @@ -0,0 +1,99 @@ +# Remote Server widget + +Add **Remote Server** from Settings → Menubar → Widgets, then open its options. +Set **SSH host** to an existing SSH alias or `user@hostname`, and optionally set +a display name. For example: `john@10.10.0.7`, **The Beast**. These are personal +settings, not distribution defaults; new installations leave the widget disabled +and its host empty, and its dashboard offers **Choose SSH host** until one is set. + +The default menubar statistic is CPU utilization. Options include load average, +memory used percent/bytes or available bytes, filesystem used percent/free bytes, +network receive/transmit rate, and the hottest reported temperature. The server +label can be hidden or compacted away on a crowded bar. A healthy server shows +only its reading; the reading turns amber or red past its warning threshold +(85%/95% for percentages and per-CPU load, 75/90 °C for temperature), and the +server mark gains an amber badge while readings are stale or a red one once the +connection is lost. + +Click the widget for its dashboard. The header names the server with its SSH +destination, uptime and a Live/Stale/Offline/Connecting status. Four tiles show +CPU, memory, the selected filesystem and the hottest sensor, each with a meter; +the tiles are also tabs for the detail beneath them, which opens on whatever the +menubar shows: + +- **CPU**: usage history, one bar per logical CPU (hover for its reading), the + CPU model and 1/5/15-minute load, including load per thread. +- **Memory**: usage history with used, available, total and swap use. +- **Storage**: local filesystems, fullest first. Bind mounts of one device + collapse into its shortest path and firmware variable stores are hidden. + Selecting a row makes it the filesystem the tile and menubar report. +- **Temp**: history of the hottest sensor and the hottest sensors by name; + repeated chip names (one per NVMe drive) are numbered. + +Network download/upload rates and their history stay in view below. **Change** +lists the default route, then addressed or active interfaces (idle container +links on request); choosing one pins it, and Automatic follows the default +route. Automatic networking prefers the default route, then the busiest +interface with an address. It never sums bridges, bonds and members together. + +Charts cover the history collected so far, from two up to ten minutes, and +scroll with time between samples; network charts scale to the next binary unit +above their peak. Missing sensors and first-sample rates are unavailable, never +zero. Disconnections keep the last readings, dimmed, with the reason, a Retry +action and the time since the last reading. Long lists scroll inside the +dashboard while its header and footer stay fixed. + +## Connection requirements + +- Linux with readable `/proc` and Python **3.9+** on the server. +- OpenSSH and Python 3 on the desktop. GNU `df` provides local filesystem stats; + `ip` provides optional addresses/default-route discovery on the server. +- Working noninteractive SSH key/agent authentication. First connect in a + terminal, for example `ssh john@10.10.0.7`, to verify its host key. Unknown or + changed keys are rejected; the widget never accepts them automatically. +- Configure ports, identities and jump hosts in `~/.ssh/config`. The host field + accepts a destination, not SSH options or a shell command. + +No root access, remote installation, remote file writes or remote service is +needed. Temperature sensors depend on the host's drivers and permissions. +Physical DIMM type/speed, SMART health and privileged hardware information are +outside the current collector. Network filesystems and temporary/container +overlay mounts are excluded from capacity collection. + +## Implementation and lifecycle + +`Common/RemoteServer.qml` owns a single `Process` for the whole shell, independent +of monitor count. `scripts/remote-server.py` validates the destination and execs +SSH, sending the self-contained `remote_server_probe.py` as a quoted Python +program. One persistent SSH channel streams newline-delimited JSON. Its stdin +accepts cadence/refresh messages, and EOF terminates the probe. The connection +does not create a background SSH master or forward an agent/ports. + +The default interval is five seconds; opening the dashboard or its settings +claims two-second sampling. Closing the last view returns to the configured +interval. Disabling the widget and closing its views stops the connection. +CPU and network rates use counter deltas over actual monotonic elapsed time, +with unknown rates on initial/reset samples. Memory usage uses `MemAvailable`, +not `MemFree`; filesystem free space is what an unprivileged user can use. +Hardware details, addresses and filesystems refresh every minute, or on Refresh; +optional `df`/`ip` commands have bounded execution time. + +History stays in memory (up to ten minutes/300 points) and resets on host change, +reboot or a long sampling gap. Reconnection backs off from five to sixty seconds. +A heartbeat timeout catches a stalled stream. Host changes discard old data and +ignore the previous connection's late output. SSH failures appear in the view. + +Read-only diagnostics and manual refresh use the normal runtime IPC entrypoint: + +```sh +cybexos-runtime ipc remoteServer status +cybexos-runtime ipc remoteServer refresh +cybexos-runtime ipc remoteServer configure +cybexos-runtime ipc popouts open remote +``` + +Tests cover counter resets, memory semantics, cache lifetime, transport quoting, +SSH restrictions, stream cadence/EOF, malformed samples, interface selection, +metric formatting, warning levels, chart windows and scales, filesystem and +sensor presentation, history bounds and settings migration. Run `./tests/run`. +For live checks, use `tests/lib/quickshell-live` begin/end and the managed service. diff --git a/docs/shell-appearance.md b/docs/shell-appearance.md index 23159ce0..f90fa4a5 100644 --- a/docs/shell-appearance.md +++ b/docs/shell-appearance.md @@ -42,18 +42,23 @@ settings. Existing explicit plugin border overrides remain valid; choose surface borders. The defaults use Dark mode, a Hug bar, wallpaper colors, opaque surfaces and -numbered workspaces. Typography uses JetBrainsMono Nerd Font at 12px, 100% UI +numbered workspaces. Typography uses JetBrainsMono Nerd Font at 14px, 100% UI scale and standard spacing. Panels have 16px corners and no border (width 0); plugins inherit the shared appearance. Appearance has no preset actions. Existing saved preferences remain in effect; section resets use these defaults. +New ISO-installed accounts explicitly seed the same JetBrainsMono font choice. +Empty or older settings without a font also inherit this default; the historical +Google Sans migration applies only to a stored pre-schema-7 Urbanist value. The shared library and usage rules are documented in [Shell typography](shell-typography.md). -Typography follows [Omarchy's default scale](https://github.com/omacom/omarchy/blob/quattro/default/themed/shell.toml.tpl) -and [default monospace family](https://github.com/omacom/omarchy/blob/quattro/default/fontconfig/conf.avail/50-omarchy.conf): -10px captions, 11px secondary copy, 12px body/control/bar text, 14px titles, -16px headings, and 24/28px display values. Settings labels, inputs, pickers, -and actions use the same body role as plugin controls. Regular copy uses +Typography derives from [Omarchy's scale](https://github.com/omacom/omarchy/blob/quattro/default/themed/shell.toml.tpl) +and [default monospace family](https://github.com/omacom/omarchy/blob/quattro/default/fontconfig/conf.avail/50-omarchy.conf), +with a larger default base: 12px captions, 13px secondary copy, +14px body/control/bar text, 16px titles, 18px headings, and 28/33px display +values. The heading multiplier is tuned to 18px at the new base. +Settings labels, inputs, pickers and actions use the same body role as plugin +controls. Regular copy uses weight 400; headings can use medium, semibold or bold. Native body/caption and plugin body/caption share the same reference sizes. diff --git a/docs/shell-typography.md b/docs/shell-typography.md index 6fbe1124..62f875d2 100644 --- a/docs/shell-typography.md +++ b/docs/shell-typography.md @@ -7,7 +7,24 @@ All three resolve through the same library. Existing `Theme.fontBody` and similar aliases, `Style.font.body`, and `api.theme.fontSize` remain compatible. New native views must use the named roles below, not the old aliases. -## Comparison with Omarchy +## Readable defaults + +Since 2026-09-26 the default base is **14 logical pixels**. Normal labels, +controls, navigation and bar readings are 14px; secondary descriptions and +tooltips are 13px; captions, section labels and metadata are 12px. Titles, +notifications and OSD text are 16px, and headings (including launcher queries +and results) are 18px. These are the sizes at 100% interface scale and default +text size; explicit smaller settings and plugin overrides remain available. + +Native and plugin surfaces use the same resolver. The Omarchy multipliers +below are retained except for `heading`, which uses 18/14 of the effective +base. Geometry retains its 12px reference so panels and spacing grow with the +larger text; wrapping rows grow to their content and panels clamp to the output. +Fresh installations and appearance resets use 14px. Existing saved font sizes +are preserved; select 14px under Appearance → Advanced text options to adopt +the new size on an existing installation. + +## Historical comparison with Omarchy (12px base) Audited on 2026-09-21 against Omarchy commit [`961ec7f39fd0d70c7d2944c5b80585a86713693d`](https://github.com/omacom/omarchy/tree/961ec7f39fd0d70c7d2944c5b80585a86713693d). @@ -45,8 +62,8 @@ Sources at the audited revision: [OSD](https://github.com/omacom/omarchy/blob/961ec7f39fd0d70c7d2944c5b80585a86713693d/shell/plugins/osd/Osd.qml), [clock hero](https://github.com/omacom/omarchy/blob/961ec7f39fd0d70c7d2944c5b80585a86713693d/shell/plugins/panels/clock/Panel.qml). -The core scale is caption 10, body-small 11, body 12, subtitle 13, title 14, -heading 16, display 24 and display-large 28. The `clock` role explicitly +At the September 21 audit the core scale was caption 10, body-small 11, body 12, +subtitle 13, title 14, heading 16, display 24 and display-large 28. The `clock` role explicitly centralizes Omarchy's exceptional 52px date treatment for our date/time hero; it is not a general heading. Icons keep their separate optical sizes. Omarchy uses Liberation Sans for notifications; we retain the user's shared @@ -56,7 +73,7 @@ shared size/usage contract, not a claim of identical layout or rendering. ## Implementation contract `ShellMetrics.calculate()` combines base size, UI scale and accessibility -scale once. `Typography.resolve()` applies Omarchy's multipliers and rounds +scale once. `Typography.resolve()` applies the shared multipliers and rounds once per token. Density affects spacing, not font size; Qt applies monitor scaling. Native and plugin adapters no longer maintain independent type scales. At default plugin settings their role values are identical, including @@ -84,15 +101,26 @@ because the available row is short. Reserve `section` for group labels and wrapping, scrolling or elision when space is constrained; do not invent a local smaller size. New exceptional sizes need a documented shared token. -The launcher deliberately uses `heading` (16px at the default base) for its -search query and primary result labels, matching Omarchy v4.0.4's menu. +The launcher deliberately uses `heading` (18px at the default base) for its +search query and primary result labels, following Omarchy v4.0.4's menu hierarchy. The query is regular weight and result labels are medium weight. Provider tabs -use `title` (14px) at medium weight with 16px icons. This prominent search field -is an exception to the ordinary `control` input role and still follows the -shared accessibility scale. +use `title` (16px) at medium weight with icons from the shared scale. This +prominent search field is an exception to the ordinary `control` input role +and still follows the shared accessibility scale. ## Verification +The 2026-09-26 readability update passed 1,163 unit tests and the required +repository check stages, with the updated typography expectations rerun. +Managed-service checks confirmed identical native/plugin role maps for all +nine text-size/density combinations, using effective bases of 14, 16 and 18px. +Overview (including the growing media row), sound, Appearance and the launcher +were inspected at the new default, and Overview at Larger text with Compact +spacing. The service finished as the sole Quickshell process with no QML +errors in its current invocation. Both outputs used 2× device scaling; +fractional output scaling and the isolated full-shell lifecycle harness were +not exercised on this live desktop. + `tests/quickshell/typography-scale.test.cjs` checks reference values, usage roles, native/plugin adapter wiring, the accessibility/density matrix, plugin overrides and icon fallbacks. Source checks reject pixel literals, point diff --git a/image/cybexos-desktop.spec b/image/cybexos-desktop.spec index 09f138c1..87c22978 100644 --- a/image/cybexos-desktop.spec +++ b/image/cybexos-desktop.spec @@ -20,7 +20,7 @@ Obsoletes: fedora-config-desktop < %{epoch}:%{version}-%{release} %global _binary_filedigest_algorithm 8 Requires: bash coreutils util-linux systemd python3 ansible-core gnupg2 Requires: sddm sddm-wayland-generic systemd-pam gnome-keyring-pam -Requires: hyprland hyprland-guiutils quickshell hypridle hyprlock hyprpolkitagent hyprsunset +Requires: hyprland hyprland-guiutils quickshell hypridle hyprlock hyprsunset Requires: xdg-desktop-portal-hyprland xdg-desktop-portal-gtk xdg-utils Requires: qt6-qtwebsockets-devel qt6-qt5compat qt6-qtsvg Requires: qt6-qtbase qt6-qtdeclarative qt6-qtwayland @@ -72,7 +72,6 @@ cp -a usr opt etc %{buildroot}/ /usr/libexec/cybexos-* /usr/lib/systemd/user/*.service /usr/lib/systemd/user/hypridle.service.d/ -/usr/lib/systemd/user/hyprpolkitagent.service.d/ /usr/lib/systemd/user/voxtype.service.d/ /usr/lib/systemd/user/hyprland-session.target /usr/lib/systemd/system/sddm.service.d/ diff --git a/image/repair-installed b/image/repair-installed index ed9433d8..4c5aaa92 100755 --- a/image/repair-installed +++ b/image/repair-installed @@ -152,7 +152,7 @@ def apply(payload, account, packages, hardware=False): if session: run(user_command(account, ['systemctl', '--user', 'daemon-reload'])) run(user_command(account, ['systemctl', '--user', 'start', - 'hyprpolkitagent.service', 'hypridle.service', 'voxtype.service'])) + 'hypridle.service', 'voxtype.service'])) finally: if shell_active: run(user_command(account, ['systemctl', '--user', 'start', 'quickshell.service'])) diff --git a/image/rootfs/usr/lib/systemd/user/hyprland-session.target b/image/rootfs/usr/lib/systemd/user/hyprland-session.target index 426977e0..11401da8 100644 --- a/image/rootfs/usr/lib/systemd/user/hyprland-session.target +++ b/image/rootfs/usr/lib/systemd/user/hyprland-session.target @@ -4,6 +4,6 @@ Description=CybexOS graphical session # target after every Wanted service, which would close a dependency cycle. DefaultDependencies=no BindsTo=graphical-session.target -Wants=graphical-session-pre.target quickshell.service hyprpolkitagent.service hypridle.service voxtype.service hermes-menubar-bridge.service cybexos-welcome.service cybexos-app-seed.service cybexos-input-method.service external-monitor-toggle.service +Wants=graphical-session-pre.target quickshell.service hypridle.service voxtype.service hermes-menubar-bridge.service cybexos-welcome.service cybexos-app-seed.service cybexos-input-method.service external-monitor-toggle.service After=graphical-session-pre.target Before=graphical-session.target diff --git a/image/rootfs/usr/lib/systemd/user/hyprpolkitagent.service.d/50-cybexos.conf b/image/rootfs/usr/lib/systemd/user/hyprpolkitagent.service.d/50-cybexos.conf deleted file mode 100644 index 2244e6b2..00000000 --- a/image/rootfs/usr/lib/systemd/user/hyprpolkitagent.service.d/50-cybexos.conf +++ /dev/null @@ -1,2 +0,0 @@ -[Unit] -PartOf=hyprland-session.target diff --git a/image/test_desktop_payload.py b/image/test_desktop_payload.py index 5d346eb9..15492aad 100644 --- a/image/test_desktop_payload.py +++ b/image/test_desktop_payload.py @@ -45,6 +45,23 @@ def test_portable_defaults_and_boot_assets_share_workstation_sources(self): split_seed(vendor, contract) defaults = json.loads((vendor / "essential-seed/.config/cybexos/shell.json").read_text()) self.assertEqual(defaults, contract["shell"]) + # Exercise first-login seeding followed by the real shell merger: + # an unversioned seed must not revive a historical font default. + (vendor / "bin").mkdir() + (vendor / "runtime").mkdir() + home = payload / "installed-user" + with patch.dict(os.environ, {}, clear=True): + INIT.initialize(home, vendor, mode="essential") + preferences = home / ".config/cybexos/shell.json" + result = subprocess.run([ + "node", "-e", + "const fs = require('fs'), H = require(process.argv[1]); " + "const s = H.merge(JSON.parse(fs.readFileSync(process.argv[2], 'utf8'))); " + "console.log(H.FONT_CHOICES.find(f => f.id === s.font).family);", + str(ROOT / "roles/desktop/files/quickshell/Common/SettingsHelpers.js"), + str(preferences), + ], text=True, capture_output=True, check=True) + self.assertEqual(result.stdout.strip(), "JetBrainsMono Nerd Font") wallpaper = vendor / "essential-seed/Pictures/Wallpapers" / defaults["wall"] self.assertEqual(wallpaper.read_bytes(), (ROOT / "assets/wallpapers" / defaults["wall"]).read_bytes()) self.assertTrue(app.is_file()) diff --git a/image/test_installed_policy.py b/image/test_installed_policy.py index 17cedbf3..dffd8afb 100644 --- a/image/test_installed_policy.py +++ b/image/test_installed_policy.py @@ -190,7 +190,7 @@ def test_session_target_can_start_services_ordered_after_graphical_session(self) (units / target.name).write_text(target.read_text()) for unit in ('graphical-session', 'graphical-session-pre'): (units / (unit + '.target')).write_text('[Unit]\nDescription=Fixture\n') - for name in ('quickshell', 'hyprpolkitagent', 'hypridle', 'voxtype', + for name in ('quickshell', 'hypridle', 'voxtype', 'hermes-menubar-bridge', 'cybexos-welcome', 'cybexos-app-seed'): (units / (name + '.service')).write_text( '[Unit]\nAfter=graphical-session.target\n[Service]\nExecStart=/usr/bin/true\n') diff --git a/image/vm_testing.py b/image/vm_testing.py index 2489f7c3..189d5530 100644 --- a/image/vm_testing.py +++ b/image/vm_testing.py @@ -107,8 +107,10 @@ def audit_script(): import pwd assert pwd.getpwuid(os.getuid()).pw_shell == '/usr/bin/fish' assert Path('/etc/cybexos/hardware.json').is_file() - for unit in ('hyprpolkitagent', 'hypridle', 'voxtype'): + for unit in ('quickshell', 'hypridle', 'voxtype'): subprocess.run(['systemctl', '--user', 'is-active', unit], check=True) + status = json.loads(subprocess.check_output(['cybexos-runtime', 'ipc', 'polkit', 'status'], text=True)) + assert status['registered'], 'Quickshell Polkit agent did not register' for unit in ('tuned-ppd', 'fwupd-refresh.timer', 'cybexos-hardware-setup.timer'): subprocess.run(['systemctl', 'is-enabled', unit], check=True) aliases = subprocess.check_output(['fish', '-ic', 'functions codex claude'], text=True) diff --git a/roles/desktop/files/quickshell/Bar/Bar.qml b/roles/desktop/files/quickshell/Bar/Bar.qml index 3d73a102..a834d793 100644 --- a/roles/desktop/files/quickshell/Bar/Bar.qml +++ b/roles/desktop/files/quickshell/Bar/Bar.qml @@ -664,6 +664,7 @@ PanelWindow { readonly property var moduleSources: ({ ws: "Modules/Workspaces.qml", media: "Modules/Media.qml", clock: "Modules/Clock.qml", weather: "Modules/Weather.qml", + remote: "Modules/Remote.qml", notes: "Modules/Notes.qml", modelusage: "Modules/ModelUsage.qml", indicators: "Modules/Indicators.qml", diff --git a/roles/desktop/files/quickshell/Bar/Modules/Remote.qml b/roles/desktop/files/quickshell/Bar/Modules/Remote.qml new file mode 100644 index 00000000..2825bb8e --- /dev/null +++ b/roles/desktop/files/quickshell/Bar/Modules/Remote.qml @@ -0,0 +1,98 @@ +import QtQuick +import ".." +import "../../Common" +import "../../Common/RemoteServerHelpers.js" as Helpers + +// The chosen reading beside a server mark. The mark carries a badge only when +// something needs attention — amber while readings are old, red once the +// connection is lost — and the reading turns amber or red past its warning +// threshold, so a healthy server is just a number. +BarModule { + id: root + + readonly property string connection: RemoteServer.connection + readonly property bool troubled: connection === "stale" || connection === "offline" + readonly property string level: Helpers.metricLevel(RemoteServer.sample, RemoteServer.options) + + moduleId: "remote" + detailSaving: RemoteServer.options.showLabel ? serverName.width + chip.spacing : 0 + + BarChip { + id: chip + host: root.host + panelName: "remote" + isle: root.isle + anchorItem: root.groupAnchor ?? chip + spacing: 6 + tooltip: [RemoteServer.label + " · " + RemoteServer.status, + RemoteServer.sample ? "CPU " + Helpers.percent(RemoteServer.sample.cpu) + + " · Memory " + Helpers.percent(Helpers.memoryPercent(RemoteServer.sample)) + + " · Up " + Helpers.uptime(RemoteServer.sample.uptime) : "", + root.troubled && RemoteServer.sample ? "Last reading " + RemoteServer.age : "", + RemoteServer.error].filter(line => line !== "").join("\n") + + Item { + anchors.verticalCenter: parent.verticalCenter + width: Theme.barIconSize + height: Theme.barIconSize + + Sym { + anchors.fill: parent + name: "dns" + size: Theme.barIconSize + color: chip.fg + } + + // Ringed in the bar surface so it reads as sitting on the mark, + // as the notification bell's unread mark does. + Rectangle { + visible: root.troubled + anchors.right: parent.right + anchors.top: parent.top + anchors.rightMargin: -3 + anchors.topMargin: -3 + width: 8 + height: 8 + radius: 4 + color: Theme.barSurface + + Rectangle { + anchors.centerIn: parent + width: 5 + height: 5 + radius: 3 + color: root.connection === "offline" ? Theme.barRedText : Theme.barAmber + } + } + } + Text { + id: serverName + anchors.verticalCenter: parent.verticalCenter + visible: RemoteServer.options.showLabel && !root.compact + text: RemoteServer.label + width: Math.min(implicitWidth, 140) + elide: Text.ElideRight + textFormat: Text.PlainText + font.family: Theme.fontMenu + font.pixelSize: Theme.typography.bar + font.weight: Theme.weightMedium + color: Theme.barTextLow + } + Text { + anchors.verticalCenter: parent.verticalCenter + text: RemoteServer.host ? RemoteServer.barValue : "Set up" + font.family: Theme.fontNumeric + font.pixelSize: Theme.typography.bar + font.weight: Theme.weightSemibold + font.features: Theme.tabularNumberFeatures + color: !RemoteServer.host ? Theme.barTextLow + : root.troubled || !RemoteServer.sample ? Theme.barTextFaint + : root.level === "critical" ? Theme.barRedText + : root.level === "warn" ? Theme.barAmber : Theme.barTextHi + + Behavior on color { + ColorAnimation { duration: Theme.chipFadeDuration } + } + } + } +} diff --git a/roles/desktop/files/quickshell/Bar/Modules/qmldir b/roles/desktop/files/quickshell/Bar/Modules/qmldir index 29407b31..01a3630d 100644 --- a/roles/desktop/files/quickshell/Bar/Modules/qmldir +++ b/roles/desktop/files/quickshell/Bar/Modules/qmldir @@ -20,3 +20,4 @@ Volume Volume.qml Weather Weather.qml Wifi Wifi.qml Workspaces Workspaces.qml +Remote Remote.qml diff --git a/roles/desktop/files/quickshell/Common/NetworkOverlayState.qml b/roles/desktop/files/quickshell/Common/NetworkOverlayState.qml index c77ac438..f6224435 100644 --- a/roles/desktop/files/quickshell/Common/NetworkOverlayState.qml +++ b/roles/desktop/files/quickshell/Common/NetworkOverlayState.qml @@ -9,6 +9,8 @@ Singleton { id: root property bool open: false + // Keep an authorizing helper alive while Polkit owns the keyboard. + property bool authenticationActive: false property string page: "" property var screen: null property string interfaceName: "" diff --git a/roles/desktop/files/quickshell/Common/PanelRegistryData.js b/roles/desktop/files/quickshell/Common/PanelRegistryData.js index 9abf3973..4ada8951 100644 --- a/roles/desktop/files/quickshell/Common/PanelRegistryData.js +++ b/roles/desktop/files/quickshell/Common/PanelRegistryData.js @@ -67,6 +67,7 @@ var PANELS = [ { name: "media", island: "left", moduleId: "media", source: "Popovers/MediaPopover.qml" }, { name: "t3code", island: "right", moduleId: "t3", source: "Popovers/T3CodePopover.qml", attached: true }, { name: "hermes", island: "right", moduleId: "hermes", source: "Popovers/HermesPopover.qml" }, + { name: "remote", island: "right", moduleId: "remote", source: "Popovers/RemoteServerPopover.qml" }, { name: "github", island: "right", moduleId: "gh", source: "Popovers/GitHubPopover.qml" }, { name: "overflow", island: "right", moduleId: "", source: "Popovers/OverflowPopover.qml" }, diff --git a/roles/desktop/files/quickshell/Common/PolkitHelpers.js b/roles/desktop/files/quickshell/Common/PolkitHelpers.js new file mode 100644 index 00000000..33406fe4 --- /dev/null +++ b/roles/desktop/files/quickshell/Common/PolkitHelpers.js @@ -0,0 +1,18 @@ +// Identity names come from NSS; GECOS may also contain phone/office fields. +function identityLabel(identity) { + if (!identity) + return ""; + const login = String(identity.string || identity.name || (identity.id ?? "")); + const display = String(identity.displayName || login).split(",")[0].trim() || login; + const label = display === login ? login : display + " (" + login + ")"; + return identity.isGroup ? "Group: " + label : label; +} + +if (typeof module !== "undefined" && module.exports) + module.exports = { identityLabel, identityOptions }; + +function identityOptions(identities) { + return (identities || []).map((identity, index) => ({ + value: index, label: identityLabel(identity) + })); +} diff --git a/roles/desktop/files/quickshell/Common/RemoteServer.qml b/roles/desktop/files/quickshell/Common/RemoteServer.qml new file mode 100644 index 00000000..aa9d575a --- /dev/null +++ b/roles/desktop/files/quickshell/Common/RemoteServer.qml @@ -0,0 +1,141 @@ +pragma Singleton +import QtQuick +import Quickshell +import Quickshell.Io +import "RemoteServerHelpers.js" as Helpers + +Singleton { + id: root + readonly property var options: Settings.modOpts.remote + readonly property string host: options.host + readonly property string label: options.label || host || "Remote Server" + readonly property bool widgetOn: ["left", "center", "right"].some( + column => Settings.mods[column].some(entry => entry.id === "remote" && entry.on)) + property int watchers: 0 + readonly property bool wanted: host !== "" && (widgetOn || watchers > 0) + readonly property int cadence: watchers > 0 ? 2 : options.pollSecs + property var sample: null + property var history: [] + property string error: "" + property double updatedAt: 0 + property double now: Date.now() + property double heartbeat: 0 + property int failures: 0 + readonly property bool busy: worker.running && updatedAt === 0 + readonly property bool stale: sample !== null && (error !== "" || !worker.running + || now - updatedAt > Math.max(15000, cadence * 3000)) + readonly property string age: updatedAt > 0 ? Helpers.ago(now - updatedAt) : "No readings yet" + // One connection state for the chip, the dashboard and IPC. An error wins + // over retained readings: they stay on screen, marked as old. + readonly property string connection: !host ? "setup" : error ? "offline" + : stale ? "stale" : sample ? "live" : "connecting" + readonly property string status: ({ setup: "Set up SSH", offline: "Disconnected", + stale: "Stale", live: "Connected", connecting: "Connecting…" })[connection] + readonly property string barValue: Helpers.metric(sample, options) + readonly property var selectedNetwork: Helpers.network(sample, options.interface) + + function acquire() { watchers++; } + function release() { watchers = Math.max(0, watchers - 1); } + function configure() { + Settings.openWidgetSettings("remote"); + Settings.showPanel("bar", Popouts.hostScreenName); + } + function start() { + if (!wanted || worker.running) return; + retry.stop(); + worker.issuedHost = host; + worker.command = ["python3", "-B", Quickshell.shellDir + "/scripts/remote-server.py", host]; + worker.running = true; + } + function refresh() { + if (worker.running) worker.write(JSON.stringify({ interval: cadence, refresh: true }) + "\n"); + else { failures = 0; start(); } + } + function receive(line) { + if (!wanted || worker.issuedHost !== host) return; + try { + if (line.length > 262144) throw new Error("Telemetry response too large"); + const value = JSON.parse(line); + if (!Helpers.validSample(value)) throw new Error("Invalid telemetry response"); + now = Date.now(); + history = Helpers.historyAppend(history, value, now); + sample = value; + updatedAt = now; + heartbeat = now; + error = ""; + failures = 0; + } catch (e) { + error = "Could not read server statistics. Check that the server supports Python 3.9+ and Linux /proc."; + worker.running = false; + } + } + onHostChanged: { + sample = null; history = []; updatedAt = 0; error = ""; failures = 0; + retry.stop(); + if (worker.running) worker.running = false; + else Qt.callLater(start); + } + onWantedChanged: { + if (wanted) Qt.callLater(start); + else { retry.stop(); worker.running = false; } + } + onCadenceChanged: { + if (worker.running) worker.write(JSON.stringify({ interval: cadence }) + "\n"); + } + Component.onCompleted: Qt.callLater(start) + + IpcHandler { + target: "remoteServer" + function status(): string { + return JSON.stringify({ host: root.host, label: root.label, connection: root.connection, + status: root.status, updatedAt: root.updatedAt, stale: root.stale, error: root.error, + cadence: root.cadence, watchers: root.watchers, running: worker.running, + metric: root.options.metric, value: root.barValue, sample: root.sample }); + } + function refresh(): void { root.refresh(); } + function configure(): void { root.configure(); } + } + + Timer { + id: retry + onTriggered: root.start() + } + Timer { + interval: 1000 + running: root.wanted + repeat: true + onTriggered: { + root.now = Date.now(); + if (worker.running && root.now - root.heartbeat > Math.max(20000, root.cadence * 3000)) { + root.error = "Server stopped responding; reconnecting…"; + worker.running = false; + } + } + } + Process { + id: worker + property string issuedHost: "" + property string diagnostic: "" + stdinEnabled: true + stdout: SplitParser { onRead: data => root.receive(data) } + stderr: SplitParser { + onRead: data => { if (data.trim()) worker.diagnostic = data.trim().slice(0, 240); } + } + onStarted: write(JSON.stringify({ interval: root.cadence }) + "\n") + onRunningChanged: { + if (running) { + diagnostic = ""; + root.heartbeat = Date.now(); + } else if (root.wanted) { + if (issuedHost !== root.host) { + retry.interval = 1; + } else { + if (!root.error) root.error = diagnostic || "SSH connection ended. Check the host, trusted host key and SSH key access."; + root.failures++; + retry.interval = Math.min(60000, 5000 * Math.pow(2, Math.min(root.failures - 1, 4))); + } + retry.restart(); + } + } + } +} diff --git a/roles/desktop/files/quickshell/Common/RemoteServerHelpers.js b/roles/desktop/files/quickshell/Common/RemoteServerHelpers.js new file mode 100644 index 00000000..219312a7 --- /dev/null +++ b/roles/desktop/files/quickshell/Common/RemoteServerHelpers.js @@ -0,0 +1,229 @@ +// Pure presentation/validation helpers, also exercised by Node fixtures. +var METRICS = [ + { value: "cpu", label: "CPU utilization" }, + { value: "load", label: "Load average" }, + { value: "memory", label: "Memory used (%)" }, + { value: "memoryUsed", label: "Memory used" }, + { value: "memoryFree", label: "Memory available" }, + { value: "disk", label: "Storage used (%)" }, + { value: "diskFree", label: "Storage free" }, + { value: "rx", label: "Network download" }, + { value: "tx", label: "Network upload" }, + { value: "temperature", label: "Temperature (hottest sensor)" } +]; + +// The dashboard's reading tiles, in order. Each also selects the history +// shown beneath them; storage shows its filesystems instead of a chart. +var VIEWS = [ + { value: "cpu", label: "CPU" }, + { value: "memory", label: "Memory" }, + { value: "storage", label: "Storage" }, + { value: "temperature", label: "Temp" } +]; +// Warning and critical thresholds. Load is judged per logical CPU. +var LIMITS = { percent: [85, 95], celsius: [75, 90] }; + +function known(value) { return typeof value === "number" && isFinite(value); } +function percent(value) { return known(value) ? Math.round(value) + "%" : "—"; } +function bytes(value) { + if (!known(value)) return "—"; + var units = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"]; + var index = 0; + while (value >= 1024 && index < units.length - 1) { value /= 1024; index++; } + return value.toFixed(index > 0 && value < 100 ? 1 : 0) + " " + units[index]; +} +function rate(value) { return known(value) ? bytes(value) + "/s" : "—"; } +function compactRate(value) { + if (!known(value)) return "—"; + var units = ["B", "K", "M", "G", "T"]; + var index = 0; + while (value >= 1000 && index < units.length - 1) { value /= 1024; index++; } + return (index > 0 && value < 10 ? value.toFixed(1) : Math.round(value)) + " " + units[index] + "/s"; +} +function ago(ms) { + if (!known(ms) || ms < 0) return ""; + var seconds = Math.floor(ms / 1000); + if (seconds < 5) return "just now"; + if (seconds < 60) return seconds + "s ago"; + var minutes = Math.floor(seconds / 60); + if (minutes < 60) return minutes + " min ago"; + var hours = Math.floor(minutes / 60); + return hours < 24 ? hours + " h ago" : Math.floor(hours / 24) + " d ago"; +} +function uptime(seconds) { + if (!known(seconds)) return "—"; + var hours = Math.floor(seconds / 3600); + return hours >= 24 ? Math.floor(hours / 24) + "d " + hours % 24 + "h" : hours + "h " + Math.floor(seconds / 60) % 60 + "m"; +} +function memoryPercent(sample) { + var m = sample && sample.memory; + return m && m.total > 0 && known(m.used) ? 100 * m.used / m.total : null; +} +function disk(sample, mount) { + return sample ? sample.storage.find(function(d) { return d.mount === mount; }) || null : null; +} +function network(sample, name) { + if (!sample) return null; + // Prefer the default route, avoiding double counting bridges/bonds and + // their members. Fall back to an addressed interface if no route exists. + if (name) return sample.network.find(function(n) { return n.name === name; }) || null; + var primary = sample.network.find(function(n) { return n.name === sample.meta.defaultInterface; }); + if (primary) return primary; + return sample.network.slice().sort(function(a, b) { + var address = Number(b.addresses.length > 0) - Number(a.addresses.length > 0); + return address || (b.rx || 0) + (b.tx || 0) - (a.rx || 0) - (a.tx || 0); + })[0] || null; +} +function temperature(sample) { + var values = sample ? sample.temperatures.map(function(t) { return t.celsius; }).filter(known) : []; + return values.length ? Math.max.apply(null, values) : null; +} +function level(value, kind) { + if (!known(value)) return "unknown"; + var limits = LIMITS[kind] || LIMITS.percent; + return value >= limits[1] ? "critical" : value >= limits[0] ? "warn" : "ok"; +} +// The level of whatever the menubar shows, so the chip can warn in place. +function metricLevel(sample, options) { + if (!sample) return "unknown"; + var storage = disk(sample, options.mount); + switch (options.metric) { + case "load": return sample.meta.cores > 0 ? level(100 * sample.load[0] / sample.meta.cores) : "unknown"; + case "memory": case "memoryUsed": case "memoryFree": return level(memoryPercent(sample)); + case "disk": case "diskFree": return level(storage ? storage.percent : null); + case "temperature": return level(temperature(sample), "celsius"); + case "rx": case "tx": return "ok"; + default: return level(sample.cpu); + } +} +// Open the dashboard on the reading the menubar summarises. +function viewFor(metric) { + switch (metric) { + case "memory": case "memoryUsed": case "memoryFree": return "memory"; + case "disk": case "diskFree": return "storage"; + case "temperature": return "temperature"; + default: return "cpu"; + } +} +function summary(points) { + var values = points.map(function(p) { return p.value; }).filter(known); + if (!values.length) return null; + var total = values.reduce(function(sum, v) { return sum + v; }, 0); + return { average: total / values.length, peak: Math.max.apply(null, values), + low: Math.min.apply(null, values) }; +} +// The window a history chart shows: the whole minutes collected so far, +// between two and ten, so a young history is not a sliver at the right edge. +function chartSpan(elapsed) { + var minutes = Math.ceil((known(elapsed) && elapsed > 0 ? elapsed : 0) / 60000); + return Math.max(2, Math.min(10, minutes)) * 60000; +} +// A rate chart's top edge: the next power of two above the peak, so the +// scale reads in whole binary units and only moves when traffic really does. +function chartCeiling(peak) { + if (!known(peak) || peak <= 1024) return 1024; + return Math.pow(2, Math.ceil(Math.log(peak) / Math.LN2 - 1e-9)); +} +// Firmware variable stores are not storage anyone manages. +var PSEUDO_FILESYSTEMS = ["efivarfs"]; +// One row per filesystem: bind mounts of one device (container config mounts, +// for example) collapse into its shortest mount path unless one of them is +// the selected mount. The selected filesystem leads, then the fullest. +function storageRows(sample, mount) { + if (!sample) return []; + var rows = []; + var byDevice = {}; + sample.storage.forEach(function(d) { + if (PSEUDO_FILESYSTEMS.indexOf(d.type) >= 0 && d.mount !== mount) return; + var key = d.device ? d.type + ":" + d.device + ":" + d.total : null; + var index = key !== null && Object.prototype.hasOwnProperty.call(byDevice, key) ? byDevice[key] : -1; + if (index < 0) { + if (key !== null) byDevice[key] = rows.length; + rows.push(d); + } else if (rows[index].mount !== mount + && (d.mount === mount || d.mount.length < rows[index].mount.length)) { + rows[index] = d; + } + }); + return rows.sort(function(a, b) { + return Number(b.mount === mount) - Number(a.mount === mount) || b.percent - a.percent + || (a.mount < b.mount ? -1 : a.mount > b.mount ? 1 : 0); + }); +} +// Hottest first. Identical chip/label pairs (one per NVMe drive) number their +// chip in reported order, so each row names one sensor: "nvme 2 · Composite". +function sensorRows(sample) { + if (!sample) return []; + var seen = {}; + var totals = {}; + sample.temperatures.forEach(function(t) { totals[t.name] = (totals[t.name] || 0) + 1; }); + return sample.temperatures.map(function(t) { + seen[t.name] = (seen[t.name] || 0) + 1; + var split = t.name.indexOf(" · "); + var name = totals[t.name] < 2 ? t.name : split < 0 ? t.name + " " + seen[t.name] + : t.name.slice(0, split) + " " + seen[t.name] + t.name.slice(split); + return { name: name, celsius: t.celsius }; + }).sort(function(a, b) { return b.celsius - a.celsius; }); +} +// Interfaces worth choosing from: the default route, then addressed ones, then +// the busiest. Unaddressed idle links (veth/tap members) stay at the end. +function interfaceRows(sample) { + if (!sample) return []; + var primary = sample.meta.defaultInterface; + return sample.network.slice().sort(function(a, b) { + return Number(b.name === primary) - Number(a.name === primary) + || Number(b.addresses.length > 0) - Number(a.addresses.length > 0) + || (b.rx || 0) + (b.tx || 0) - (a.rx || 0) - (a.tx || 0) + || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0); + }); +} +function metric(sample, options) { + if (!sample) return "—"; + var storage = disk(sample, options.mount); + var net = network(sample, options.interface); + switch (options.metric) { + case "load": return sample.load[0].toFixed(2); + case "memory": return percent(memoryPercent(sample)); + case "memoryUsed": return bytes(sample.memory.used); + case "memoryFree": return bytes(sample.memory.available); + case "disk": return percent(storage ? storage.percent : null); + case "diskFree": return bytes(storage ? storage.free : null); + case "rx": return "↓ " + rate(net ? net.rx : null); + case "tx": return "↑ " + rate(net ? net.tx : null); + case "temperature": return known(temperature(sample)) ? Math.round(temperature(sample)) + "°C" : "—"; + default: return percent(sample.cpu); + } +} +function validSample(s) { + return !!s && s.version === 1 && typeof s.boot === "string" + && known(s.uptime) && (s.cpu === null || known(s.cpu)) + && s.meta && typeof s.meta.hostname === "string" && typeof s.meta.os === "string" + && Array.isArray(s.load) && s.load.length === 3 && s.load.every(known) + && s.memory && known(s.memory.total) && (s.memory.used === null || known(s.memory.used)) + && (s.memory.available === null || known(s.memory.available)) + && Array.isArray(s.perCore) && s.perCore.every(function(c) { return c === null || known(c); }) + && Array.isArray(s.storage) && s.storage.every(function(d) { + return typeof d.mount === "string" && known(d.total) && known(d.free) && known(d.percent); + }) + && Array.isArray(s.network) && s.network.every(function(n) { + return typeof n.name === "string" && Array.isArray(n.addresses) + && (n.rx === null || known(n.rx)) && (n.tx === null || known(n.tx)); + }) + && Array.isArray(s.temperatures) && s.temperatures.every(function(t) { + return typeof t.name === "string" && known(t.celsius); + }); +} +function historyAppend(history, sample, now) { + var previous = history.length ? history[history.length - 1] : null; + var next = previous && (previous.boot !== sample.boot || now - previous.at > 90000) ? [] : history; + // Bound both time and memory. Each point keeps interface rates separately, + // so changing the selected interface doesn't relabel somebody else's graph. + return next.filter(function(p) { return now - p.at < 600000; }).concat([{ + at: now, boot: sample.boot, cpu: sample.cpu, memory: memoryPercent(sample), + temperature: temperature(sample), network: sample.network + }]).slice(-300); +} +if (typeof module !== "undefined" && module.exports) + module.exports = { METRICS, VIEWS, LIMITS, known, percent, bytes, rate, compactRate, ago, uptime, + memoryPercent, disk, network, temperature, level, metricLevel, viewFor, summary, chartSpan, chartCeiling, + storageRows, sensorRows, interfaceRows, metric, validSample, historyAppend }; diff --git a/roles/desktop/files/quickshell/Common/SettingsHelpers.js b/roles/desktop/files/quickshell/Common/SettingsHelpers.js index 90d7ade9..54017ba6 100644 --- a/roles/desktop/files/quickshell/Common/SettingsHelpers.js +++ b/roles/desktop/files/quickshell/Common/SettingsHelpers.js @@ -39,7 +39,7 @@ var IDLE_SUSPEND_MINS = [0, 15, 30, 60, 120]; // the digitalpals.model-usage Omarchy plugin (see ModelUsage/README.md). var MODULE_IDS = [ "ws", "media", "indicators", "clock", "weather", "notes", "modelusage", "updates", "gh", - "t3", "hermes", "tray", + "t3", "hermes", "remote", "tray", "notifications", "vol", "wifi", "bt", "batt", "control" ]; @@ -50,7 +50,7 @@ var RETIRED_MODULE_IDS = ["bell", "idle", "usage"]; var MODEL_USAGE_PROVIDERS = ["claude", "codex", "kimi"]; var MODEL_USAGE_COST_PROVIDERS = ["claude", "codex"]; -var DETAIL_IDS = ["media", "weather", "clock", "t3", "hermes", "gh", "updates", +var DETAIL_IDS = ["remote", "media", "weather", "clock", "t3", "hermes", "gh", "updates", "notifications", "vol", "batt"]; var DETAIL_POLICIES = ["auto", "prefer", "compact"]; @@ -217,7 +217,7 @@ function defaultMods() { mod("notes", true)], right: [ mod("modelusage", false), mod("updates", true), mod("gh", false), mod("t3", false), - mod("hermes", false), + mod("hermes", false), mod("remote", false), mod("tray", false), mod("notifications", true), mod("vol", true), mod("wifi", true), mod("bt", true), mod("batt", true), mod("control", true) ] @@ -254,6 +254,7 @@ function defaultModOpts() { seconds: false, showDate: true, dateFormat: "ddd dd", showEvents: true, daysAhead: 14, pollMins: 15 }, + remote: { host: "", label: "", metric: "cpu", mount: "/", interface: "", showLabel: true, pollSecs: 5 }, weather: { place: "", lat: 0, lon: 0, pollMins: 20 }, notes: { titleProvider: "off", @@ -359,7 +360,7 @@ function defaults() { glassEnabled: false, highContrast: false, reducedMotion: false, - shellFontSize: 12, + shellFontSize: 14, shellScale: 100, surfaceBorderMode: "accent", surfaceBorderColor: "#9ecbeb", @@ -844,6 +845,15 @@ var MOD_OPT_CHECKS = { return enumIn(v, ["ddd dd", "ddd d MMM", "dd MMM", "dd-MM"], d); } }, + remote: { + host: function(v, d) { return textIn(v, 254, d).trim(); }, + label: function(v, d) { return textIn(v, 40, d).trim(); }, + metric: function(v, d) { return enumIn(v, ["cpu", "load", "memory", "memoryUsed", "memoryFree", "disk", "diskFree", "rx", "tx", "temperature"], d); }, + mount: function(v, d) { return textIn(v, 256, d); }, + interface: function(v, d) { return textIn(v, 64, d).trim(); }, + showLabel: boolIn, + pollSecs: function(v, d) { return intIn(v, 2, 60, 1, d); } + }, weather: { place: function(v, d) { return textIn(v, 40, d); }, lat: function(v, d) { return realIn(v, -90, 90, 0.0001, d); }, @@ -1168,12 +1178,14 @@ function adoptRedesign(parsed) { // Schema 7 makes the softer variable face the shell default. As with the // schema-4 redesign, a stored value equal to the previous default is treated // as untouched; every other valid font remains an explicit user choice. +// A missing font must use today's default, including unversioned installer +// seeds. It is not evidence of a saved schema-6 font preference. function adoptSofterTypography(parsed) { if (!parsed || typeof parsed !== "object" || (typeof parsed.v === "number" && parsed.v >= 7)) return parsed; var next = clone(parsed); - if (next.font === undefined || next.font === "urbanist") + if (next.font === "urbanist") next.font = "google"; return next; } diff --git a/roles/desktop/files/quickshell/Common/TablerGlyphs.js b/roles/desktop/files/quickshell/Common/TablerGlyphs.js index 535c5ef1..a3852cab 100644 --- a/roles/desktop/files/quickshell/Common/TablerGlyphs.js +++ b/roles/desktop/files/quickshell/Common/TablerGlyphs.js @@ -237,6 +237,7 @@ var ALIASES = { "videocam": "video", "view_quilt": "layout", "visibility": "eye", + "visibility_off": "eye-off", "volume_down": "volume-2", "volume_mute": "volume-3", "volume_off": "volume-off", @@ -516,6 +517,9 @@ var GLYPHS = { "eye": { "outline": "\uea9a" }, + "eye-off": { + "outline": "\uecf0" + }, "file-text": { "outline": "\ueaa2" }, diff --git a/roles/desktop/files/quickshell/Common/Typography.js b/roles/desktop/files/quickshell/Common/Typography.js index 3a36e273..6a04dba2 100644 --- a/roles/desktop/files/quickshell/Common/Typography.js +++ b/roles/desktop/files/quickshell/Common/Typography.js @@ -1,10 +1,11 @@ // Shared native/plugin typography. Logical pixels; output scale belongs to Qt. // Reference: Omarchy 961ec7f39fd0d70c7d2944c5b80585a86713693d, -// shell/Commons/Style.qml. Usage rationale: docs/shell-typography.md. +// shell/Commons/Style.qml. CybexOS uses a 14px default with 18px headings. +// Usage rationale: docs/shell-typography.md. var SCALE = { caption: ["caption", 0.833], bodySmall: ["body-small", 0.917], body: ["body", 1], subtitle: ["subtitle", 1.083], - title: ["title", 1.167], heading: ["heading", 1.333], + title: ["title", 1.167], heading: ["heading", 18 / 14], display: ["display", 2], displayLarge: ["display-large", 2.333], iconSmall: ["icon-small", 0.917], icon: ["icon", 1.167], iconLarge: ["icon-large", 1.5], @@ -25,7 +26,7 @@ function pixels(base, multiplier) { function resolve(base, overrides) { base = Number(base); - if (!isFinite(base) || base <= 0) base = 12; + if (!isFinite(base) || base <= 0) base = 14; overrides = overrides || {}; var sizes = {}; Object.keys(SCALE).forEach(function(name) { diff --git a/roles/desktop/files/quickshell/Common/WidgetCatalog.js b/roles/desktop/files/quickshell/Common/WidgetCatalog.js index a1a0edb0..99302835 100644 --- a/roles/desktop/files/quickshell/Common/WidgetCatalog.js +++ b/roles/desktop/files/quickshell/Common/WidgetCatalog.js @@ -21,6 +21,7 @@ var WIDGETS = { indicators: { name: "Indicators", short: "Indicators", tag: "clock-side", glyph: "tune", description: "Quick actions and recording indicators beside the clock." }, clock: { name: "Clock", short: "Clock", detail: true, glyph: "schedule", description: "Time, date, and calendar." }, weather: { name: "Weather", short: "Weather", detail: true, glyph: "cloud", description: "Local conditions and forecast." }, + remote: { name: "Remote Server", short: "Server", detail: true, glyph: "dns", description: "Monitor CPU, memory, storage and network over SSH." }, notes: { name: "Notes", short: "Notes", glyph: "edit_note", description: "Capture and revisit your notes." }, modelusage: { name: "Model Usage", short: "Model Usage", glyph: "monitoring", description: "Claude, Codex and Kimi quota limits, resets and API cost estimates." }, t3: { name: "T3 Code", short: "T3 Code", detail: true, glyph: "code", description: "Follow T3 Code sessions." }, diff --git a/roles/desktop/files/quickshell/Common/qmldir b/roles/desktop/files/quickshell/Common/qmldir index be9f5a43..58a0aa22 100644 --- a/roles/desktop/files/quickshell/Common/qmldir +++ b/roles/desktop/files/quickshell/Common/qmldir @@ -96,3 +96,4 @@ OmarchyServiceApi OmarchyServiceApi.qml singleton SystemSettings SystemSettings.qml singleton DisplaySettings DisplaySettings.qml SystemSettingsBackend SystemSettingsBackend.qml +singleton RemoteServer RemoteServer.qml diff --git a/roles/desktop/files/quickshell/Commons/Style.qml b/roles/desktop/files/quickshell/Commons/Style.qml index f28664b6..d215d88a 100644 --- a/roles/desktop/files/quickshell/Commons/Style.qml +++ b/roles/desktop/files/quickshell/Commons/Style.qml @@ -25,7 +25,7 @@ import "../Common/Typography.js" as Typography // padding, controls, and panel dimensions while preserving each component's // proportions; by default it also tracks `base-size`. `[bar] // size-horizontal` / `size-vertical` set the cross-axis dimension for -// top/bottom and left/right bars at the default 12px font size; by default +// top/bottom and left/right bars at the 12px reference font size; by default // those dimensions scale with `base-size` so larger fonts don't clip. QtObject { id: root @@ -283,7 +283,7 @@ QtObject { // The only sanity floor is 1px. Themes and users can make this as large // as they like; if the shell gets ridiculous, that's their call. - property int fontBaseSize: 12 + property int fontBaseSize: 14 property var fontOverrides: ({}) property var barOverrides: ({}) @@ -373,7 +373,7 @@ QtObject { var barOut = {} var styleOut = {} var spacingOut = {} - var nextBase = 12 + var nextBase = 14 var nextSpacingScale = 1.0 var nextSpacingScaleWithFont = true var nextBarScaleWithFont = true diff --git a/roles/desktop/files/quickshell/NetworkOverlayWindow.qml b/roles/desktop/files/quickshell/NetworkOverlayWindow.qml index 5af63259..2ea274ab 100644 --- a/roles/desktop/files/quickshell/NetworkOverlayWindow.qml +++ b/roles/desktop/files/quickshell/NetworkOverlayWindow.qml @@ -21,7 +21,8 @@ PanelWindow { readonly property string speedHelper: Quickshell.shellDir + "/scripts/network-speedtest.py" readonly property bool qrPageActive: NetworkOverlayState.page === "qr" - visible: NetworkOverlayState.open || scrim.opacity > 0.001 + visible: !NetworkOverlayState.authenticationActive + && (NetworkOverlayState.open || scrim.opacity > 0.001) screen: NetworkOverlayState.screen ?? Screens.focused anchors { top: true; left: true; right: true; bottom: true } exclusionMode: ExclusionMode.Ignore @@ -29,13 +30,13 @@ PanelWindow { WlrLayershell.layer: WlrLayer.Overlay WlrLayershell.namespace: "qs-network-overlay" - WlrLayershell.keyboardFocus: NetworkOverlayState.open + WlrLayershell.keyboardFocus: NetworkOverlayState.open && !NetworkOverlayState.authenticationActive ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None HyprlandFocusGrab { - active: NetworkOverlayState.open + active: NetworkOverlayState.open && !NetworkOverlayState.authenticationActive windows: [root] - onCleared: NetworkOverlayState.close() + onCleared: if (!NetworkOverlayState.authenticationActive) NetworkOverlayState.close() } Rectangle { @@ -57,7 +58,7 @@ PanelWindow { FocusScope { anchors.fill: parent - focus: NetworkOverlayState.open + focus: NetworkOverlayState.open && !NetworkOverlayState.authenticationActive Keys.onEscapePressed: NetworkOverlayState.close() Rectangle { diff --git a/roles/desktop/files/quickshell/PolkitPrompt.qml b/roles/desktop/files/quickshell/PolkitPrompt.qml new file mode 100644 index 00000000..c0d0e02e --- /dev/null +++ b/roles/desktop/files/quickshell/PolkitPrompt.qml @@ -0,0 +1,314 @@ +pragma ComponentBehavior: Bound +import QtQuick +import "Common" +import "Common/PolkitHelpers.js" as PolkitHelpers +import "Settings" as SettingsUi + +// Only the active conversation owns an input. The window destroys this view +// when it closes; responses go directly to Polkit, never through IPC or a +// command line. Keeping the view separate also permits real-engine fixtures. +FocusScope { + id: root + + required property var flow + property bool detailsOpen: false + property bool revealed: false + property bool attemptFailed: false + property string feedback: "" + property bool feedbackError: false + readonly property bool finished: !flow || flow.isCompleted || flow.isCancelled + readonly property bool responseRequired: !finished && flow.isResponseRequired + readonly property bool secret: !flow || !flow.responseVisible + readonly property string prompt: flow && flow.inputPrompt + ? flow.inputPrompt.trim() : secret ? "Password" : "Response" + readonly property string account: PolkitHelpers.identityLabel(flow ? flow.selectedIdentity : null) + readonly property bool hasError: attemptFailed || feedbackError + + implicitHeight: content.implicitHeight + focus: true + Accessible.role: Accessible.Dialog + Accessible.name: "Authentication required" + + function clearResponse() { + response.clear(); + revealed = false; + } + + function focusResponse() { + if (responseRequired) + response.forceActiveFocus(); + else + cancelButton.forceActiveFocus(); + } + + function submit() { + // Empty responses are valid for some PAM conversations. Polkit, not + // presentation code, decides whether a response is acceptable. + if (!responseRequired) + return; + feedback = ""; + feedbackError = false; + attemptFailed = false; + flow.submit(response.text); + clearResponse(); + } + + function cancel() { + clearResponse(); + if (!finished) + flow.cancelAuthenticationRequest(); + } + + onFlowChanged: { + clearResponse(); + detailsOpen = false; + attemptFailed = false; + feedback = flow ? flow.supplementaryMessage : ""; + feedbackError = !!(flow && flow.supplementaryIsError); + Qt.callLater(focusResponse); + } + onResponseRequiredChanged: { + clearResponse(); + Qt.callLater(focusResponse); + } + onFinishedChanged: if (finished) clearResponse() + Component.onCompleted: Qt.callLater(focusResponse) + Component.onDestruction: clearResponse() + Keys.onEscapePressed: cancel() + + Connections { + target: root.flow + function onAuthenticationFailed() { + root.attemptFailed = true; + root.clearResponse(); + Qt.callLater(root.focusResponse); + } + function onSelectedIdentityChanged() { + root.clearResponse(); + root.attemptFailed = false; + root.feedback = ""; + root.feedbackError = false; + Qt.callLater(root.focusResponse); + } + function onInputPromptChanged() { root.clearResponse(); } + function onResponseVisibleChanged() { root.clearResponse(); } + function onSupplementaryMessageChanged() { + root.feedback = root.flow.supplementaryMessage; + root.feedbackError = root.flow.supplementaryIsError; + if (!root.feedbackError) + root.attemptFailed = false; + } + function onSupplementaryIsErrorChanged() { + root.feedbackError = root.flow.supplementaryIsError; + } + } + + Column { + id: content + width: root.width + spacing: Theme.scaled(18) + + Row { + width: parent.width + spacing: Theme.scaled(12) + + Rectangle { + width: Theme.scaled(40) + height: width + radius: Theme.chipRadius + 3 + color: Theme.chip + Sym { + anchors.centerIn: parent + name: "lock" + size: Theme.iconLarge + color: Theme.accentText + } + } + + Column { + anchors.verticalCenter: parent.verticalCenter + width: parent.width - Theme.scaled(52) + spacing: 3 + Text { + width: parent.width + text: "Authentication required" + textFormat: Text.PlainText + font.family: Theme.fontMenu + font.pixelSize: Theme.typography.heading + font.weight: Theme.weightSemibold + color: Theme.textHi + wrapMode: Text.Wrap + } + Text { + width: parent.width + text: root.account + textFormat: Text.PlainText + font.family: Theme.fontMenu + font.pixelSize: Theme.typography.secondary + color: Theme.textMid + wrapMode: Text.Wrap + } + } + } + + Text { + width: parent.width + text: root.flow && root.flow.message ? root.flow.message : "Authenticate to continue." + textFormat: Text.PlainText + font.family: Theme.fontMenu + font.pixelSize: Theme.typography.primary + color: Theme.textHi + wrapMode: Text.Wrap + } + + SettingsUi.SettingsSelect { + id: accountSelect + objectName: "polkitAccount" + visible: model.length > 1 + width: parent.width + maximumWidth: width + accessibleName: "Authenticate as" + model: PolkitHelpers.identityOptions(root.flow ? root.flow.identities : []) + current: root.flow ? root.flow.identities.indexOf(root.flow.selectedIdentity) : -1 + enabled: !root.finished + KeyNavigation.priority: KeyNavigation.BeforeItem + KeyNavigation.tab: root.responseRequired ? response : cancelButton + KeyNavigation.backtab: detailsButton + onPicked: value => { + root.clearResponse(); + root.flow.selectedIdentity = root.flow.identities[value]; + } + } + + Column { + visible: root.responseRequired + width: parent.width + spacing: Theme.scaled(7) + + Text { + width: parent.width + text: root.prompt + textFormat: Text.PlainText + font.family: Theme.fontMenu + font.pixelSize: Theme.typography.secondary + color: Theme.textMid + wrapMode: Text.Wrap + } + + SettingsUi.SettingsField { + id: response + objectName: "polkitResponse" + width: parent.width + implicitHeight: Theme.scaled(42) + enabled: root.responseRequired + invalid: root.hasError + rightPadding: root.secret ? revealButton.width + Theme.controlSpacing * 2 : Theme.controlSpacing + echoMode: root.secret && !root.revealed ? TextInput.Password : TextInput.Normal + passwordMaskDelay: 0 + // Revealing the password must not enable IME learning/prediction. + inputMethodHints: root.secret + ? Qt.ImhHiddenText | Qt.ImhSensitiveData | Qt.ImhNoPredictiveText | Qt.ImhNoAutoUppercase + : Qt.ImhNoPredictiveText | Qt.ImhNoAutoUppercase + Accessible.name: root.prompt + KeyNavigation.priority: KeyNavigation.BeforeItem + KeyNavigation.tab: root.secret ? revealButton : authenticateButton + KeyNavigation.backtab: accountSelect.visible ? accountSelect : detailsButton + onAccepted: root.submit() + + SettingsUi.SettingsAction { + id: revealButton + objectName: "polkitReveal" + anchors.right: parent.right + anchors.rightMargin: Theme.controlSpacing + anchors.verticalCenter: parent.verticalCenter + visible: root.secret + compact: true + text: root.revealed ? "Hide password" : "Show password" + glyph: root.revealed ? "visibility_off" : "visibility" + KeyNavigation.priority: KeyNavigation.BeforeItem + KeyNavigation.tab: authenticateButton + KeyNavigation.backtab: response + onTriggered: { + root.revealed = !root.revealed; + response.forceActiveFocus(); + } + } + } + } + + Text { + objectName: "polkitStatus" + width: parent.width + visible: text !== "" + text: root.attemptFailed && !root.feedbackError ? "Authentication failed. Please try again." + : root.feedback ? root.feedback + : root.attemptFailed ? "Authentication failed. Please try again." + : !root.responseRequired && !root.finished ? "Waiting for authentication…" : "" + textFormat: Text.PlainText + font.family: Theme.fontMenu + font.pixelSize: Theme.typography.secondary + color: root.hasError ? Theme.redText : Theme.textMid + wrapMode: Text.Wrap + } + + Flow { + width: parent.width + spacing: Theme.controlSpacing + layoutDirection: Qt.RightToLeft + + SettingsUi.SettingsAction { + id: authenticateButton + objectName: "polkitSubmit" + text: root.responseRequired ? "Authenticate" : "Authenticating…" + height: Theme.scaled(36) + primary: true + enabled: root.responseRequired + KeyNavigation.priority: KeyNavigation.BeforeItem + KeyNavigation.tab: cancelButton + KeyNavigation.backtab: root.secret ? revealButton : response + onTriggered: root.submit() + } + + SettingsUi.SettingsAction { + id: cancelButton + objectName: "polkitCancel" + text: "Cancel" + height: Theme.scaled(36) + enabled: !root.finished + KeyNavigation.priority: KeyNavigation.BeforeItem + KeyNavigation.tab: detailsButton + KeyNavigation.backtab: root.responseRequired ? authenticateButton + : accountSelect.visible ? accountSelect : detailsButton + onTriggered: root.cancel() + } + } + + Column { + width: parent.width + spacing: Theme.controlSpacing + + SettingsUi.SettingsAction { + id: detailsButton + objectName: "polkitDetails" + text: root.detailsOpen ? "Hide details" : "Show details" + glyph: root.detailsOpen ? "expand_less" : "expand_more" + KeyNavigation.priority: KeyNavigation.BeforeItem + KeyNavigation.tab: accountSelect.visible ? accountSelect + : root.responseRequired ? response : cancelButton + KeyNavigation.backtab: cancelButton + onTriggered: root.detailsOpen = !root.detailsOpen + } + + Text { + visible: root.detailsOpen + width: parent.width + text: "Action: " + (root.flow ? root.flow.actionId : "") + textFormat: Text.PlainText + font.family: Theme.fontMenu + font.pixelSize: Theme.typography.secondary + color: Theme.textMid + wrapMode: Text.WrapAnywhere + } + } + } +} diff --git a/roles/desktop/files/quickshell/PolkitWindow.qml b/roles/desktop/files/quickshell/PolkitWindow.qml new file mode 100644 index 00000000..97427de2 --- /dev/null +++ b/roles/desktop/files/quickshell/PolkitWindow.qml @@ -0,0 +1,106 @@ +pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Controls as Controls +import Quickshell +import Quickshell.Io +import Quickshell.Services.Polkit +import Quickshell.Wayland +import "Common" + +PanelWindow { + id: root + + property string hostScreenName: "" + readonly property bool active: agent.flow !== null + && !agent.flow.isCompleted && !agent.flow.isCancelled + onActiveChanged: NetworkOverlayState.authenticationActive = active + + visible: active + screen: Screens.byName(hostScreenName) ?? Screens.focused + anchors { top: true; left: true; right: true; bottom: true } + exclusionMode: ExclusionMode.Ignore + color: "transparent" + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.namespace: "qs-polkit" + WlrLayershell.keyboardFocus: active ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None + + function releaseOverlays() { + if (!active) + return; + Launcher.close(); + Popouts.close(); + Session.closeKeys(); + } + + PolkitAgent { + id: agent + onFlowChanged: { + if (!root.active) + return; + root.hostScreenName = Screens.focused ? Screens.focused.name : ""; + // Release other exclusive shell surfaces before taking the keyboard. + root.releaseOverlays(); + } + } + + // A global shortcut must not put another exclusive surface in front of + // the prompt while the user is entering a response. + Connections { + target: Launcher + function onOpenChanged() { if (Launcher.open) root.releaseOverlays(); } + } + Connections { + target: Popouts + function onOpenChanged() { if (Popouts.open) root.releaseOverlays(); } + } + Connections { + target: Session + function onKeysOpenChanged() { if (Session.keysOpen) root.releaseOverlays(); } + } + + // Health checks can inspect registration without exposing identities, + // actions, cookies or responses. There is deliberately no submit IPC. + IpcHandler { + target: "polkit" + function status(): string { + return JSON.stringify({registered: agent.isRegistered, active: root.active}); + } + } + + Rectangle { + anchors.fill: parent + color: Theme.scrim + // An accidental click outside the card must not cancel an app's request. + MouseArea { anchors.fill: parent } + } + + Rectangle { + anchors.centerIn: parent + width: Math.max(1, Math.min(Theme.scaled(440, Theme.typeScale), root.width - 32)) + height: Math.min(root.height - 32, promptLoader.implicitHeight + Theme.scaled(48)) + radius: Theme.popRadius + color: Theme.background + border.width: 1 + border.color: Theme.stroke + + Controls.ScrollView { + anchors.fill: parent + anchors.margins: Theme.scaled(24) + contentWidth: availableWidth + contentHeight: promptLoader.implicitHeight + clip: true + Controls.ScrollBar.horizontal.policy: Controls.ScrollBar.AlwaysOff + + Loader { + id: promptLoader + width: parent.width + active: root.active + focus: true + sourceComponent: PolkitPrompt { + flow: agent.flow + width: promptLoader.width + } + } + } + } +} diff --git a/roles/desktop/files/quickshell/Popovers/Drawer/DrawerOverview.qml b/roles/desktop/files/quickshell/Popovers/Drawer/DrawerOverview.qml index 50fc5f0d..33c7bcef 100644 --- a/roles/desktop/files/quickshell/Popovers/Drawer/DrawerOverview.qml +++ b/roles/desktop/files/quickshell/Popovers/Drawer/DrawerOverview.qml @@ -35,7 +35,7 @@ Column { Rectangle { visible: Media.hasTrack && Settings.drawerOverview.media === true width: parent.width - height: 64 + height: Math.max(Theme.scaled(64), mediaText.implicitHeight + Theme.scaled(20)) radius: 10 color: Theme.chip @@ -73,6 +73,7 @@ Column { } Column { + id: mediaText anchors.left: art.right anchors.leftMargin: 12 anchors.right: transport.left diff --git a/roles/desktop/files/quickshell/Popovers/RemoteServerPopover.qml b/roles/desktop/files/quickshell/Popovers/RemoteServerPopover.qml new file mode 100644 index 00000000..653688fb --- /dev/null +++ b/roles/desktop/files/quickshell/Popovers/RemoteServerPopover.qml @@ -0,0 +1,1236 @@ +pragma ComponentBehavior: Bound +import QtQuick +import "../Common" +import "../Ui" as Ui +import "../Common/Format.js" as Format +import "../Common/RemoteServerHelpers.js" as Helpers + +// The server dashboard. A header says which machine and whether it is live; +// four reading tiles give the state at a glance and double as the tabs for +// the one detailed view beneath them; the network stays in view below that; +// and a one-line footer carries freshness and the two actions. The common +// case fits without scrolling. Expanded lists scroll inside the body while +// the header and footer stay put. +PopoutPanel { + id: root + + readonly property var stats: RemoteServer.sample + readonly property var net: RemoteServer.selectedNetwork + readonly property var disk: Helpers.disk(stats, RemoteServer.options.mount) + readonly property var hottest: Helpers.temperature(stats) + readonly property bool configured: RemoteServer.host !== "" + readonly property bool dimmed: RemoteServer.stale || RemoteServer.error !== "" + readonly property int gutter: Theme.panelPadding + readonly property int gap: Theme.scaled(12) + readonly property int preferredWidth: Theme.scaled(472) + + // An explicit pick wins for as long as the panel exists; otherwise the + // dashboard opens on whatever the menubar is summarising. + property string picked: "" + readonly property string view: picked || Helpers.viewFor(RemoteServer.options.metric) + property bool allDisks: false + property bool allSensors: false + property bool pickingInterface: false + property bool allInterfaces: false + + readonly property double span: Helpers.chartSpan(RemoteServer.history.length + ? RemoteServer.now - RemoteServer.history[0].at : 0) + readonly property string spanLabel: "last " + Math.round(span / 60000) + " min" + readonly property var cpuPoints: RemoteServer.history.map(p => ({ at: p.at, value: p.cpu })) + readonly property var memoryPoints: RemoteServer.history.map(p => ({ at: p.at, value: p.memory })) + readonly property var temperaturePoints: RemoteServer.history.map( + p => ({ at: p.at, value: p.temperature ?? null })) + function ratePoints(key) { + const name = root.net ? root.net.name : ""; + return RemoteServer.history.map(p => { + const n = name ? p.network.find(v => v.name === name) : null; + return { at: p.at, value: n ? n[key] : null }; + }); + } + readonly property var rxPoints: ratePoints("rx") + readonly property var txPoints: ratePoints("tx") + + implicitWidth: Math.min(preferredWidth, availableWidth > 0 ? availableWidth : preferredWidth) + implicitHeight: Math.min(gutter * 2 + header.height + gap + body.implicitHeight + gap + footer.height, + availableHeight > 0 ? availableHeight : 860) + + Claim { + active: root.visible + onClaimed: RemoteServer.acquire() + onReleased: RemoteServer.release() + } + + function tone(level) { + return level === "critical" ? Theme.redText : level === "warn" ? Theme.amber : Theme.textHi; + } + function meterTone(level) { + return level === "critical" ? Theme.red : level === "warn" ? Theme.amber : Theme.accent; + } + function reading(view) { + const s = root.stats; + let value = null; + let unit = "%"; + let kind = "percent"; + if (view === "memory") + value = Helpers.memoryPercent(s); + else if (view === "storage") + value = root.disk ? root.disk.percent : null; + else if (view === "temperature") { + value = root.hottest; + unit = "°C"; + kind = "celsius"; + } else + value = s ? s.cpu : null; + const known = Helpers.known(value); + return { number: known ? String(Math.round(value)) : "—", unit: known ? unit : "", + fraction: known ? Format.clamp01(value / 100) : 0, level: Helpers.level(value, kind), + text: known ? Math.round(value) + unit : "unavailable" }; + } + function spread(points, unit) { + const s = Helpers.summary(points); + return s ? "avg " + Math.round(s.average) + unit + " · peak " + Math.round(s.peak) + unit : ""; + } + + Rectangle { + anchors.fill: parent + visible: root.drawBackground + radius: Theme.panelRadius + color: root.surfaceColor + border.width: Theme.surfaceBorderWidth + border.color: root.surfaceBorderColor + } + + component Caption: Text { + textFormat: Text.PlainText + elide: Text.ElideRight + font.family: Theme.fontMenu + font.pixelSize: Theme.typography.metadata + color: Theme.textDim + } + component Figure: Text { + textFormat: Text.PlainText + font.family: Theme.fontNumeric + font.pixelSize: Theme.typography.secondary + font.weight: Theme.weightSemibold + font.features: Theme.tabularNumberFeatures + color: Theme.textHi + } + + // A reading, and the tab for its detail. + component ReadingTile: Rectangle { + id: tile + + required property var modelData + readonly property string view: modelData.value + readonly property var reading: root.reading(view) + readonly property bool selected: root.view === view + + function pick() { + root.picked = tile.view; + } + + height: Theme.scaled(66) + radius: Theme.chipRadius + color: tile.selected || tileMouse.containsMouse ? Theme.chipHover : Theme.chip + border.width: tile.selected || tile.activeFocus ? 1 : 0 + border.color: Qt.alpha(Theme.accentText, tile.activeFocus ? 1 : 0.5) + activeFocusOnTab: true + Accessible.role: Accessible.PageTab + Accessible.name: tile.modelData.label + Accessible.description: tile.reading.text + (tile.selected ? ", shown below" : "") + Accessible.onPressAction: tile.pick() + Keys.onPressed: event => { + if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter + || event.key === Qt.Key_Space) { + tile.pick(); + event.accepted = true; + } + } + + Behavior on color { + ColorAnimation { duration: Theme.chipFadeDuration } + } + + Text { + id: tileLabel + x: Theme.scaled(10) + y: Theme.scaled(9) + width: parent.width - x * 2 + elide: Text.ElideRight + text: tile.modelData.label.toUpperCase() + font.family: Theme.fontMenu + font.pixelSize: Theme.typography.section + font.weight: Theme.weightSemibold + font.letterSpacing: 1 + color: tile.selected ? Theme.accentText : Theme.textFaint + } + Text { + id: tileNumber + anchors.left: tileLabel.left + anchors.top: tileLabel.bottom + anchors.topMargin: Theme.scaled(2) + text: tile.reading.number + font.family: Theme.fontNumeric + font.pixelSize: Theme.typography.title + font.weight: Theme.weightSemibold + font.features: Theme.tabularNumberFeatures + color: root.tone(tile.reading.level) + } + Text { + anchors.left: tileNumber.right + anchors.leftMargin: 1 + anchors.baseline: tileNumber.baseline + text: tile.reading.unit + font.family: Theme.fontNumeric + font.pixelSize: Theme.typography.metadata + font.weight: Theme.weightMedium + color: Theme.textLow + } + BlockMeter { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.margins: Theme.scaled(10) + height: 5 + blockWidth: 3 + gap: 2 + value: tile.reading.fraction + fillColor: root.meterTone(tile.reading.level) + } + MouseArea { + id: tileMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: tile.pick() + } + } + + // A run of labelled figures on one recessed strip. + component FactStrip: Rectangle { + id: strip + + property var facts: [] + + width: parent ? parent.width : 0 + height: Theme.scaled(46) + radius: Theme.chipRadius + color: Theme.chip + + Repeater { + model: strip.facts + + Item { + id: fact + + required property var modelData + required property int index + readonly property real cell: strip.width / Math.max(1, strip.facts.length) + + x: fact.index * fact.cell + width: fact.cell + height: strip.height + Accessible.role: Accessible.StaticText + Accessible.name: fact.modelData.label + Accessible.description: fact.modelData.value + + (fact.modelData.detail ? ", " + fact.modelData.detail : "") + + Rectangle { + visible: fact.index > 0 + anchors.verticalCenter: parent.verticalCenter + width: 1 + height: parent.height - Theme.scaled(18) + color: Theme.hairlineSoft + } + Caption { + id: factLabel + x: Theme.scaled(10) + y: Theme.scaled(7) + width: parent.width - x * 2 + text: fact.modelData.label + } + Figure { + anchors.left: factLabel.left + anchors.top: factLabel.bottom + anchors.topMargin: 1 + width: factLabel.width + elide: Text.ElideRight + text: fact.modelData.value + color: root.tone(fact.modelData.level || "ok") + } + } + } + } + + // A chart with its title and summary on one line above it. + component History: Column { + id: history + + property string title: "" + property string summary: "" + property var series: [] + property real ceiling: 100 + property string ceilingLabel: "" + property real chartHeight: Theme.scaled(84) + + width: parent ? parent.width : 0 + spacing: Theme.scaled(6) + + Item { + width: parent.width + height: historyTitle.implicitHeight + + Text { + id: historyTitle + width: Math.min(implicitWidth, parent.width - historySummary.implicitWidth + - historySpan.implicitWidth - 20) + elide: Text.ElideRight + text: history.title + font.family: Theme.fontMenu + font.pixelSize: Theme.typography.secondary + font.weight: Theme.weightSemibold + color: Theme.textMid + } + Caption { + id: historySpan + anchors.left: historyTitle.right + anchors.leftMargin: Theme.scaled(8) + anchors.baseline: historyTitle.baseline + text: root.spanLabel + color: Theme.textFaint + } + Caption { + id: historySummary + anchors.right: parent.right + anchors.baseline: historyTitle.baseline + text: history.summary + font.features: Theme.tabularNumberFeatures + } + } + Item { + width: parent.width + height: history.chartHeight + + Ui.TelemetryChart { + anchors.fill: parent + series: history.series + ceiling: history.ceiling + span: root.span + now: RemoteServer.now + description: history.title + ", " + root.spanLabel + } + Caption { + visible: history.ceilingLabel !== "" + x: 4 + y: 3 + text: history.ceilingLabel + color: Theme.textFaint + } + Caption { + visible: !history.series.some(s => s.points.some(p => Helpers.known(p.value))) + anchors.centerIn: parent + text: RemoteServer.connection === "connecting" ? "Collecting readings…" : "No history yet" + color: Theme.textFaint + } + } + } + + // A pressable row inside an expanded list. + component ListRow: Rectangle { + id: row + + property string accessibleName: "" + signal activated() + + width: parent ? parent.width : 0 + radius: Theme.chipRadius + color: rowMouse.containsMouse || row.activeFocus ? Theme.hoverFill : "transparent" + border.width: row.activeFocus ? 1 : 0 + border.color: Theme.accentText + activeFocusOnTab: true + Accessible.role: Accessible.Button + Accessible.name: row.accessibleName + Accessible.onPressAction: row.activated() + Keys.onPressed: event => { + if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter + || event.key === Qt.Key_Space) { + row.activated(); + event.accepted = true; + } + } + + MouseArea { + id: rowMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: row.activated() + } + } + + // ---- header ---------------------------------------------------------- + Item { + id: header + + x: root.gutter + y: root.gutter + width: root.width - root.gutter * 2 + height: Theme.scaled(42) + + Rectangle { + id: mark + anchors.verticalCenter: parent.verticalCenter + width: Theme.scaled(38) + height: width + radius: Theme.chipRadius + color: Theme.chip + + Sym { + anchors.centerIn: parent + name: "dns" + size: Theme.iconLarge + color: root.configured ? Theme.accentText : Theme.textDim + } + } + Column { + anchors.left: mark.right + anchors.leftMargin: Theme.scaled(12) + anchors.right: pill.left + anchors.rightMargin: Theme.scaled(10) + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.scaled(2) + + Text { + width: parent.width + elide: Text.ElideRight + textFormat: Text.PlainText + text: root.configured ? RemoteServer.label : "Remote Server" + font.family: Theme.fontMenu + font.pixelSize: Theme.typography.title + font.weight: Theme.weightSemibold + color: Theme.textHi + } + Text { + width: parent.width + elide: Text.ElideRight + textFormat: Text.PlainText + text: { + if (!root.configured) + return "Monitor a Linux machine over SSH"; + const who = RemoteServer.label !== RemoteServer.host ? RemoteServer.host + : root.stats ? root.stats.meta.hostname : ""; + return [who, root.stats ? "up " + Helpers.uptime(root.stats.uptime) : ""] + .filter(part => part !== "").join(" · "); + } + font.family: Theme.fontMenu + font.pixelSize: Theme.typography.secondary + color: Theme.textLow + } + } + Rectangle { + id: pill + + readonly property string connection: RemoteServer.connection + readonly property color tint: connection === "live" ? Theme.ok + : connection === "offline" ? Theme.redText + : connection === "stale" ? Theme.amber : Theme.textDim + + visible: root.configured + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + width: pillRow.implicitWidth + Theme.scaled(18) + height: Theme.scaled(24) + radius: height / 2 + color: connection === "live" ? Theme.okBgSoft + : connection === "offline" ? Theme.redBgSoft + : connection === "stale" ? Theme.amberBgSoft : Theme.chip + Accessible.role: Accessible.StaticText + Accessible.name: "Connection" + Accessible.description: RemoteServer.status + + Row { + id: pillRow + anchors.centerIn: parent + spacing: Theme.scaled(6) + + Rectangle { + id: pillDot + anchors.verticalCenter: parent.verticalCenter + width: 6 + height: 6 + radius: 3 + color: pill.tint + + SequentialAnimation on opacity { + running: pill.connection === "connecting" && !Theme.reducedMotion + loops: Animation.Infinite + onRunningChanged: if (!running) pillDot.opacity = 1 + NumberAnimation { from: 1; to: 0.3; duration: 700; easing.type: Easing.InOutSine } + NumberAnimation { from: 0.3; to: 1; duration: 700; easing.type: Easing.InOutSine } + } + } + Text { + anchors.verticalCenter: parent.verticalCenter + text: ({ live: "Live", offline: "Offline", stale: "Stale", + connecting: "Connecting" })[pill.connection] || "" + font.family: Theme.fontMenu + font.pixelSize: Theme.typography.metadata + font.weight: Theme.weightSemibold + color: pill.tint + } + } + } + } + + // ---- body ------------------------------------------------------------ + Flickable { + id: scroll + + x: root.gutter + y: header.y + header.height + root.gap + width: header.width + height: footer.y - root.gap - y + contentWidth: width + contentHeight: body.implicitHeight + boundsBehavior: Flickable.StopAtBounds + clip: true + + Column { + id: body + + width: scroll.width + spacing: Theme.panelSectionSpacing + + // Nothing to monitor yet. + Column { + visible: !root.configured + width: parent.width + topPadding: Theme.scaled(10) + bottomPadding: Theme.scaled(6) + spacing: Theme.scaled(10) + + Text { + width: parent.width + wrapMode: Text.Wrap + horizontalAlignment: Text.AlignHCenter + textFormat: Text.PlainText + text: "Choose a server you can already reach with an SSH key. Nothing is installed or written on it; it needs Linux and Python 3.9 or newer." + lineHeight: Theme.proseLineHeight + font.family: Theme.fontMenu + font.pixelSize: Theme.typography.secondary + color: Theme.textLow + } + ActionButton { + anchors.horizontalCenter: parent.horizontalCenter + label: "Choose SSH host" + tint: Theme.accentText + onTriggered: RemoteServer.configure() + } + } + + // Why the readings are missing or old. Offline is red wherever it + // shows — here, the status pill and the menubar badge. + Rectangle { + visible: RemoteServer.error !== "" + width: parent.width + height: problem.implicitHeight + Theme.scaled(22) + radius: Theme.chipRadius + color: Theme.redBgSoft + border.width: 1 + border.color: Theme.redBorder + + Sym { + x: Theme.scaled(12) + y: Theme.scaled(12) + name: "error" + size: Theme.iconMedium + color: Theme.redText + } + Column { + id: problem + x: Theme.scaled(38) + y: Theme.scaled(11) + width: parent.width - x - retry.width - Theme.scaled(22) + spacing: Theme.scaled(3) + + Text { + width: parent.width + wrapMode: Text.Wrap + textFormat: Text.PlainText + text: root.stats ? "Connection lost · showing the last readings" + : "Can't reach " + RemoteServer.label + font.family: Theme.fontMenu + font.pixelSize: Theme.typography.secondary + font.weight: Theme.weightSemibold + color: Theme.redText + } + Text { + width: parent.width + wrapMode: Text.Wrap + textFormat: Text.PlainText + text: RemoteServer.error + font.family: Theme.fontMenu + font.pixelSize: Theme.typography.metadata + color: Theme.textMid + } + } + ActionButton { + id: retry + anchors.right: parent.right + anchors.rightMargin: Theme.scaled(10) + anchors.verticalCenter: parent.verticalCenter + label: "Retry" + tint: Theme.redText + onTriggered: RemoteServer.refresh() + } + } + + Rectangle { + visible: RemoteServer.connection === "connecting" + width: parent.width + height: Theme.scaled(64) + radius: Theme.chipRadius + color: Theme.chip + + Caption { + anchors.centerIn: parent + width: Math.min(implicitWidth, parent.width - Theme.scaled(24)) + text: "Connecting securely and collecting the first readings…" + color: Theme.textLow + } + } + + Column { + visible: root.stats !== null + width: parent.width + spacing: Theme.panelSectionSpacing + opacity: root.dimmed ? 0.6 : 1 + + Behavior on opacity { + NumberAnimation { duration: Theme.chipFadeDuration } + } + + Row { + id: tiles + width: parent.width + spacing: Theme.scaled(8) + + Repeater { + model: Helpers.VIEWS + ReadingTile { + width: (tiles.width - tiles.spacing * (Helpers.VIEWS.length - 1)) + / Helpers.VIEWS.length + } + } + } + + // ---- CPU ---- + Column { + visible: root.view === "cpu" + width: parent.width + spacing: Theme.scaled(10) + + History { + title: "CPU usage" + summary: root.spread(root.cpuPoints, "%") + series: [{ points: root.cpuPoints, tint: Theme.accentText }] + } + // Every logical CPU as one bar, wrapping only on very + // large machines, so 64 threads read as one texture. + Item { + id: cores + + readonly property var values: root.stats ? root.stats.perCore : [] + readonly property int count: values.length + readonly property real pitchGap: count > 96 ? 1 : 2 + readonly property int perRow: Math.max(1, Math.min(count, + Math.floor((width + pitchGap) / (3 + pitchGap)))) + readonly property int rows: Math.ceil(count / perRow) + readonly property real cell: (width - pitchGap * (perRow - 1)) / perRow + readonly property real rowHeight: rows > 1 ? Theme.scaled(14) : Theme.scaled(22) + property int hovered: -1 + + visible: count > 0 + width: parent.width + height: count > 0 ? rows * rowHeight + (rows - 1) * 3 : 0 + Accessible.role: Accessible.Chart + Accessible.name: "Per-CPU activity" + Accessible.description: count + " logical CPUs" + + Repeater { + model: cores.values + + Rectangle { + id: core + + required property var modelData + required property int index + readonly property string level: Helpers.level(core.modelData) + + x: (core.index % cores.perRow) * (cores.cell + cores.pitchGap) + y: Math.floor(core.index / cores.perRow) * (cores.rowHeight + 3) + width: cores.cell + height: cores.rowHeight + radius: Math.min(1.5, width / 2) + color: cores.hovered === core.index ? Theme.chip : Theme.hairlineSoft + + Rectangle { + anchors.bottom: parent.bottom + width: parent.width + height: Helpers.known(core.modelData) + ? Math.max(1.5, parent.height * Format.clamp01(core.modelData / 100)) : 0 + radius: parent.radius + color: root.meterTone(core.level) + } + } + } + MouseArea { + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.NoButton + onPositionChanged: mouse => { + const column = Math.floor(mouse.x / (cores.cell + cores.pitchGap)); + const line = Math.floor(mouse.y / (cores.rowHeight + 3)); + const index = line * cores.perRow + Math.min(cores.perRow - 1, column); + cores.hovered = index >= 0 && index < cores.count ? index : -1; + } + onExited: cores.hovered = -1 + } + } + Item { + width: parent.width + height: coreModel.implicitHeight + + Caption { + id: coreModel + width: parent.width - coreReading.implicitWidth - 12 + text: root.stats ? [root.stats.meta.cores + " threads", root.stats.meta.model] + .filter(part => part).join(" · ") : "" + } + Caption { + id: coreReading + anchors.right: parent.right + text: cores.hovered >= 0 + ? "CPU " + cores.hovered + " · " + Helpers.percent(cores.values[cores.hovered]) : "" + color: Theme.textMid + font.features: Theme.tabularNumberFeatures + } + } + FactStrip { + readonly property var perThread: root.stats && root.stats.meta.cores > 0 + ? 100 * root.stats.load[0] / root.stats.meta.cores : null + facts: root.stats ? [ + { label: "Load 1 min", value: root.stats.load[0].toFixed(2), + level: Helpers.level(perThread) }, + { label: "5 min", value: root.stats.load[1].toFixed(2) }, + { label: "15 min", value: root.stats.load[2].toFixed(2) }, + { label: "Per thread", value: Helpers.known(perThread) + ? (perThread / 100).toFixed(2) : "—" } + ] : [] + } + } + + // ---- memory ---- + Column { + visible: root.view === "memory" + width: parent.width + spacing: Theme.scaled(10) + + History { + title: "Memory in use" + summary: root.spread(root.memoryPoints, "%") + series: [{ points: root.memoryPoints, tint: Theme.accentText }] + } + FactStrip { + readonly property var memory: root.stats ? root.stats.memory : null + readonly property var swapPercent: memory && memory.swapTotal > 0 + ? 100 * memory.swapUsed / memory.swapTotal : null + facts: memory ? [ + { label: "Used", value: Helpers.bytes(memory.used) }, + { label: "Available", value: Helpers.bytes(memory.available) }, + { label: "Total", value: Helpers.bytes(memory.total) }, + { label: "Swap used", + value: memory.swapTotal > 0 ? Helpers.percent(swapPercent) : "Off", + level: Helpers.level(swapPercent), + detail: Helpers.bytes(memory.swapUsed) + " of " + Helpers.bytes(memory.swapTotal) } + ] : [] + } + } + + // ---- storage ---- + Column { + id: storage + + readonly property var rows: Helpers.storageRows(root.stats, RemoteServer.options.mount) + + visible: root.view === "storage" + width: parent.width + spacing: Theme.scaled(4) + + Item { + width: parent.width + height: storageTitle.implicitHeight + Theme.scaled(4) + + Text { + id: storageTitle + text: "Filesystems" + font.family: Theme.fontMenu + font.pixelSize: Theme.typography.secondary + font.weight: Theme.weightSemibold + color: Theme.textMid + } + Caption { + anchors.right: parent.right + anchors.baseline: storageTitle.baseline + text: "Select one for the Storage tile" + color: Theme.textFaint + } + } + Caption { + visible: root.stats !== null && storage.rows.length === 0 + width: parent.width + text: root.stats ? root.stats.storageError || "No local filesystems reported" : "" + } + Repeater { + model: root.allDisks ? storage.rows : storage.rows.slice(0, 4) + + ListRow { + id: diskRow + + required property var modelData + readonly property bool chosen: modelData.mount === RemoteServer.options.mount + readonly property string level: Helpers.level(modelData.percent) + + height: Theme.scaled(48) + accessibleName: modelData.mount + ", " + modelData.percent + "% used, " + + Helpers.bytes(modelData.free) + " free" + + (chosen ? ", shown in the Storage tile" : "") + onActivated: Settings.setModuleOption("remote", "mount", diskRow.modelData.mount) + + Text { + id: mountName + x: Theme.scaled(8) + y: Theme.scaled(7) + width: Math.min(implicitWidth, parent.width * 0.45) + elide: Text.ElideMiddle + textFormat: Text.PlainText + text: diskRow.modelData.mount + font.family: Theme.fontMenu + font.pixelSize: Theme.typography.primary + font.weight: Theme.weightMedium + color: diskRow.chosen ? Theme.accentText : Theme.textHi + } + Caption { + anchors.left: mountName.right + anchors.leftMargin: Theme.scaled(8) + anchors.right: diskFree.left + anchors.rightMargin: Theme.scaled(8) + anchors.baseline: mountName.baseline + text: diskRow.modelData.type + color: Theme.textFaint + } + Caption { + id: diskFree + anchors.right: diskPercent.left + anchors.rightMargin: Theme.scaled(8) + anchors.baseline: mountName.baseline + text: Helpers.bytes(diskRow.modelData.free) + " free of " + + Helpers.bytes(diskRow.modelData.total) + font.features: Theme.tabularNumberFeatures + } + Figure { + id: diskPercent + anchors.right: parent.right + anchors.rightMargin: Theme.scaled(8) + anchors.baseline: mountName.baseline + text: diskRow.modelData.percent + "%" + color: root.tone(diskRow.level) + } + BlockMeter { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.leftMargin: Theme.scaled(8) + anchors.rightMargin: Theme.scaled(8) + anchors.bottomMargin: Theme.scaled(9) + height: 5 + blockWidth: 3 + gap: 2 + value: diskRow.modelData.percent / 100 + fillColor: root.meterTone(diskRow.level) + } + } + } + LinkText { + visible: storage.rows.length > 4 + x: Theme.scaled(8) + text: root.allDisks ? "Show fewer" : "Show all " + storage.rows.length + " filesystems" + onClicked: root.allDisks = !root.allDisks + } + } + + // ---- temperature ---- + Column { + id: thermal + + readonly property var sensors: Helpers.sensorRows(root.stats) + + visible: root.view === "temperature" + width: parent.width + spacing: Theme.scaled(10) + + History { + title: "Hottest sensor" + summary: root.spread(root.temperaturePoints, "°C") + series: [{ points: root.temperaturePoints, tint: Theme.accentText }] + } + Caption { + visible: root.stats !== null && thermal.sensors.length === 0 + width: parent.width + text: "This server reports no temperature sensors." + } + Grid { + width: parent.width + columns: 2 + columnSpacing: Theme.scaled(16) + + Repeater { + model: root.allSensors ? thermal.sensors : thermal.sensors.slice(0, 6) + + Item { + id: sensor + + required property var modelData + + width: (thermal.width - Theme.scaled(16)) / 2 + height: Theme.scaled(26) + Accessible.role: Accessible.StaticText + Accessible.name: sensor.modelData.name + Accessible.description: Math.round(sensor.modelData.celsius) + " °C" + + Text { + anchors.left: parent.left + anchors.right: sensorValue.left + anchors.rightMargin: Theme.scaled(8) + anchors.verticalCenter: parent.verticalCenter + elide: Text.ElideRight + textFormat: Text.PlainText + text: sensor.modelData.name + font.family: Theme.fontMenu + font.pixelSize: Theme.typography.secondary + color: Theme.textMid + } + Figure { + id: sensorValue + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + text: Math.round(sensor.modelData.celsius) + "°C" + color: root.tone(Helpers.level(sensor.modelData.celsius, "celsius")) + } + Rectangle { + anchors.bottom: parent.bottom + width: parent.width + height: 1 + color: Theme.hairlineSoft + } + } + } + } + LinkText { + visible: thermal.sensors.length > 6 + text: root.allSensors ? "Show fewer" : "Show all " + thermal.sensors.length + " sensors" + onClicked: root.allSensors = !root.allSensors + } + } + + // ---- network ---- + Column { + id: network + + readonly property var choices: { + const rows = Helpers.interfaceRows(root.stats); + return root.allInterfaces ? rows + : rows.filter(n => n.addresses.length > 0 || (n.rx || 0) + (n.tx || 0) > 0 + || n.name === RemoteServer.options.interface); + } + readonly property real ceiling: { + const rx = Helpers.summary(root.rxPoints); + const tx = Helpers.summary(root.txPoints); + return Helpers.chartCeiling(Math.max(rx ? rx.peak : 0, tx ? tx.peak : 0)); + } + + width: parent.width + spacing: Theme.scaled(8) + + Item { + width: parent.width + height: networkLabel.height + + SectionLabel { + id: networkLabel + width: parent.width - (pickLink.visible ? pickLink.width + Theme.scaled(10) : 0) + text: "NETWORK" + detail: root.net ? root.net.name + + (RemoteServer.options.interface === "" ? " · automatic" : "") : "" + } + LinkText { + id: pickLink + visible: root.stats !== null && root.stats.network.length > 1 + anchors.right: parent.right + anchors.verticalCenter: networkLabel.verticalCenter + font.pixelSize: Theme.typography.secondary + text: root.pickingInterface ? "Done" : "Change" + accessibleName: root.pickingInterface ? "Close interface list" : "Choose network interface" + onClicked: root.pickingInterface = !root.pickingInterface + } + } + + Row { + width: parent.width + + Repeater { + model: [ + { key: "rx", label: "in", tint: Theme.accentText, glyph: "arrow_downward" }, + { key: "tx", label: "out", tint: Theme.textMid, glyph: "arrow_upward" } + ] + + Row { + id: rate + + required property var modelData + + width: network.width / 2 + spacing: Theme.scaled(6) + Accessible.role: Accessible.StaticText + Accessible.name: rate.modelData.key === "rx" ? "Download" : "Upload" + Accessible.description: rateValue.text + + Sym { + anchors.verticalCenter: parent.verticalCenter + name: rate.modelData.glyph + size: Theme.iconMedium + color: rate.modelData.tint + } + Text { + id: rateValue + anchors.verticalCenter: parent.verticalCenter + text: Helpers.rate(root.net ? root.net[rate.modelData.key] : null) + font.family: Theme.fontNumeric + font.pixelSize: Theme.typography.title + font.weight: Theme.weightSemibold + font.features: Theme.tabularNumberFeatures + color: Theme.textHi + } + Caption { + anchors.baseline: rateValue.baseline + text: rate.modelData.label + } + } + } + } + + Item { + width: parent.width + height: Theme.scaled(54) + + Ui.TelemetryChart { + anchors.fill: parent + ceiling: network.ceiling + span: root.span + now: RemoteServer.now + description: "Network traffic, " + root.spanLabel + series: [ + { points: root.txPoints, tint: Theme.textMid }, + { points: root.rxPoints, tint: Theme.accentText } + ] + } + Caption { + x: 4 + y: 3 + text: Helpers.bytes(network.ceiling) + "/s" + color: Theme.textFaint + } + } + + Caption { + visible: text !== "" + width: parent.width + text: root.net ? root.net.addresses.join(" · ") + : root.stats ? "Interface " + RemoteServer.options.interface + " is not present" : "" + } + + // The interface list, opened from the section's link. + Column { + visible: root.pickingInterface + width: parent.width + spacing: 0 + + Repeater { + model: [{ name: "", addresses: [], rx: null, tx: null }].concat(network.choices) + + ListRow { + id: choice + + required property var modelData + readonly property bool automatic: modelData.name === "" + readonly property bool chosen: modelData.name === RemoteServer.options.interface + + height: Theme.listRowHeight + accessibleName: choice.automatic ? "Automatic interface" : choice.modelData.name + onActivated: { + Settings.setModuleOption("remote", "interface", choice.modelData.name); + root.pickingInterface = false; + } + + Sym { + id: choiceCheck + x: Theme.scaled(8) + anchors.verticalCenter: parent.verticalCenter + name: "check" + size: Theme.iconSmall + color: Theme.accentText + opacity: choice.chosen ? 1 : 0 + } + Text { + id: choiceName + anchors.left: choiceCheck.right + anchors.leftMargin: Theme.scaled(8) + anchors.verticalCenter: parent.verticalCenter + width: Math.min(implicitWidth, parent.width * 0.4) + elide: Text.ElideRight + textFormat: Text.PlainText + text: choice.automatic ? "Automatic" : choice.modelData.name + font.family: Theme.fontMenu + font.pixelSize: Theme.typography.primary + font.weight: choice.chosen ? Theme.weightSemibold : Theme.weightMedium + color: choice.chosen ? Theme.accentText : Theme.textHi + } + Caption { + anchors.left: choiceName.right + anchors.leftMargin: Theme.scaled(8) + anchors.right: choiceRate.left + anchors.rightMargin: Theme.scaled(8) + anchors.verticalCenter: parent.verticalCenter + text: choice.automatic + ? "default route" + (root.stats && root.stats.meta.defaultInterface + ? " · " + root.stats.meta.defaultInterface : "") + : choice.modelData.addresses[0] || "" + } + Row { + id: choiceRate + anchors.right: parent.right + anchors.rightMargin: Theme.scaled(8) + anchors.verticalCenter: parent.verticalCenter + visible: !choice.automatic + spacing: Theme.scaled(3) + + Sym { + anchors.verticalCenter: parent.verticalCenter + name: "arrow_downward" + size: Theme.iconTiny + color: Theme.textDim + } + Caption { + anchors.verticalCenter: parent.verticalCenter + width: implicitWidth + Theme.scaled(8) + text: Helpers.compactRate(choice.modelData.rx) + font.features: Theme.tabularNumberFeatures + } + Sym { + anchors.verticalCenter: parent.verticalCenter + name: "arrow_upward" + size: Theme.iconTiny + color: Theme.textDim + } + Caption { + anchors.verticalCenter: parent.verticalCenter + text: Helpers.compactRate(choice.modelData.tx) + font.features: Theme.tabularNumberFeatures + } + } + } + } + LinkText { + visible: root.stats !== null && root.stats.network.length > network.choices.length + || root.allInterfaces + x: Theme.scaled(8) + topPadding: Theme.scaled(6) + text: root.allInterfaces ? "Hide idle interfaces" + : "Show all " + (root.stats ? root.stats.network.length : 0) + " interfaces" + onClicked: root.allInterfaces = !root.allInterfaces + } + } + } + + Caption { + visible: root.stats !== null + width: parent.width + text: root.stats ? [root.stats.meta.os, "Linux " + root.stats.meta.kernel, + root.stats.meta.hostname].join(" · ") : "" + color: Theme.textFaint + } + } + } + } + ScrollChrome { + x: scroll.x + y: scroll.y + width: scroll.width + height: scroll.height + target: scroll + edgeColor: root.surfaceColor + } + + // ---- footer ---------------------------------------------------------- + Item { + id: footer + + x: root.gutter + y: root.height - root.gutter - height + width: header.width + height: Theme.panelFooterHeight + + Rectangle { + width: parent.width + height: 1 + color: Theme.hairlineSoft + } + Caption { + anchors.left: parent.left + anchors.leftMargin: 2 + anchors.right: actions.left + anchors.rightMargin: Theme.scaled(10) + anchors.verticalCenter: parent.verticalCenter + anchors.verticalCenterOffset: 1 + text: { + switch (RemoteServer.connection) { + case "setup": return "Nothing to monitor yet"; + case "connecting": return "Connecting to " + RemoteServer.host + "…"; + case "live": return "Updated " + RemoteServer.age + " · every " + RemoteServer.cadence + " s"; + default: return root.stats ? "Last reading " + RemoteServer.age : "Retrying automatically"; + } + } + color: Theme.textFaint + } + Row { + id: actions + anchors.right: parent.right + anchors.rightMargin: 2 + anchors.verticalCenter: parent.verticalCenter + anchors.verticalCenterOffset: 1 + spacing: Theme.scaled(16) + + LinkText { + visible: root.configured + text: "Refresh" + accessibleName: "Refresh server readings" + onClicked: RemoteServer.refresh() + } + LinkText { + text: "Settings" + accessibleName: "Remote Server settings" + onClicked: RemoteServer.configure() + } + } + } +} diff --git a/roles/desktop/files/quickshell/Popovers/qmldir b/roles/desktop/files/quickshell/Popovers/qmldir index b7e2d9eb..30750b7f 100644 --- a/roles/desktop/files/quickshell/Popovers/qmldir +++ b/roles/desktop/files/quickshell/Popovers/qmldir @@ -48,3 +48,4 @@ TailscalePopover TailscalePopover.qml UpdatesPopover UpdatesPopover.qml WeatherPopover WeatherPopover.qml WifiPopover WifiPopover.qml +RemoteServerPopover RemoteServerPopover.qml diff --git a/roles/desktop/files/quickshell/Settings/ModuleDetailView.qml b/roles/desktop/files/quickshell/Settings/ModuleDetailView.qml index 74aa0f7f..b14bd2ab 100644 --- a/roles/desktop/files/quickshell/Settings/ModuleDetailView.qml +++ b/roles/desktop/files/quickshell/Settings/ModuleDetailView.qml @@ -2,6 +2,7 @@ pragma ComponentBehavior: Bound import QtQuick import "../Common" import "../Common/SettingsHelpers.js" as SettingsHelpers +import "../Common/RemoteServerHelpers.js" as RemoteHelpers // Built-in options shared by the bar editor and standalone detail view. // The detail policy control lives here (storage stays in Settings.mods); @@ -244,6 +245,7 @@ SettingsPage { case "indicators": return indicatorsOptions; case "clock": return clockOptions; case "weather": return weatherOptions; + case "remote": return remoteOptions; case "notes": return notesOptions; case "t3": return t3Options; case "hermes": return hermesOptions; @@ -896,6 +898,91 @@ SettingsPage { } } + Component { + id: remoteOptions + Column { + id: remoteSettings + spacing: Theme.settingsRowSpacing + Claim { + active: remoteSettings.visible + onClaimed: RemoteServer.acquire() + onReleased: RemoteServer.release() + } + SettingsTextRow { + width: parent.width + label: "SSH host" + placeholder: "john@10.10.0.7 or SSH alias" + hint: "Uses your SSH config and keys. Connect once in a terminal to verify the host key. Requires Linux and Python 3.9+." + value: view.opts.host + dirty: view.optDirty("host") + onCommitted: text => view.setOpt("host", text) + onResetRequested: view.resetOpt("host") + } + SettingsTextRow { + width: parent.width + label: "Display name" + placeholder: "The Beast" + value: view.opts.label + dirty: view.optDirty("label") + onCommitted: text => view.setOpt("label", text) + onResetRequested: view.resetOpt("label") + } + SelectRow { + width: parent.width + label: "Menubar statistic" + model: RemoteHelpers.METRICS + current: view.opts.metric + dirty: view.optDirty("metric") + onPicked: value => view.setOpt("metric", value) + onResetRequested: view.resetOpt("metric") + } + SwitchRow { + width: parent.width + label: "Show server name" + checked: view.opts.showLabel + dirty: view.optDirty("showLabel") + onToggled: value => view.setOpt("showLabel", value) + onResetRequested: view.resetOpt("showLabel") + } + SettingsTextRow { + width: parent.width + label: "Filesystem" + hint: "Mount point used for the menubar storage statistic." + placeholder: "/" + value: view.opts.mount + dirty: view.optDirty("mount") + onCommitted: text => view.setOpt("mount", text) + onResetRequested: view.resetOpt("mount") + } + SettingsTextRow { + width: parent.width + label: "Network interface" + hint: "Leave empty to use the default route. Enter an interface name to pin it." + placeholder: "Automatic" + value: view.opts.interface + dirty: view.optDirty("interface") + onCommitted: text => view.setOpt("interface", text) + onResetRequested: view.resetOpt("interface") + } + SliderRow { + width: parent.width + label: "Update every" + hint: "The open dashboard updates every 2 seconds." + min: 2; max: 60; step: 1 + value: view.optValue("pollSecs") + unit: "s" + dirty: view.optDirty("pollSecs") + onMoved: value => view.settleOpt("pollSecs", value) + onResetRequested: view.resetOpt("pollSecs") + } + SettingsAction { + text: "Refresh connection" + enabled: RemoteServer.host !== "" + onTriggered: RemoteServer.refresh() + } + } + } + Component { id: weatherOptions diff --git a/roles/desktop/files/quickshell/Settings/SettingsSelect.qml b/roles/desktop/files/quickshell/Settings/SettingsSelect.qml index 8ea0501b..c87ca989 100644 --- a/roles/desktop/files/quickshell/Settings/SettingsSelect.qml +++ b/roles/desktop/files/quickshell/Settings/SettingsSelect.qml @@ -48,6 +48,7 @@ Controls.ComboBox { contentItem: Text { text: combo.displayText + textFormat: Text.PlainText color: Theme.textHi font.family: combo.fontFor && combo.currentIndex >= 0 ? combo.fontFor(combo.model[combo.currentIndex].value) : Theme.fontMenu @@ -86,6 +87,7 @@ Controls.ComboBox { anchors.rightMargin: Theme.controlSpacing anchors.verticalCenter: parent.verticalCenter text: option.modelData.label + textFormat: Text.PlainText color: Theme.textHi font.family: combo.fontFor ? combo.fontFor(option.modelData.value) : Theme.fontMenu font.pixelSize: Theme.typography.control diff --git a/roles/desktop/files/quickshell/Ui/TelemetryChart.qml b/roles/desktop/files/quickshell/Ui/TelemetryChart.qml new file mode 100644 index 00000000..97850f43 --- /dev/null +++ b/roles/desktop/files/quickshell/Ui/TelemetryChart.qml @@ -0,0 +1,118 @@ +import QtQuick +import "../Common" +import "../Common/Format.js" as Format + +// A rolling history plot. The time axis is a fixed window ending at `now`, so +// a short history fills in from the right instead of being stretched across +// the whole width, and the plot keeps moving between samples. Each series is +// { points: [{ at, value }], tint }; a null value breaks the line rather than +// dropping it to zero. Colours reach the 2D context as rgba() strings, which +// is the one form it reads alpha from reliably. +Canvas { + id: root + + property var series: [] + // The value at the top edge. Callers with an open-ended scale (rates) + // pass a rounded ceiling rather than the raw peak, so the plot does not + // rescale on every sample. + property real ceiling: 100 + property double span: 600000 + property double now: Date.now() + property int divisions: 4 + property color gridColor: Theme.hairlineSoft + property string description: "Recent history" + + // Keep the latest-point mark whole at the right edge. + readonly property real inset: 3 + + implicitHeight: 48 + Accessible.role: Accessible.Chart + Accessible.name: description + + onSeriesChanged: requestPaint() + onCeilingChanged: requestPaint() + onNowChanged: requestPaint() + onGridColorChanged: requestPaint() + onWidthChanged: requestPaint() + onHeightChanged: requestPaint() + + function rgba(c, alpha) { + return "rgba(" + Math.round(c.r * 255) + "," + Math.round(c.g * 255) + "," + + Math.round(c.b * 255) + "," + (c.a * alpha).toFixed(3) + ")"; + } + + onPaint: { + const ctx = getContext("2d"); + ctx.reset(); + if (width <= root.inset * 2 || height <= 2) + return; + const top = 1; + const bottom = height - 1; + const plot = bottom - top; + const right = width - root.inset; + const start = root.now - root.span; + const max = root.ceiling > 0 ? root.ceiling : 1; + + ctx.lineWidth = 1; + ctx.strokeStyle = rgba(root.gridColor, 1); + for (let i = 0; i <= root.divisions; i++) { + const y = Math.round(top + plot * i / root.divisions) + 0.5; + ctx.beginPath(); + ctx.moveTo(0, y); + ctx.lineTo(width, y); + ctx.stroke(); + } + + for (const line of root.series) { + const runs = []; + let run = []; + for (const p of line.points) { + if (p.value === null || p.value === undefined || !isFinite(p.value)) { + if (run.length) + runs.push(run); + run = []; + continue; + } + run.push([right * (p.at - start) / root.span, + bottom - plot * Format.clamp01(p.value / max)]); + } + if (run.length) + runs.push(run); + + const fill = ctx.createLinearGradient(0, top, 0, bottom); + fill.addColorStop(0, rgba(line.tint, 0.30)); + fill.addColorStop(1, rgba(line.tint, 0.02)); + ctx.lineJoin = "round"; + ctx.lineCap = "round"; + for (const r of runs) { + if (r.length < 2) + continue; + ctx.beginPath(); + ctx.moveTo(r[0][0], bottom); + for (const point of r) + ctx.lineTo(point[0], point[1]); + ctx.lineTo(r[r.length - 1][0], bottom); + ctx.closePath(); + ctx.fillStyle = fill; + ctx.fill(); + + ctx.beginPath(); + ctx.moveTo(r[0][0], r[0][1]); + for (const point of r) + ctx.lineTo(point[0], point[1]); + ctx.lineWidth = 1.5; + ctx.strokeStyle = rgba(line.tint, 1); + ctx.stroke(); + } + + const last = runs.length ? runs[runs.length - 1] : null; + if (last) { + const point = last[last.length - 1]; + ctx.beginPath(); + ctx.arc(point[0], point[1], 2.5, 0, 2 * Math.PI); + ctx.fillStyle = rgba(line.tint, 1); + ctx.fill(); + } + } + } +} diff --git a/roles/desktop/files/quickshell/Ui/qmldir b/roles/desktop/files/quickshell/Ui/qmldir index 427a2088..f4004cf1 100644 --- a/roles/desktop/files/quickshell/Ui/qmldir +++ b/roles/desktop/files/quickshell/Ui/qmldir @@ -33,3 +33,4 @@ TextField TextField.qml Toggle Toggle.qml ToggleSwitch ToggleSwitch.qml WidgetButton WidgetButton.qml +TelemetryChart TelemetryChart.qml diff --git a/roles/desktop/files/quickshell/assets/tabler/aliases.json b/roles/desktop/files/quickshell/assets/tabler/aliases.json index f2e40229..749bb27a 100644 --- a/roles/desktop/files/quickshell/assets/tabler/aliases.json +++ b/roles/desktop/files/quickshell/assets/tabler/aliases.json @@ -235,6 +235,7 @@ "videocam": "video", "view_quilt": "layout", "visibility": "eye", + "visibility_off": "eye-off", "volume_down": "volume-2", "volume_mute": "volume-3", "volume_off": "volume-off", diff --git a/roles/desktop/files/quickshell/assets/tabler/outline.ttf b/roles/desktop/files/quickshell/assets/tabler/outline.ttf index d4d4fb6c..741649a8 100644 Binary files a/roles/desktop/files/quickshell/assets/tabler/outline.ttf and b/roles/desktop/files/quickshell/assets/tabler/outline.ttf differ diff --git a/roles/desktop/files/quickshell/qmldir b/roles/desktop/files/quickshell/qmldir index 51f98e8d..b9ec8f77 100644 --- a/roles/desktop/files/quickshell/qmldir +++ b/roles/desktop/files/quickshell/qmldir @@ -11,3 +11,5 @@ NetworkOverlayWindow NetworkOverlayWindow.qml OsdWindow OsdWindow.qml ShortcutsOverlay ShortcutsOverlay.qml SettingsWindow SettingsWindow.qml +PolkitPrompt PolkitPrompt.qml +PolkitWindow PolkitWindow.qml diff --git a/roles/desktop/files/quickshell/scripts/remote-server.py b/roles/desktop/files/quickshell/scripts/remote-server.py new file mode 100644 index 00000000..63dbe516 --- /dev/null +++ b/roles/desktop/files/quickshell/scripts/remote-server.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +"""Launch one read-only Linux telemetry stream over the user's SSH connection. + +exec keeps the SSH lifetime identical to the shell-owned Process. The probe +is sent as program text, never installed remotely. stdin remains available for +cadence/refresh requests; EOF ends the remote probe. +""" +import argparse +import os +from pathlib import Path +import re +import shlex + + +def command(host): + # A destination is data, never SSH options, a URI or shell program. + if not re.fullmatch(r"[A-Za-z0-9_][A-Za-z0-9_.@:%\[\]-]{0,253}", host): + raise ValueError("Enter an SSH alias or user@hostname; set ports in ~/.ssh/config") + source = Path(__file__).with_name("remote_server_probe.py").read_text() + return [ + "ssh", "-T", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=yes", + "-o", "ConnectTimeout=7", "-o", "ConnectionAttempts=1", + "-o", "ServerAliveInterval=5", "-o", "ServerAliveCountMax=2", + # Own this connection, including its teardown, even if ssh config + # normally shares a persistent master with interactive terminals. + "-o", "ControlMaster=no", "-o", "ControlPath=none", + "-o", "ClearAllForwardings=yes", "-o", "ForwardAgent=no", + "-o", "PermitLocalCommand=no", "-o", "RequestTTY=no", + "--", host, "python3 -B -u -c " + shlex.quote(source), + ] + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("host") + args = parser.parse_args() + try: + argv = command(args.host) + except ValueError as error: + parser.error(str(error)) + os.execvp(argv[0], argv) + + +if __name__ == "__main__": + main() diff --git a/roles/desktop/files/quickshell/scripts/remote_server_probe.py b/roles/desktop/files/quickshell/scripts/remote_server_probe.py new file mode 100644 index 00000000..4c3a7694 --- /dev/null +++ b/roles/desktop/files/quickshell/scripts/remote_server_probe.py @@ -0,0 +1,222 @@ +#!/usr/bin/env python3 +"""Linux-only, unprivileged SSH probe. Standard library and GNU df/ip only. + +Newline JSON on stdout. stdin accepts {"interval": 2|5, "refresh": true}. +No files, credentials, services or persistent state are written on the host. +""" +import json +import os +from pathlib import Path +import re +import select +import socket +import subprocess +import sys +import time + + +def read(path, fallback=""): + try: + return Path(path).read_text(errors="replace").strip() + except OSError: + return fallback + + +def cpu_ticks(text): + result = {} + for line in text.splitlines(): + parts = line.split() + if parts and re.fullmatch(r"cpu\d*", parts[0]): + # guest/guest_nice are already included in user/nice. + ticks = [int(v) for v in parts[1:9]] + result[parts[0]] = (sum(ticks), ticks[3] + ticks[4]) + return result + + +def cpu_percent(current, previous): + if previous is None: + return None + total = current[0] - previous[0] + idle = current[1] - previous[1] + if total <= 0 or idle < 0 or idle > total: + return None + return round(100 * (total - idle) / total, 1) + + +def memory_info(text): + fields = {} + for line in text.splitlines(): + key, _, value = line.partition(":") + if value.strip(): + fields[key] = int(value.split()[0]) * 1024 + total = fields.get("MemTotal", 0) + available = fields.get("MemAvailable") + # Do not confuse MemFree with memory available to applications. + return {"total": total, "available": available, + "used": total - available if available is not None else None, + "swapTotal": fields.get("SwapTotal", 0), + "swapUsed": fields.get("SwapTotal", 0) - fields.get("SwapFree", 0)} + + +def network_counters(text): + result = {} + for line in text.splitlines(): + name, sep, values = line.partition(":") + fields = values.split() + if sep and len(fields) >= 16 and name.strip() != "lo": + result[name.strip()] = (int(fields[0]), int(fields[8])) + return result + + +def rate(value, old, elapsed): + return (value - old) / elapsed if old is not None and elapsed > 0 and value >= old else None + + +def temperatures(): + result = [] + for path in sorted(Path("/sys/class/hwmon").glob("hwmon*/temp*_input")): + try: + value = int(read(path)) / 1000 + except ValueError: + continue + if not -40 <= value <= 150: + continue + prefix = path.name.removesuffix("_input") + label = read(path.with_name(prefix + "_label"), prefix) + chip = read(path.parent / "name", path.parent.name) + result.append({"name": chip + " · " + label, "celsius": value}) + if not result: + for path in sorted(Path("/sys/class/thermal").glob("thermal_zone*/temp")): + try: + value = int(read(path)) / 1000 + except ValueError: + continue + if -40 <= value <= 150: + result.append({"name": read(path.parent / "type", path.parent.name), "celsius": value}) + return result[:128] + + +def run(args): + return subprocess.run(args, capture_output=True, text=True, timeout=3, + check=True, env={**os.environ, "LC_ALL": "C"}).stdout + + +def storage(): + rows = [] + try: + text = run(["df", "-l", "-B1", "--output=source,fstype,size,used,avail,pcent,target", + "-x", "tmpfs", "-x", "devtmpfs", "-x", "squashfs", "-x", "overlay"]) + for line in text.splitlines()[1:]: + parts = line.split(None, 6) + if len(parts) != 7: + continue + device, fs, total, used, free, percent, mount = parts + rows.append({"device": device, "type": fs, "total": int(total), + "used": int(used), "free": int(free), + "percent": int(percent.rstrip("%")), "mount": mount}) + return sorted(rows, key=lambda row: row["mount"]), "" + except (OSError, ValueError, subprocess.SubprocessError): + return [], "Filesystem statistics unavailable" + + +def metadata(): + os_release = dict(line.split("=", 1) for line in read("/etc/os-release").splitlines() if "=" in line) + model = next((line.split(":", 1)[1].strip() for line in read("/proc/cpuinfo").splitlines() + if line.startswith(("model name", "Hardware"))), "") + addresses = {} + default_interface = "" + try: + for link in json.loads(run(["ip", "-j", "address", "show"])): + addresses[link["ifname"]] = [a["local"] for a in link.get("addr_info", []) + if a.get("scope") == "global"] + except (OSError, ValueError, KeyError, subprocess.SubprocessError): + pass + try: + routes = json.loads(run(["ip", "-j", "route", "show", "default"])) + if routes: + default_interface = min(routes, key=lambda r: r.get("metric", 0)).get("dev", "") + except (OSError, ValueError, AttributeError, subprocess.SubprocessError): + pass + return {"hostname": socket.gethostname(), "os": os_release.get("PRETTY_NAME", "Linux").strip('"'), + "kernel": os.uname().release, "model": model, "cores": os.cpu_count(), + "addresses": addresses, "defaultInterface": default_interface} + + +class Probe: + def __init__(self): + self.previous_cpu = {} + self.previous_net = {} + self.previous_time = None + self.boot = "" + self.slow_at = -60.0 + self.meta = {} + self.disks = [] + self.storage_error = "" + + def sample(self): + now = time.monotonic() + boot = read("/proc/sys/kernel/random/boot_id") + if boot != self.boot: + self.previous_cpu, self.previous_net, self.previous_time = {}, {}, None + self.boot = boot + cpu = cpu_ticks(read("/proc/stat")) + if "cpu" not in cpu: + raise RuntimeError("This widget requires a Linux server with readable /proc") + net = network_counters(read("/proc/net/dev")) + elapsed = now - self.previous_time if self.previous_time is not None else 0 + if now - self.slow_at >= 60: + self.meta = metadata() + self.disks, self.storage_error = storage() + self.slow_at = now + interfaces = [] + for name, counters in net.items(): + previous = self.previous_net.get(name) + interfaces.append({"name": name, "received": counters[0], "sent": counters[1], + "rx": rate(counters[0], previous[0] if previous else None, elapsed), + "tx": rate(counters[1], previous[1] if previous else None, elapsed), + "addresses": self.meta["addresses"].get(name, [])}) + result = {"version": 1, "boot": boot, "uptime": float(read("/proc/uptime").split()[0]), + "meta": self.meta, "cpu": cpu_percent(cpu["cpu"], self.previous_cpu.get("cpu")), + "perCore": [cpu_percent(v, self.previous_cpu.get(k)) for k, v in cpu.items() if k != "cpu"], + "load": list(os.getloadavg()), "memory": memory_info(read("/proc/meminfo")), + "temperatures": temperatures(), "storage": self.disks, "storageError": self.storage_error, + "network": sorted(interfaces, key=lambda row: row["name"])} + self.previous_cpu, self.previous_net, self.previous_time = cpu, net, now + return result + + +def main(): + probe = Probe() + interval = 5 + pending = b"" + deadline = 0.0 + while True: + if time.monotonic() >= deadline: + print(json.dumps(probe.sample(), allow_nan=False, separators=(",", ":")), flush=True) + deadline = time.monotonic() + interval + ready, _, _ = select.select([sys.stdin], [], [], max(0, deadline - time.monotonic())) + if not ready: + continue + chunk = os.read(sys.stdin.fileno(), 4096) + if not chunk: + return + pending += chunk + if len(pending) > 8192: + raise ValueError("Control request too large") + while b"\n" in pending: + line, pending = pending.split(b"\n", 1) + try: + request = json.loads(line) + interval = max(2, min(60, int(request.get("interval", interval)))) + if request.get("refresh"): + probe.slow_at = -60.0 + deadline = min(deadline, time.monotonic() + (0 if request.get("refresh") else interval)) + except (ValueError, TypeError, AttributeError): + continue + + +if __name__ == "__main__": + try: + main() + except BrokenPipeError: + pass diff --git a/roles/desktop/files/quickshell/shell.qml b/roles/desktop/files/quickshell/shell.qml index 9cf34966..3db3d63a 100644 --- a/roles/desktop/files/quickshell/shell.qml +++ b/roles/desktop/files/quickshell/shell.qml @@ -270,6 +270,7 @@ ShellRoot { NotificationToasts {} OsdWindow {} ShortcutsOverlay {} + PolkitWindow {} // Reading a singleton's property is what constructs it. Notifications // must start collecting, GitHub must start polling — GitHub diff --git a/roles/desktop/tasks/main.yml b/roles/desktop/tasks/main.yml index 099eab46..dadca2ab 100644 --- a/roles/desktop/tasks/main.yml +++ b/roles/desktop/tasks/main.yml @@ -73,7 +73,6 @@ - hypridle - hyprlock - hyprpicker - - hyprpolkitagent - hyprsunset - quickshell - ImageMagick @@ -1261,11 +1260,23 @@ mode: "0644" loop: - hypridle - - hyprpolkitagent notify: - Reload user systemd - Restart hypridle +- name: Retire standalone Polkit agent startup now provided by Quickshell + become: true + become_user: "{{ primary_user }}" + ansible.builtin.file: + path: "{{ primary_home }}/.config/systemd/user/{{ item }}" + state: absent + loop: + - hyprland-session.target.wants/hyprpolkitagent.service + - graphical-session.target.wants/hyprpolkitagent.service + - hyprpolkitagent.service + notify: Reload user systemd + tags: [quickshell] + # Dictation follows the developer tooling feature, which also selects its GPU # backend, downloads its model, and binds its keys. Without that model an # always-on daemon would only hold an idle audio and GPU context. @@ -1351,7 +1362,7 @@ state: link force: true loop: >- - {{ ['quickshell', 'hypridle', 'hyprpolkitagent', 'cybexos-input-method'] + {{ ['quickshell', 'hypridle', 'cybexos-input-method'] + (features.developer_tools | bool | ternary(['voxtype'], [])) }} notify: Reload user systemd diff --git a/roles/desktop/templates/hyprpolkitagent.service.j2 b/roles/desktop/templates/hyprpolkitagent.service.j2 deleted file mode 100644 index c7eb2ee7..00000000 --- a/roles/desktop/templates/hyprpolkitagent.service.j2 +++ /dev/null @@ -1,12 +0,0 @@ -[Unit] -Description=Hyprland Polkit authentication agent -PartOf=hyprland-session.target - -[Service] -Type=simple -ExecStart=/usr/libexec/hyprpolkitagent -Restart=on-failure -RestartSec=2 - -[Install] -WantedBy=hyprland-session.target diff --git a/roles/dotfiles/tasks/wallpapers.yml b/roles/dotfiles/tasks/wallpapers.yml index 48d154b4..4b43b57c 100644 --- a/roles/dotfiles/tasks/wallpapers.yml +++ b/roles/dotfiles/tasks/wallpapers.yml @@ -44,7 +44,8 @@ ansible.builtin.copy: dest: "{{ primary_home }}/.config/cybexos/shell.json" mode: "0600" - content: "{{ wallpaper_settings | combine(cybexos_desktop_contract.shell) | to_nice_json }}\n" + # Other seeded defaults, including the font, must preserve saved choices. + content: "{{ cybexos_desktop_contract.shell | combine(wallpaper_settings) | combine({'wall': cybexos_desktop_contract.shell.wall}) | to_nice_json }}\n" when: - wallpaper_settings.wall | default('') == '' - wallpaper_settings.wallDir | default('~/Pictures/Wallpapers') in ['~/Pictures/Wallpapers', primary_home + '/Pictures/Wallpapers'] diff --git a/roles/finalize/tasks/main.yml b/roles/finalize/tasks/main.yml index eb995903..e9c695a4 100644 --- a/roles/finalize/tasks/main.yml +++ b/roles/finalize/tasks/main.yml @@ -105,7 +105,6 @@ - start - quickshell.service - hypridle.service - - hyprpolkitagent.service changed_when: true - name: Confirm the restored desktop services are active @@ -120,7 +119,6 @@ loop: - quickshell - hypridle - - hyprpolkitagent changed_when: false rescue: diff --git a/tests/ownership-layering.py b/tests/ownership-layering.py index a5101d60..142f93fe 100755 --- a/tests/ownership-layering.py +++ b/tests/ownership-layering.py @@ -266,8 +266,61 @@ def repository_contract() -> None: assert (ROOT / "docs/architecture/ownership.md").is_file() +def polkit_runtime_contract() -> None: + """Exercise agent handoff without starting a real shell or host service.""" + with tempfile.TemporaryDirectory(prefix="cybexos-polkit-runtime.") as temporary: + root = Path(temporary) + shell = root / "home/.local/share/cybexos/runtime/quickshell" + shell.mkdir(parents=True) + (shell / "shell.qml").write_text("// fixture\n") + native = shell / "PolkitWindow.qml" + native.write_text("// fixture\n") + binary = root / "bin" + binary.mkdir() + log = root / "calls" + # Only the executable is redirected; selection and service handoff + # run through the production resolver. All systemctl calls are mocked. + runtime = root / "runtime" + runtime.write_text(RUNTIME.read_text().replace("exec /usr/bin/qs -p", "exec qs -p")) + runtime.chmod(0o755) + for name, script in { + "systemctl": '''#!/bin/bash +case "$*" in + *is-active*) exit "${FIXTURE_ACTIVE:-1}" ;; + *LoadState*) printf '%s\\n' "${FIXTURE_LOAD:-loaded}" ;; + *stop*) printf 'stop\\n' >>"$FIXTURE_LOG"; exit "${FIXTURE_STOP:-0}" ;; + *start*) printf 'start\\n' >>"$FIXTURE_LOG" ;; + *) exit 9 ;; +esac +''', + "qs": '#!/bin/bash\nprintf "shell\\n" >>"$FIXTURE_LOG"\n', + }.items(): + path = binary / name + path.write_text(script) + path.chmod(0o755) + env = {**os.environ, "HOME": str(root / "home"), + "XDG_DATA_HOME": str(root / "home/.local/share"), + "XDG_CONFIG_HOME": str(root / "home/.config"), + "CYBEXOS_RUNTIME_TESTING": "0", "FIXTURE_LOG": str(log), + "PATH": f"{binary}:{os.environ['PATH']}"} + + def start(**values): + log.write_text("") + result = run(runtime, "exec", "quickshell", env={**env, **values}, check=False) + return result.returncode, log.read_text().splitlines() + + assert start(FIXTURE_ACTIVE="0") == (0, ["stop", "shell"]) + assert start() == (0, ["stop", "shell"]), "cancel pending legacy startup too" + assert start(FIXTURE_LOAD="not-found") == (0, ["shell"]) + assert start(FIXTURE_LOAD="not-found", FIXTURE_ACTIVE="0") == (0, ["stop", "shell"]) + assert start(FIXTURE_STOP="1") == (1, ["stop"]), "do not register competing agents" + native.unlink() + assert start() == (0, ["start", "shell"]), "older runtimes retain their agent" + + if __name__ == "__main__": migration_contract() dev_source_contract() repository_contract() + polkit_runtime_contract() print("Vendor runtime updates preserve every user-owned layer byte-for-byte") diff --git a/tests/qml-lifecycle/shell.qml b/tests/qml-lifecycle/shell.qml index bd7a22f6..a07239b8 100644 --- a/tests/qml-lifecycle/shell.qml +++ b/tests/qml-lifecycle/shell.qml @@ -28,6 +28,7 @@ ShellRoot { } function runLifecycle() { + runPolkitLifecycle(); root.revealer = revealerComponent.createObject(harness, { reveal: false }); root.toggle = toggleComponent.createObject(harness); root.action = actionComponent.createObject(harness); @@ -66,6 +67,114 @@ ShellRoot { "sleep 0.2; kill -TERM -- \"$1\"", "bash", String(Quickshell.processId)]); } + function descendant(item, name) { + if (item.objectName === name) return item; + for (const child of item.children || []) { + const result = descendant(child, name); + if (result) return result; + } + return null; + } + + // Drive the shipped view with a PAM-like conversation: these fixtures + // never register an agent or authenticate against the host system. + function runPolkitLifecycle() { + const conversation = polkitFlow.createObject(harness); + const next = polkitFlow.createObject(harness); + const view = polkitView.createObject(harness, { flow: conversation }); + root.check(view !== null, "Polkit prompt did not construct"); + if (!view) return; + const field = descendant(view, "polkitResponse"); + const reveal = descendant(view, "polkitReveal"); + root.check(field !== null && reveal !== null, "Polkit controls are missing"); + if (field && reveal) { + view.focusResponse(); + root.check(field.focus, "Polkit did not focus the response field"); + root.check(field.echoMode === TextInput.Password, "secret prompt is not masked"); + field.text = "fixture response"; + reveal.triggered(); + root.check(field.echoMode === TextInput.Normal, "reveal does not show the response"); + view.submit(); + root.check(conversation.received === "fixture response", "response was not submitted intact"); + root.check(field.text === "" && !view.revealed, "submit retained a response or reveal state"); + view.submit(); + root.check(conversation.submissions === 1, "double submission was accepted while waiting"); + + conversation.authenticationFailed(); + conversation.isResponseRequired = true; + root.check(view.hasError, "retry lacks an error indication"); + root.check(field.echoMode === TextInput.Password, "retry reused reveal state"); + field.text = "discard on account switch"; + conversation.selectedIdentity = conversation.identities[1]; + root.check(field.text === "" && !view.attemptFailed, "account switch retained response or error"); + root.check(view.account === "Admin (admin)", "selected identity label is incorrect"); + + conversation.inputPrompt = "Verification code:"; + conversation.responseVisible = true; + root.check(field.echoMode === TextInput.Normal && !reveal.visible, + "visible PAM response is incorrectly treated as a password"); + field.text = "discard on prompt change"; + conversation.inputPrompt = "Password:"; + conversation.responseVisible = false; + root.check(field.text === "", "new prompt retained the previous response"); + + field.text = "discard on next request"; + view.detailsOpen = true; + view.flow = next; + root.check(field.text === "" && !view.detailsOpen, "next request retained conversation state"); + field.text = "discard on cancellation"; + view.cancel(); + root.check(next.isCancelled && field.text === "", "cancel did not clear and abort the request"); + + view.flow = conversation; + field.text = "discard on remote cancellation"; + conversation.isCancelled = true; + root.check(field.text === "" && !field.enabled, "remote cancellation retained an enabled input"); + conversation.isCancelled = false; + field.text = "discard on success"; + conversation.isCompleted = true; + root.check(field.text === "" && !field.enabled, "completed flow retained an enabled input"); + view.flow = null; + root.check(view.finished, "removed flow is still actionable"); + } + view.destroy(); + conversation.destroy(); + next.destroy(); + } + + Component { + id: polkitView + PolkitPrompt { width: 392 } + } + Component { + id: polkitFlow + QtObject { + property string message: "Authenticate to unlock the fixture" + property string actionId: "org.cybexos.fixture" + property var identities: [ + { string: "john", displayName: "John Example" }, + { string: "admin", displayName: "Admin" } + ] + property var selectedIdentity: identities[0] + property bool isResponseRequired: true + property bool isCompleted: false + property bool isCancelled: false + property bool responseVisible: false + property string inputPrompt: "Password:" + property string supplementaryMessage: "" + property bool supplementaryIsError: false + property string received: "" + property int submissions: 0 + signal authenticationFailed() + function submit(value) { + received = value; + submissions++; + isResponseRequired = false; + } + function cancelAuthenticationRequest() { isCancelled = true; } + } + } + Component { id: revealerComponent Common.Revealer { diff --git a/tests/quickshell/input-method.test.cjs b/tests/quickshell/input-method.test.cjs index fed23537..d9f74093 100644 --- a/tests/quickshell/input-method.test.cjs +++ b/tests/quickshell/input-method.test.cjs @@ -18,7 +18,7 @@ test("IBus runs as a session unit on Hyprland's input-method v2 protocol", () => test("the workstation and the image start the same IBus unit", () => { const desktop = read("roles/desktop/tasks/main.yml"); assert.match(desktop, /src: cybexos-input-method\.service\s+dest: "\{\{ primary_home \}\}\/\.config\/systemd\/user\/cybexos-input-method\.service"/); - assert.match(desktop, /\['quickshell', 'hypridle', 'hyprpolkitagent', 'cybexos-input-method'\]/); + assert.match(desktop, /\['quickshell', 'hypridle', 'cybexos-input-method'\]/); const target = read("image/rootfs/usr/lib/systemd/user/hyprland-session.target"); assert.match(target, /^Wants=.*\bcybexos-input-method\.service\b/m); assert.match(read("image/package"), diff --git a/tests/quickshell/omarchy-theme.test.cjs b/tests/quickshell/omarchy-theme.test.cjs index dd83e60d..d301a0cf 100644 --- a/tests/quickshell/omarchy-theme.test.cjs +++ b/tests/quickshell/omarchy-theme.test.cjs @@ -16,7 +16,8 @@ test("Omarchy adapter maps the default font, geometry and supplied palette borde assert.equal(p.font, "mono"); assert.equal(p.surfaceCornerRadius, 16); const v = values(p); - assert.equal(Math.round(420 * Number(v["font.base-size"]) / 12), 420); + assert.equal(v["font.base-size"], "14"); + assert.equal(Math.round(420 * Number(v["font.base-size"]) / 12), 490); for (const surface of ["popups", "tooltip", "menu", "launcher", "notifications"]) { assert.equal(v[surface + ".border"], palette.surfaceBorder); assert.equal(v[surface + ".border-width"], "2"); @@ -52,7 +53,7 @@ test("plugin overrides stay independent and shared border opacity is applied onc const p = prefs({ pluginScale: 150, textScale: "large", pluginBorderMode: "custom", pluginBorderColor: "#abcdef", pluginBorderWidth: 4, pluginBorderOpacity: 50, pluginRadius: 0 }); const v = values(p); - assert.equal(v["font.base-size"], "21"); + assert.equal(v["font.base-size"], "24"); assert.equal(v["popups.border"], "#abcdef"); assert.equal(v["popups.border-alpha"], "0.5"); const shared = values(prefs(), { ...palette, surfaceBorder: "#fedcba", diff --git a/tests/quickshell/polkit.test.cjs b/tests/quickshell/polkit.test.cjs new file mode 100644 index 00000000..16487ef7 --- /dev/null +++ b/tests/quickshell/polkit.test.cjs @@ -0,0 +1,24 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { load } = require("./shell.cjs"); +const { identityLabel, identityOptions } = load("PolkitHelpers.js"); + +test("Polkit shows the full account name without leaking the other GECOS fields", () => { + assert.equal(identityLabel({ string: "john", displayName: "John Example,Room 4,555-0100" }), + "John Example (john)"); + assert.equal(identityLabel({ string: "alice", displayName: "alice" }), "alice"); + assert.equal(identityLabel({ string: "wheel", displayName: "", isGroup: true }), "Group: wheel"); + assert.equal(identityLabel({ id: 0 }), "0"); + assert.equal(identityLabel(null), ""); +}); + +test("identity choices preserve order and distinguish accounts with identical names", () => { + assert.deepEqual(identityOptions([ + { string: "alice", displayName: "Alice" }, + { string: "admin", displayName: "Alice" } + ]), [ + { value: 0, label: "Alice (alice)" }, + { value: 1, label: "Alice (admin)" } + ]); + assert.deepEqual(identityOptions(null), []); +}); diff --git a/tests/quickshell/remote-server.test.cjs b/tests/quickshell/remote-server.test.cjs new file mode 100644 index 00000000..96807954 --- /dev/null +++ b/tests/quickshell/remote-server.test.cjs @@ -0,0 +1,150 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const H = require("../../roles/desktop/files/quickshell/Common/RemoteServerHelpers.js"); +const S = require("../../roles/desktop/files/quickshell/Common/SettingsHelpers.js"); + +function sample() { + return { version: 1, boot: "a", uptime: 1200, cpu: 25, perCore: [20, 30], load: [1, 2, 3], + meta: { hostname: "beast", os: "Linux", defaultInterface: "eth0" }, + memory: { total: 16 * 1024 ** 3, available: 12 * 1024 ** 3, used: 4 * 1024 ** 3 }, + storage: [{ mount: "/", total: 1000, free: 400, percent: 60 }], + temperatures: [{ name: "CPU", celsius: 54 }], + network: [{ name: "eth0", addresses: ["10.10.0.7"], rx: 1024, tx: 2048 }, + { name: "docker0", addresses: ["172.17.0.1"], rx: 999999, tx: 999999 }] }; +} +test("remote widget migrates as disabled and preserves user options", () => { + const old = S.defaults(); + old.mods.right = old.mods.right.filter(m => m.id !== "remote"); + delete old.modOpts.remote; + const migrated = S.merge(old); + assert.equal(migrated.mods.right.find(m => m.id === "remote").on, false); + assert.equal(migrated.modOpts.remote.host, ""); + assert.equal(migrated.modOpts.remote.metric, "cpu"); + const normalized = S.normalizeModOpts({ remote: { host: " john@10.10.0.7 ", label: "The Beast", + metric: "diskFree", mount: "/data", interface: "eth0", pollSecs: 999 } }).remote; + assert.equal(normalized.host, "john@10.10.0.7"); + assert.equal(normalized.metric, "diskFree"); + assert.equal(normalized.mount, "/data"); + assert.equal(normalized.pollSecs, 60); + assert.equal(S.normalizeModOpts({ remote: { metric: "bogus" } }).remote.metric, "cpu"); +}); +test("every menu metric produces the intended unit and missing readings stay unknown", () => { + const s = sample(); + const options = { mount: "/", interface: "eth0" }; + const expected = { cpu: "25%", load: "1.00", memory: "25%", memoryUsed: "4.0 GiB", + memoryFree: "12.0 GiB", disk: "60%", diskFree: "400 B", rx: "↓ 1.0 KiB/s", + tx: "↑ 2.0 KiB/s", temperature: "54°C" }; + for (const { value } of H.METRICS) { + assert.equal(H.metric(s, { ...options, metric: value }), expected[value]); + assert.equal(H.metric(null, { ...options, metric: value }), "—"); + } + assert.equal(H.metric(s, { metric: "disk", mount: "/missing" }), "—"); + assert.equal(H.metric({ ...s, cpu: null }, { metric: "cpu" }), "—"); + assert.equal(H.temperature({ ...s, temperatures: [] }), null); +}); +test("network automatic uses the default route and explicit missing interfaces stay missing", () => { + const s = sample(); + assert.equal(H.network(s, "").name, "eth0"); + assert.equal(H.network(s, "docker0").name, "docker0"); + assert.equal(H.network(s, "missing"), null); + delete s.meta.defaultInterface; + assert.equal(H.network(s, "").name, "docker0"); +}); +test("malformed data is rejected while unavailable first-sample counters are valid", () => { + const s = sample(); + assert.equal(H.validSample(s), true); + s.cpu = null; s.perCore = [null]; s.network[0].rx = null; + assert.equal(H.validSample(s), true); + for (const value of [null, {}, { ...s, version: 2 }, { ...s, cpu: Infinity }, + { ...s, network: [{}] }, { ...s, storage: [{}] }, { ...s, load: [] }]) + assert.equal(H.validSample(value), false); +}); +test("history is bounded and reboot or long disconnection starts a new series", () => { + const s = sample(); + let history = []; + for (let at = 0; at <= 700000; at += 2000) history = H.historyAppend(history, s, at); + assert.equal(history.length, 300); + assert.ok(history.every(p => 700000 - p.at < 600000)); + assert.equal(H.historyAppend(history, { ...s, boot: "b" }, 702000).length, 1); + assert.equal(H.historyAppend(history, s, 900000).length, 1); + assert.equal(history[0].network[0].name, "eth0"); +}); +test("readings warn at their thresholds and load is judged per logical CPU", () => { + assert.equal(H.level(null), "unknown"); + assert.equal(H.level(84), "ok"); + assert.equal(H.level(85), "warn"); + assert.equal(H.level(95), "critical"); + assert.equal(H.level(74, "celsius"), "ok"); + assert.equal(H.level(75, "celsius"), "warn"); + assert.equal(H.level(90, "celsius"), "critical"); + const s = sample(); + s.meta.cores = 1; + assert.equal(H.metricLevel(s, { metric: "load" }), "critical"); + s.meta.cores = 2; + assert.equal(H.metricLevel(s, { metric: "load" }), "ok"); + assert.equal(H.metricLevel(s, { metric: "cpu" }), "ok"); + assert.equal(H.metricLevel(s, { metric: "diskFree", mount: "/" }), "ok"); + assert.equal(H.metricLevel(s, { metric: "rx" }), "ok"); + assert.equal(H.metricLevel(null, { metric: "cpu" }), "unknown"); +}); +test("the dashboard opens on the reading the menubar shows", () => { + const views = H.VIEWS.map(v => v.value); + for (const { value } of H.METRICS) + assert.ok(views.includes(H.viewFor(value)), value); + assert.equal(H.viewFor("memoryFree"), "memory"); + assert.equal(H.viewFor("diskFree"), "storage"); + assert.equal(H.viewFor("temperature"), "temperature"); + assert.equal(H.viewFor("rx"), "cpu"); +}); +test("chart windows grow with history and rate scales use whole binary steps", () => { + assert.equal(H.chartSpan(0), 120000); + assert.equal(H.chartSpan(121000), 180000); + assert.equal(H.chartSpan(3600000), 600000); + assert.equal(H.chartCeiling(null), 1024); + assert.equal(H.chartCeiling(530000), 1048576); + assert.equal(H.chartCeiling(1048576), 1048576); + assert.deepEqual(H.summary([{ value: 10 }, { value: null }, { value: 30 }]), + { average: 20, peak: 30, low: 10 }); + assert.equal(H.summary([{ value: null }]), null); +}); +test("filesystems collapse bind mounts, drop firmware stores and lead with the selection", () => { + const s = sample(); + s.storage = [ + { device: "tank/ROOT", type: "zfs", mount: "/", total: 10, free: 5, percent: 50 }, + { device: "efivarfs", type: "efivarfs", mount: "/sys/firmware/efi/efivars", total: 1, free: 0, percent: 78 }, + { device: "/dev/loop0", type: "btrfs", mount: "/var/lib/incus/devices/a/config.mount", total: 4, free: 1, percent: 79 }, + { device: "/dev/loop0", type: "btrfs", mount: "/var/lib/incus/pool", total: 4, free: 1, percent: 79 } + ]; + assert.deepEqual(H.storageRows(s, "/").map(d => d.mount), ["/", "/var/lib/incus/pool"]); + assert.deepEqual(H.storageRows(s, "/var/lib/incus/devices/a/config.mount").map(d => d.mount), + ["/var/lib/incus/devices/a/config.mount", "/"]); + assert.deepEqual(H.storageRows(null, "/"), []); +}); +test("repeated sensor names identify their chip and sort hottest first", () => { + const s = sample(); + s.temperatures = [{ name: "nvme · Composite", celsius: 30 }, { name: "k10temp · Tctl", celsius: 58 }, + { name: "nvme · Composite", celsius: 27 }]; + assert.deepEqual(H.sensorRows(s).map(t => t.name), + ["k10temp · Tctl", "nvme 1 · Composite", "nvme 2 · Composite"]); +}); +test("interfaces list the default route, then addressed, then busy links", () => { + const s = sample(); + s.network.push({ name: "veth0", addresses: [], rx: 5000000, tx: 0 }); + assert.deepEqual(H.interfaceRows(s).map(n => n.name), ["eth0", "docker0", "veth0"]); +}); +test("relative ages and compact rates stay short", () => { + assert.equal(H.ago(2000), "just now"); + assert.equal(H.ago(42000), "42s ago"); + assert.equal(H.ago(125000), "2 min ago"); + assert.equal(H.ago(7200000), "2 h ago"); + assert.equal(H.ago(null), ""); + assert.equal(H.compactRate(900), "900 B/s"); + assert.equal(H.compactRate(425984), "416 K/s"); + assert.equal(H.compactRate(1300000), "1.2 M/s"); + assert.equal(H.compactRate(null), "—"); +}); +test("history keeps the hottest reading for the temperature chart", () => { + const [point] = H.historyAppend([], sample(), 1000); + assert.equal(point.temperature, 54); + assert.equal(point.cpu, 25); +}); diff --git a/tests/quickshell/settings-helpers.test.cjs b/tests/quickshell/settings-helpers.test.cjs index deee4d97..9f577ed4 100644 --- a/tests/quickshell/settings-helpers.test.cjs +++ b/tests/quickshell/settings-helpers.test.cjs @@ -61,7 +61,7 @@ test("defaults carry the design values", () => { ["indicators", "clock", "weather", "notes"]); assert.equal(d.mods.center.find(m => m.id === "notes").on, true); assert.deepEqual(d.mods.right.map(m => m.id), - ["modelusage", "updates", "gh", "t3", "hermes", "tray", "notifications", + ["modelusage", "updates", "gh", "t3", "hermes", "remote", "tray", "notifications", "vol", "wifi", "bt", "batt", "control"]); assert.equal(d.mods.left[1].on, true, "the media chip hides itself when nothing plays"); assert.equal(d.mods.right.find(m => m.id === "bt").on, true, @@ -81,7 +81,7 @@ test("defaults carry the design values", () => { assert.ok([...d.mods.left, ...d.mods.center, ...d.mods.right] .every(module => module.detail === "auto")); assert.deepEqual(Object.keys(d.modOpts), - ["ws", "media", "indicators", "clock", "weather", "notes", "t3", "hermes", "modelusage", + ["ws", "media", "indicators", "clock", "remote", "weather", "notes", "t3", "hermes", "modelusage", "gh", "updates", "tray", "notifications", "vol", "batt"]); assert.equal(d.modOpts.ws.minSlots, 5); assert.equal(d.modOpts.ws.style, "numbers"); @@ -566,6 +566,18 @@ test("schema-6 migration preserves module order and adds clock-side indicators", assert.equal(migrated.mods.right[0].id, "batt"); }); +test("missing font preferences use the current default across first-run and legacy settings", () => { + for (const raw of [null, {}, { wall: "mountain.jpg" }, { v: 3 }, { v: 6 }, { v: H.VERSION }]) { + const merged = H.merge(raw); + assert.equal(merged.font, "mono", JSON.stringify(raw)); + assert.equal(H.FONT_CHOICES.find(choice => choice.id === merged.font).family, + "JetBrainsMono Nerd Font"); + } + for (const font of H.FONT_IDS) + assert.equal(H.merge({ v: H.VERSION, font }).font, font, + "an explicitly saved font remains selectable"); +}); + test("schema-7 adopts Google Sans only from the previous default", () => { assert.equal(H.merge({ v: 6, font: "urbanist" }).font, "google", "the old untouched default follows the softer typography pass"); @@ -596,7 +608,7 @@ test("normalizeMods appends ids missing from the file at their default column", assert.deepEqual(next.center.map(m => m.id), ["indicators", "clock", "weather", "notes"]); assert.deepEqual(next.right.map(m => m.id), - ["modelusage", "updates", "gh", "t3", "hermes", "tray", "notifications", + ["modelusage", "updates", "gh", "t3", "hermes", "remote", "tray", "notifications", "wifi", "bt", "batt", "control"]); assert.ok(next.right.some(m => m.id === "bt" && m.on === true), "appended module keeps its default enable flag"); @@ -620,7 +632,7 @@ test("a schema-3 file adopts the redesign only where it was left untouched", () assert.equal(untouched.barRadius, 11); assert.equal(untouched.gap, 8); assert.equal(untouched.accent, "#d3d283"); - assert.equal(untouched.font, "google"); + assert.equal(untouched.font, "mono"); assert.equal(untouched.osd, "bottom"); assert.equal(untouched.modOpts.ws.style, "numbers"); assert.equal(untouched.modOpts.media.maxWidth, 180); @@ -778,7 +790,7 @@ test("schema-11 inserts notifications before the first right-side status widget" const migrated = H.merge({ v: 10, mods: raw }).mods.right; assert.deepEqual(migrated.map(mod => mod.id), ["modelusage", "updates", "tray", "gh", "notifications", "wifi", "t3", - "hermes", "vol", "bt", "batt", "control"]); + "hermes", "vol", "bt", "batt", "remote", "control"]); }); test("schema-11 appends notifications on the right when its status widgets moved", () => { diff --git a/tests/quickshell/settings.test.cjs b/tests/quickshell/settings.test.cjs index ad0d69b1..2335dd70 100644 --- a/tests/quickshell/settings.test.cjs +++ b/tests/quickshell/settings.test.cjs @@ -259,7 +259,7 @@ test("the tray and the updates chip use the reorderable widget pipeline", () => const bar = read("Bar/Bar.qml"); assert.match(helpers, /"updates", "gh"/); - assert.match(helpers, /"hermes",\s*"tray"/); + assert.match(helpers, /"hermes",\s*"remote",\s*"tray"/); assert.match(catalog, /updates:\s*\{ name: "Updates"/); assert.match(catalog, /tray:\s*\{ name: "System tray"/); assert.doesNotMatch(modules, /pinnedTail|text:\s*"pinned"/); @@ -396,7 +396,7 @@ test("schema twenty-three keeps safe defaults and exposes accessibility preferen assert.match(helpers, /nightLight:\s*false/); assert.match(helpers, /idleInhibitMode:\s*"off"/); assert.match(helpers, /idleInhibitUntilMs:\s*0/); - assert.match(helpers, /"modelusage", "updates", "gh",\s*"t3", "hermes", "tray"/); + assert.match(helpers, /"modelusage", "updates", "gh",\s*"t3", "hermes", "remote", "tray"/); assert.match(helpers, /hermes:\s*\{ showLabel: true, activityDetail: "verb" \}/); assert.match(helpers, /notes:\s*\{[\s\S]*?titleProvider:\s*"off"[\s\S]*?codexModel:\s*"gpt-5\.6-luna"[\s\S]*?codexEffort:\s*"none"[\s\S]*?claudeModel:\s*"fable"[\s\S]*?claudeEffort:\s*"low"/); diff --git a/tests/quickshell/typography-scale.test.cjs b/tests/quickshell/typography-scale.test.cjs index 45cea7f9..585f3005 100644 --- a/tests/quickshell/typography-scale.test.cjs +++ b/tests/quickshell/typography-scale.test.cjs @@ -8,16 +8,21 @@ const H = load("SettingsHelpers.js"); const M = load("ShellMetrics.js"); const read = file => fs.readFileSync(path.join(shellDir, file), "utf8"); -test("Omarchy reference scale and usage roles stay distinct", () => { - const s = T.resolve(12); - assert.equal(s.clock, 52); +test("readable defaults and usage roles stay distinct", () => { + const s = T.resolve(M.calculate(H.defaults()).fontBase); + assert.equal(s.clock, 61); assert.deepEqual([s.caption, s.bodySmall, s.body, s.subtitle, s.title, - s.heading, s.display, s.displayLarge], [10, 11, 12, 13, 14, 16, 24, 28]); + s.heading, s.display, s.displayLarge], [12, 13, 14, 15, 16, 18, 28, 33]); for (const role of ["bar", "control", "navigation", "primary"]) - assert.equal(s[role], 12, role); - for (const role of ["secondary", "tooltip"]) assert.equal(s[role], 11, role); - for (const role of ["section", "metadata"]) assert.equal(s[role], 10, role); - for (const role of ["notification", "osd"]) assert.equal(s[role], 14, role); + assert.equal(s[role], 14, role); + for (const role of ["secondary", "tooltip"]) assert.equal(s[role], 13, role); + for (const role of ["section", "metadata"]) assert.equal(s[role], 12, role); + for (const role of ["notification", "osd"]) assert.equal(s[role], 16, role); + for (const invalid of [undefined, 0, -1, NaN, Infinity]) + assert.deepEqual(T.resolve(invalid), s); + // Defaults and section resets grow; an explicit saved size stays chosen. + assert.equal(H.merge({}).shellFontSize, 14); + assert.equal(H.merge({ shellFontSize: 12 }).shellFontSize, 12); }); test("native and compatibility adapters use the same resolver, without a second type scale", () => { @@ -41,6 +46,7 @@ test("type scaling follows accessibility and UI scale but not spacing density", assert.deepEqual(T.resolve(M.calculate({ ...p, interfaceDensity }).fontBase), expected); assert.equal(expected.bar, base); assert.equal(expected.title, Math.max(1, Math.round(base * 1.167))); + assert.equal(expected.heading, Math.max(1, Math.round(base * 18 / 14))); assert.equal(expected.secondary, Math.max(1, Math.round(base * 0.917))); } }); diff --git a/tests/quickshell/typography.test.cjs b/tests/quickshell/typography.test.cjs index 00715bf6..a77b472d 100644 --- a/tests/quickshell/typography.test.cjs +++ b/tests/quickshell/typography.test.cjs @@ -59,7 +59,7 @@ function contrast(a, b) { } test("semantic typography tokens retain the intended logical-pixel scale", () => { - // Reference sizes are multiplied by the common font scale. + // Compatibility aliases at an explicit 12px base; headings use 18/14. assert.deepEqual([ intToken("fontMicro"), intToken("fontTiny"), @@ -70,7 +70,7 @@ test("semantic typography tokens retain the intended logical-pixel scale", () => intToken("fontProminent"), intToken("fontDisplay"), intToken("fontHero"), - ], [10, 11, 10, 11, 12, 16, 14, 24, 28]); + ], [10, 11, 10, 11, 12, 15, 14, 24, 28]); }); test("menu typography keeps the bar's own compact metrics", () => { @@ -425,7 +425,7 @@ test("no surface paints an accent field where a chip belongs", () => { test("default body size is shared by settings controls and plugin typography", () => { const d = load("SettingsHelpers.js").defaults(); - assert.equal(d.shellFontSize, 12); + assert.equal(d.shellFontSize, 14); assert.equal(d.shellScale, 100); assert.equal(d.pluginScale, 100); // The plugin install and clone fields are FieldRows. @@ -439,7 +439,7 @@ test("default body size is shared by settings controls and plugin typography", ( assert.match(fs.readFileSync(path.join(shellDir, "Settings", "SettingsTextRow.qml"), "utf8"), /SettingsField \{/); const widgets = fs.readFileSync(path.join(shellDir, "Bar", "UserWidgets.qml"), "utf8"); assert.match(widgets, /fontSize: Theme\.typography\.bar/); - assert.equal(load("ShellMetrics.js").calculate(d).fontBase, 12); + assert.equal(load("ShellMetrics.js").calculate(d).fontBase, 14); for (const name of ["SettingsRow", "SettingsField", "SettingsAction", "PickerRow", "PillRow", "SliderRow"]) { const source = fs.readFileSync(path.join(shellDir, "Settings", name + ".qml"), "utf8"); diff --git a/tests/remote-server.py b/tests/remote-server.py new file mode 100644 index 00000000..306552d7 --- /dev/null +++ b/tests/remote-server.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +"""Unprivileged telemetry, counter semantics and SSH stream lifecycle.""" +import importlib.util +import json +import os +from pathlib import Path +import select +import signal +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch + +sys.dont_write_bytecode = True +ROOT = Path(__file__).resolve().parents[1] +SCRIPTS = ROOT / "roles/desktop/files/quickshell/scripts" + + +def load(name, filename): + spec = importlib.util.spec_from_file_location(name, SCRIPTS / filename) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +probe = load("probe", "remote_server_probe.py") +launcher = load("launcher", "remote-server.py") + + +class RemoteServerTests(unittest.TestCase): + def test_cpu_ignores_double_counted_guest_and_handles_reset(self): + ticks = probe.cpu_ticks("cpu 100 20 30 400 50 5 10 0 25 10\ncpu0 1 2 3 4 5 6 7 8 9 10") + self.assertEqual(ticks["cpu"], (615, 450)) + self.assertIsNone(probe.cpu_percent(ticks["cpu"], None)) + self.assertEqual(probe.cpu_percent((200, 100), (100, 50)), 50) + self.assertIsNone(probe.cpu_percent((100, 50), (200, 100))) + self.assertIsNone(probe.cpu_percent((100, 50), (100, 50))) + + def test_memory_uses_available_instead_of_free(self): + memory = probe.memory_info("MemTotal: 1000 kB\nMemFree: 100 kB\nMemAvailable: 400 kB\nSwapTotal: 500 kB\nSwapFree: 300 kB") + self.assertEqual(memory["used"], 600 * 1024) + self.assertEqual(memory["available"], 400 * 1024) + self.assertEqual(memory["swapUsed"], 200 * 1024) + self.assertIsNone(probe.memory_info("MemTotal: 1000 kB")["used"]) + + def test_rates_use_elapsed_time_and_do_not_spike_after_reset(self): + text = "lo: 99 0 0 0 0 0 0 0 88 0 0 0 0 0 0 0\n eth0: 2000 0 0 0 0 0 0 0 900 0 0 0 0 0 0 0" + self.assertEqual(probe.network_counters(text), {"eth0": (2000, 900)}) + self.assertEqual(probe.rate(2000, 1000, 2.5), 400) + self.assertIsNone(probe.rate(2, 1000, 2.5)) + self.assertIsNone(probe.rate(2000, None, 2.5)) + self.assertIsNone(probe.rate(2000, 1000, 0)) + + def test_storage_handles_spaces_and_failure(self): + with patch.object(probe, "run", return_value="header\n/dev/sda ext4 1000 600 350 64% /a mount\n"): + disks, error = probe.storage() + self.assertFalse(error) + self.assertEqual(disks[0]["mount"], "/a mount") + self.assertEqual(disks[0]["free"], 350) + with patch.object(probe, "run", side_effect=subprocess.TimeoutExpired("df", 3)): + self.assertEqual(probe.storage(), ([], "Filesystem statistics unavailable")) + + def test_probe_caches_slow_data_and_resets_on_new_boot(self): + files = {"/proc/stat": "cpu 100 0 0 100 0 0 0 0", "/proc/net/dev": "", + "/proc/meminfo": "MemTotal: 1000 kB\nMemAvailable: 600 kB", + "/proc/uptime": "100 100", "/proc/sys/kernel/random/boot_id": "a"} + with patch.object(probe, "read", side_effect=lambda p, *_: files[str(p)]), \ + patch.object(probe, "metadata", return_value={"addresses": {}}) as meta, \ + patch.object(probe, "storage", return_value=([], "")) as disks, \ + patch.object(probe, "temperatures", return_value=[]), \ + patch.object(probe.time, "monotonic", side_effect=[100, 102, 104, 165]): + collector = probe.Probe() + self.assertIsNone(collector.sample()["cpu"]) + files["/proc/stat"] = "cpu 130 0 0 110 0 0 0 0" + self.assertEqual(collector.sample()["cpu"], 75) + files["/proc/sys/kernel/random/boot_id"] = "b" + self.assertIsNone(collector.sample()["cpu"]) + collector.sample() + self.assertEqual(meta.call_count, 2) + self.assertEqual(disks.call_count, 2) + + def test_ssh_destination_is_data_and_host_verification_is_required(self): + for host in ["john@10.10.0.7", "beast", "john@[2001:db8::1]"]: + command = launcher.command(host) + self.assertEqual(command[-2], host) + for flag in ["BatchMode=yes", "StrictHostKeyChecking=yes", "ControlPath=none", "ClearAllForwardings=yes", "ForwardAgent=no"]: + self.assertIn(flag, command) + for host in ["-oProxyCommand=bad", "host;touch /tmp/no", "$(id)", "a b", "a\nb", ""]: + with self.assertRaises(ValueError): + launcher.command(host) + + def test_stream_controls_and_eof_stop_the_transport(self): + # A fake SSH executable runs the exact transmitted, quoted program + # locally. This covers delivery, framing and lifetime without a host. + with tempfile.TemporaryDirectory(prefix="cybexos-remote-test-") as directory: + fake = Path(directory) / "ssh" + fake.write_text("#!/usr/bin/env python3\nimport os,sys\nos.execl('/bin/sh','sh','-c',sys.argv[-1])\n") + fake.chmod(0o700) + proc = subprocess.Popen([sys.executable, "-B", str(SCRIPTS / "remote-server.py"), "fixture"], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + text=True, start_new_session=True, + env={**os.environ, "PATH": directory + ":" + os.environ["PATH"]}) + try: + proc.stdin.write('not json\n{"interval":2}\n') + proc.stdin.flush() + samples = [] + for _ in range(2): + self.assertTrue(select.select([proc.stdout], [], [], 15)[0], "sample timed out") + samples.append(json.loads(proc.stdout.readline())) + self.assertEqual(samples[0]["version"], 1) + self.assertIsNone(samples[0]["cpu"]) + self.assertIsInstance(samples[1]["cpu"], (int, float)) + proc.stdin.close() + self.assertEqual(proc.wait(timeout=5), 0, proc.stderr.read()) + finally: + if proc.poll() is None: + os.killpg(proc.pid, signal.SIGTERM) + proc.wait(timeout=5) + for stream in (proc.stdin, proc.stdout, proc.stderr): + stream.close() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/run b/tests/run index 1c243bf0..e780b408 100755 --- a/tests/run +++ b/tests/run @@ -481,6 +481,7 @@ python_fixtures=( hermes-bridge hermes-webui-auth hermes-remote-runtime + remote-server ) python_fixture_limit=300 diff --git a/tests/verify-system b/tests/verify-system index 66d6c981..d5a325fc 100755 --- a/tests/verify-system +++ b/tests/verify-system @@ -622,11 +622,8 @@ check 'Walker command is absent' bash -c '! command -v walker' check 'Elephant command is absent' bash -c '! command -v elephant' check 'Walker user unit is absent' bash -c '! systemctl --user list-unit-files walker.service --no-legend 2>/dev/null | grep -q walker' check 'Elephant user unit is absent' bash -c '! systemctl --user list-unit-files elephant.service --no-legend 2>/dev/null | grep -q elephant' -if command_exists hyprpolkitagent || [[ -x /usr/libexec/hyprpolkitagent ]]; then - pass 'Hyprland Polkit agent executable' -else - fail 'missing Hyprland Polkit agent executable' -fi +check 'Quickshell Polkit dialog is installed' test -r \ + "$HOME/.local/share/cybexos/runtime/quickshell/PolkitWindow.qml" if feature_enabled developer_tools && command_exists node; then major=$(node -p 'process.versions.node.split(".")[0]' 2>/dev/null || true) @@ -739,7 +736,7 @@ if [[ ${XDG_CURRENT_DESKTOP:-} == *Hyprland* ]] && command_exists hyprctl && hyp errors=$(hyprctl configerrors 2>/dev/null || true) [[ -z $errors || $errors == 'no errors' ]] && pass 'Hyprland has no config errors' || fail "Hyprland config errors: $errors" check 'retired Noctalia process is absent' bash -c '! pgrep -x noctalia' - user_units=(quickshell hypridle hyprpolkitagent) + user_units=(quickshell hypridle) # The external-monitor watcher is installed only on the gated XPS hardware. $xps_2026 && user_units+=(external-monitor-toggle) feature_enabled developer_tools && user_units+=(voxtype) @@ -761,6 +758,10 @@ if [[ ${XDG_CURRENT_DESKTOP:-} == *Hyprland* ]] && command_exists hyprctl && hyp if $quickshell_live_safe; then check 'Quickshell launcher IPC responds' qs_live_wait_ipc "$QS_LIVE_TIMEOUT" launcher close check 'Quickshell settings IPC responds' qs_live_wait_ipc "$QS_LIVE_TIMEOUT" settings close + polkit_status=$(qs_live_wait_ipc "$QS_LIVE_TIMEOUT" polkit status || true) + check 'Quickshell Polkit agent is registered' jq -e '.registered == true' <<<"$polkit_status" + check 'standalone Polkit agent is inactive' bash -c \ + '! systemctl --user is-active --quiet hyprpolkitagent.service' else fail 'Quickshell IPC checks skipped because live process ownership is ambiguous' fi diff --git a/tests/vm/assert-converged b/tests/vm/assert-converged index 2ad76f62..cdd38204 100755 --- a/tests/vm/assert-converged +++ b/tests/vm/assert-converged @@ -37,7 +37,7 @@ grep -q '^performance=xps-performance$' /etc/tuned/ppd.conf && fail 'the XPS performance mapping was installed on generic hardware' user_id=$(id -u fedora) -for unit in quickshell.service hypridle.service hyprpolkitagent.service; do +for unit in quickshell.service hypridle.service; do sudo -u fedora env XDG_RUNTIME_DIR="/run/user/$user_id" \ systemctl --user is-enabled --quiet "$unit" || fail "$unit is not enabled for fedora"