Compare commits
52
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d87f4b6d6a | ||
|
|
9fc1fdbbbb | ||
|
|
dd8d93c387 | ||
|
|
b8832a0174 | ||
|
|
3f07d25858 | ||
|
|
df1dcdfad5 | ||
|
|
91f7273f41 | ||
|
|
f9eba1e8c5 | ||
|
|
b1edfb6fe4 | ||
|
|
1afa41526a | ||
|
|
08b16fa03f | ||
|
|
ccf46c40ef | ||
|
|
60a321e6f4 | ||
|
|
7d5c65be03 | ||
|
|
f8e7512e01 | ||
|
|
b7ce2c6e43 | ||
|
|
1f62256024 | ||
|
|
4ef2f01baa | ||
|
|
6042c0b1c0 | ||
|
|
0c4d132ee1 | ||
|
|
faa9a00716 | ||
|
|
27af4fd443 | ||
|
|
b16834fea6 | ||
|
|
8bfae70284 | ||
|
|
4279da999a | ||
|
|
54b8a82803 | ||
|
|
2e8292599a | ||
|
|
5562323eb8 | ||
|
|
2cc109f5e1 | ||
|
|
52d896b054 | ||
|
|
e4409ed6aa | ||
|
|
f0321432b4 | ||
|
|
d855a26bd9 | ||
|
|
607acb0a2d | ||
|
|
9b2fa80ab7 | ||
|
|
8a0a09dbb4 | ||
|
|
816ea68f9a | ||
|
|
3238623934 | ||
|
|
21aa9223df | ||
|
|
e2e03252e4 | ||
|
|
0ca83f74c7 | ||
|
|
d58c431199 | ||
|
|
3ff414fb1b | ||
|
|
77f8102148 | ||
|
|
0fd59c59e3 | ||
|
|
b1f9891849 | ||
|
|
6026bf308b | ||
|
|
24d8bfd641 | ||
|
|
14529c011f | ||
|
|
7378c18ab9 | ||
|
|
9ce7040b91 | ||
|
|
af0ba13573 |
@@ -16,3 +16,8 @@ __pycache__/
|
|||||||
|
|
||||||
# Generated from the colour scheme; machine state, not configuration.
|
# Generated from the colour scheme; machine state, not configuration.
|
||||||
/config/dot/kitty/current-theme.conf
|
/config/dot/kitty/current-theme.conf
|
||||||
|
|
||||||
|
# Generated from the colour scheme; machine state, not configuration.
|
||||||
|
/config/dot/gtk-3.0/settings.ini
|
||||||
|
/config/dot/gtk-4.0/settings.ini
|
||||||
|
/config/dot/tmux/current-theme.conf
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ same Tokyo Night Moon palette.
|
|||||||
| Piece | What it is |
|
| Piece | What it is |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `config/dot/hypr/` | Compositor config. **Lua, not hyprlang** — see its README |
|
| `config/dot/hypr/` | Compositor config. **Lua, not hyprlang** — see its README |
|
||||||
| `config/dot/quickshell/` | The shell: bar, dock, Continuum overview, Panama Settings, Screen Intelligence, focus sessions, quick settings, notifications, screenshot UI |
|
| `config/dot/quickshell/` | The shell: bar, dock, Continuum overview, Settings, Screen Intelligence, focus sessions, quick settings, notifications, screenshot UI |
|
||||||
| `config/dot/vicinae/` | Raycast-style launcher, themed |
|
| `config/dot/vicinae/` | Raycast-style launcher, themed |
|
||||||
| `config/dot/uwsm/` | Session environment (see the uwsm caveat in the hypr README) |
|
| `config/dot/uwsm/` | Session environment (see the uwsm caveat in the hypr README) |
|
||||||
| `config/dot/wofi/` | Fallback launcher, in case the shell fails to start |
|
| `config/dot/wofi/` | Fallback launcher, in case the shell fails to start |
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# Tokyo Night Day for btop, the light counterpart to tokyonight-moon.
|
||||||
|
#
|
||||||
|
# btop ships no Tokyo Night light variant at all, and the nearest stock light
|
||||||
|
# theme (flat-remix-light) is a different palette that happens to have a similar
|
||||||
|
# background. Same colours as kitty/themes/tokyonight-day.conf so the terminal
|
||||||
|
# and what runs inside it cannot disagree.
|
||||||
|
#
|
||||||
|
# Gradients keep the same low -> middle -> high meaning as the dark theme, using
|
||||||
|
# Day's darker, more saturated accents: the light versions of these hues are too
|
||||||
|
# faint to read as a filled meter.
|
||||||
|
|
||||||
|
theme[main_bg]="#e1e2e7"
|
||||||
|
theme[main_fg]="#3760bf"
|
||||||
|
theme[title]="#3760bf"
|
||||||
|
theme[hi_fg]="#2e7de9"
|
||||||
|
theme[selected_bg]="#c4c8da"
|
||||||
|
theme[selected_fg]="#2e7de9"
|
||||||
|
theme[inactive_fg]="#7079a8"
|
||||||
|
theme[proc_misc]="#587539"
|
||||||
|
|
||||||
|
theme[cpu_box]="#a8aecb"
|
||||||
|
theme[mem_box]="#a8aecb"
|
||||||
|
theme[net_box]="#a8aecb"
|
||||||
|
theme[proc_box]="#a8aecb"
|
||||||
|
theme[div_line]="#c4c8da"
|
||||||
|
|
||||||
|
theme[temp_start]="#2e7de9"
|
||||||
|
theme[temp_mid]="#8c6c3e"
|
||||||
|
theme[temp_end]="#f52a65"
|
||||||
|
|
||||||
|
theme[cpu_start]="#2e7de9"
|
||||||
|
theme[cpu_mid]="#9854f1"
|
||||||
|
theme[cpu_end]="#f52a65"
|
||||||
|
|
||||||
|
theme[free_start]="#c4c8da"
|
||||||
|
theme[free_mid]="#007197"
|
||||||
|
theme[free_end]="#2e7de9"
|
||||||
|
|
||||||
|
theme[cached_start]="#007197"
|
||||||
|
theme[cached_mid]="#2e7de9"
|
||||||
|
theme[cached_end]="#9854f1"
|
||||||
|
|
||||||
|
theme[available_start]="#8c6c3e"
|
||||||
|
theme[available_mid]="#b15c00"
|
||||||
|
theme[available_end]="#f52a65"
|
||||||
|
|
||||||
|
theme[used_start]="#587539"
|
||||||
|
theme[used_mid]="#8c6c3e"
|
||||||
|
theme[used_end]="#f52a65"
|
||||||
|
|
||||||
|
theme[download_start]="#c4c8da"
|
||||||
|
theme[download_mid]="#2e7de9"
|
||||||
|
theme[download_end]="#007197"
|
||||||
|
|
||||||
|
theme[upload_start]="#c4c8da"
|
||||||
|
theme[upload_mid]="#9854f1"
|
||||||
|
theme[upload_end]="#7847bd"
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# Tokyo Night Moon for btop, matched to Panama's palette.
|
||||||
|
#
|
||||||
|
# btop ships a "tokyo-night" theme, but it is the Night variant (#1a1b26). The
|
||||||
|
# rest of this desktop is Moon (#222436), and two Tokyo Nights side by side read
|
||||||
|
# as a mistake rather than as a choice.
|
||||||
|
#
|
||||||
|
# The *_start/_mid/_end triples are gradients btop draws meters with: low,
|
||||||
|
# middle, and high. They run blue -> yellow -> red so a saturated resource is
|
||||||
|
# obvious at a glance without reading the number.
|
||||||
|
|
||||||
|
theme[main_bg]="#222436"
|
||||||
|
theme[main_fg]="#c8d3f5"
|
||||||
|
theme[title]="#c8d3f5"
|
||||||
|
theme[hi_fg]="#82aaff"
|
||||||
|
theme[selected_bg]="#3b4261"
|
||||||
|
theme[selected_fg]="#82aaff"
|
||||||
|
theme[inactive_fg]="#636da6"
|
||||||
|
theme[proc_misc]="#a5e8b5"
|
||||||
|
|
||||||
|
theme[cpu_box]="#4d5685"
|
||||||
|
theme[mem_box]="#4d5685"
|
||||||
|
theme[net_box]="#4d5685"
|
||||||
|
theme[proc_box]="#4d5685"
|
||||||
|
theme[div_line]="#3b4261"
|
||||||
|
|
||||||
|
theme[temp_start]="#82aaff"
|
||||||
|
theme[temp_mid]="#ffc777"
|
||||||
|
theme[temp_end]="#ff757f"
|
||||||
|
|
||||||
|
theme[cpu_start]="#82aaff"
|
||||||
|
theme[cpu_mid]="#c099ff"
|
||||||
|
theme[cpu_end]="#ff757f"
|
||||||
|
|
||||||
|
theme[free_start]="#3b4261"
|
||||||
|
theme[free_mid]="#589ed7"
|
||||||
|
theme[free_end]="#86e1fc"
|
||||||
|
|
||||||
|
theme[cached_start]="#86e1fc"
|
||||||
|
theme[cached_mid]="#82aaff"
|
||||||
|
theme[cached_end]="#c099ff"
|
||||||
|
|
||||||
|
theme[available_start]="#ffc777"
|
||||||
|
theme[available_mid]="#ff966c"
|
||||||
|
theme[available_end]="#ff757f"
|
||||||
|
|
||||||
|
theme[used_start]="#a5e8b5"
|
||||||
|
theme[used_mid]="#ffc777"
|
||||||
|
theme[used_end]="#ff757f"
|
||||||
|
|
||||||
|
theme[download_start]="#3b4261"
|
||||||
|
theme[download_mid]="#82aaff"
|
||||||
|
theme[download_end]="#86e1fc"
|
||||||
|
|
||||||
|
theme[upload_start]="#3b4261"
|
||||||
|
theme[upload_mid]="#c099ff"
|
||||||
|
theme[upload_end]="#fca7ea"
|
||||||
@@ -1,3 +1,12 @@
|
|||||||
|
# GENERATED FILE -- edit settings.ini.template instead.
|
||||||
|
#
|
||||||
|
# The theme name and dark preference below follow Panama's colour
|
||||||
|
# scheme, so this file is regenerated on every switch and is not
|
||||||
|
# committed. Under GNOME, gnome-settings-daemon publishes these over
|
||||||
|
# XSETTINGS and this file is ignored; under Hyprland there is no
|
||||||
|
# settings daemon, so for GTK3 it is authoritative -- which is why it
|
||||||
|
# has to change with the scheme rather than being pinned to dark.
|
||||||
|
#
|
||||||
[Settings]
|
[Settings]
|
||||||
# These values must match `gsettings get org.gnome.desktop.interface ...`.
|
# These values must match `gsettings get org.gnome.desktop.interface ...`.
|
||||||
#
|
#
|
||||||
@@ -6,12 +15,12 @@
|
|||||||
# this file becomes authoritative for GTK3 — which is why it previously named
|
# this file becomes authoritative for GTK3 — which is why it previously named
|
||||||
# themes that aren't installed (Tahoe-Dark, WhiteSur-cursors) without anything
|
# themes that aren't installed (Tahoe-Dark, WhiteSur-cursors) without anything
|
||||||
# appearing broken.
|
# appearing broken.
|
||||||
gtk-theme-name=adw-gtk3-dark
|
gtk-theme-name=@GTK_THEME@
|
||||||
gtk-icon-theme-name=Adwaita
|
gtk-icon-theme-name=Adwaita
|
||||||
gtk-font-name=Adwaita Sans 11
|
gtk-font-name=Adwaita Sans 11
|
||||||
gtk-cursor-theme-name=oreo_blue_cursors
|
gtk-cursor-theme-name=oreo_blue_cursors
|
||||||
gtk-cursor-theme-size=24
|
gtk-cursor-theme-size=24
|
||||||
gtk-application-prefer-dark-theme=1
|
gtk-application-prefer-dark-theme=@PREFER_DARK@
|
||||||
|
|
||||||
gtk-toolbar-style=GTK_TOOLBAR_ICONS
|
gtk-toolbar-style=GTK_TOOLBAR_ICONS
|
||||||
gtk-toolbar-icon-size=GTK_ICON_SIZE_LARGE_TOOLBAR
|
gtk-toolbar-icon-size=GTK_ICON_SIZE_LARGE_TOOLBAR
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
[Settings]
|
|
||||||
# libadwaita apps normally take their dark preference from the
|
|
||||||
# org.freedesktop.appearance portal (served by xdg-desktop-portal-gtk, which
|
|
||||||
# reads gsettings). This file is the fallback for plain GTK4 apps and for the
|
|
||||||
# window before the portal answers.
|
|
||||||
gtk-application-prefer-dark-theme=1
|
|
||||||
gtk-theme-name=adw-gtk3-dark
|
|
||||||
gtk-icon-theme-name=Adwaita
|
|
||||||
gtk-font-name=Adwaita Sans 11
|
|
||||||
gtk-cursor-theme-name=oreo_blue_cursors
|
|
||||||
gtk-cursor-theme-size=24
|
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# GENERATED FILE -- edit settings.ini.template instead.
|
||||||
|
#
|
||||||
|
# The theme name and dark preference below follow Panama's colour
|
||||||
|
# scheme, so this file is regenerated on every switch and is not
|
||||||
|
# committed. Under GNOME, gnome-settings-daemon publishes these over
|
||||||
|
# XSETTINGS and this file is ignored; under Hyprland there is no
|
||||||
|
# settings daemon, so for GTK3 it is authoritative -- which is why it
|
||||||
|
# has to change with the scheme rather than being pinned to dark.
|
||||||
|
#
|
||||||
|
[Settings]
|
||||||
|
# libadwaita apps normally take their dark preference from the
|
||||||
|
# org.freedesktop.appearance portal (served by xdg-desktop-portal-gtk, which
|
||||||
|
# reads gsettings). This file is the fallback for plain GTK4 apps and for the
|
||||||
|
# window before the portal answers.
|
||||||
|
gtk-application-prefer-dark-theme=@PREFER_DARK@
|
||||||
|
gtk-theme-name=@GTK_THEME@
|
||||||
|
gtk-icon-theme-name=Adwaita
|
||||||
|
gtk-font-name=Adwaita Sans 11
|
||||||
|
gtk-cursor-theme-name=oreo_blue_cursors
|
||||||
|
gtk-cursor-theme-size=24
|
||||||
@@ -36,7 +36,32 @@ Last live audit: 2026-08-17, Fedora 44, Hyprland 0.56.2, Quickshell 0.3.0.
|
|||||||
| Removable media | udiskie plus udisks notifications | Live |
|
| Removable media | udiskie plus udisks notifications | Live |
|
||||||
| Autostart apps | Nextcloud, Bitwarden, and RustDesk system service/tray | Live |
|
| Autostart apps | Nextcloud, Bitwarden, and RustDesk system service/tray | Live |
|
||||||
| Printer administration | CUPS with the `system-config-printer` graphical interface | Live |
|
| Printer administration | CUPS with the `system-config-printer` graphical interface | Live |
|
||||||
| System settings | Panama Settings for display policy, appearance, desktop, sound, focus, shortcuts, and services; labelled GNOME hardware/account handoffs | Live |
|
| System settings | The Settings app for display policy, appearance, desktop, sound, focus, shortcuts, and services; labelled GNOME hardware/account handoffs | Live |
|
||||||
|
| System health and recovery | Settings → System Health, `Panama: Check System Health` in Vicinae, a degraded-only bar indicator, redacted reports, and bounded Panama-owned repairs | Live |
|
||||||
|
|
||||||
|
## System health and recovery
|
||||||
|
|
||||||
|
Panama stays silent while the desktop is healthy. A compact bar indicator
|
||||||
|
appears only for actionable warnings or errors and opens the same **System
|
||||||
|
Health** page available from Settings and the Vicinae command **Panama: Check
|
||||||
|
System Health**. The terminal summary is available with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
~/.config/quickshell/scripts/panama-doctor --summary
|
||||||
|
```
|
||||||
|
|
||||||
|
The doctor reports authored, redacted observations about Panama-owned services,
|
||||||
|
tools, links, and configured integrations. It does not read secrets, clipboard
|
||||||
|
or notification contents, calendar events, SSIDs, or device addresses. Repairs
|
||||||
|
are a small allow-list: Panama user services, Panama-owned links and launcher
|
||||||
|
commands, duplicate Panama Caffeine inhibitors, and a confirmed shell restart.
|
||||||
|
They never install packages, invoke `sudo`, delete user data, or rewrite
|
||||||
|
arbitrary configuration.
|
||||||
|
|
||||||
|
Generic Fedora configuration remains with the system tools that own it. The
|
||||||
|
final System Health card hands network settings, users, sharing, colour
|
||||||
|
profiles, and digital wellbeing to their exact GNOME Settings panels rather
|
||||||
|
than presenting inert Hyprland controls.
|
||||||
|
|
||||||
## GNOME extension migration
|
## GNOME extension migration
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ Don't "fix" them.
|
|||||||
| File | Contents |
|
| File | Contents |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `hyprland.lua` | Entry point. Each `require()` is its own error scope |
|
| `hyprland.lua` | Entry point. Each `require()` is its own error scope |
|
||||||
| `prefs.lua` | Reads the settings file Panama Settings writes. See below |
|
| `prefs.lua` | Reads the settings file the Settings app writes. See below |
|
||||||
| `env.lua` | Environment. Note the uwsm caveat below |
|
| `env.lua` | Environment. Note the uwsm caveat below |
|
||||||
| `monitors.lua` | DP-2 geometry, scaling, and the HDR decision |
|
| `monitors.lua` | DP-2 geometry, scaling, and the HDR decision |
|
||||||
| `looks.lua` | Colours, blur, glow, shadows, animations, VRR, scanout |
|
| `looks.lua` | Colours, blur, glow, shadows, animations, VRR, scanout |
|
||||||
@@ -59,7 +59,7 @@ generated elsewhere:
|
|||||||
`quickshell/scripts/panama-idle` regenerates the hypridle config from the
|
`quickshell/scripts/panama-idle` regenerates the hypridle config from the
|
||||||
settings store and restarts the daemon. `hypridle.conf` in this directory
|
settings store and restarts the daemon. `hypridle.conf` in this directory
|
||||||
remains the shipped default and is what runs when the drop-in is not installed;
|
remains the shipped default and is what runs when the drop-in is not installed;
|
||||||
Panama Settings shows which of the two states you are in rather than presenting
|
The Settings app shows which of the two states you are in rather than presenting
|
||||||
controls that quietly do nothing.
|
controls that quietly do nothing.
|
||||||
|
|
||||||
Remove the drop-in and go back to the shipped config with:
|
Remove the drop-in and go back to the shipped config with:
|
||||||
@@ -77,7 +77,7 @@ relationship is:
|
|||||||
`prefs.get("key", <shipped value>)`, so the config still works standalone with
|
`prefs.get("key", <shipped value>)`, so the config still works standalone with
|
||||||
no settings file at all.
|
no settings file at all.
|
||||||
- **The JSON is the truth.** Hyprland and Quickshell both read it.
|
- **The JSON is the truth.** Hyprland and Quickshell both read it.
|
||||||
- **Panama Settings is the editor.** It writes the file *and* applies the change
|
- **The Settings app is the editor.** It writes the file *and* applies the change
|
||||||
live, so nothing needs a reload and the two sides cannot drift apart.
|
live, so nothing needs a reload and the two sides cannot drift apart.
|
||||||
|
|
||||||
To add an adjustable setting: add an entry to
|
To add an adjustable setting: add an entry to
|
||||||
@@ -95,7 +95,7 @@ accepts the config in each of those states.
|
|||||||
|
|
||||||
Every bind in `keybinds.lua` goes through a local `bind()` wrapper that
|
Every bind in `keybinds.lua` goes through a local `bind()` wrapper that
|
||||||
substitutes the chord from a stored override, so shortcuts can be moved from
|
substitutes the chord from a stored override, so shortcuts can be moved from
|
||||||
Panama Settings without editing this file.
|
the Settings app without editing this file.
|
||||||
|
|
||||||
```lua
|
```lua
|
||||||
bind(mod .. " + Q", hl.dsp.window.close(), { description = "Close window" })
|
bind(mod .. " + Q", hl.dsp.window.close(), { description = "Close window" })
|
||||||
@@ -118,7 +118,7 @@ shipped one, so a hand-edited `settings.json` cannot cost you a keymap.
|
|||||||
|
|
||||||
Every `hl.bind` must pass a `description`. Hyprland reports Lua-defined binds
|
Every `hl.bind` must pass a `description`. Hyprland reports Lua-defined binds
|
||||||
with dispatcher `__lua` and a bytecode offset as the argument, so a bind without
|
with dispatcher `__lua` and a bytecode offset as the argument, so a bind without
|
||||||
one has nothing readable beside its chord, and Panama Settings drops it from the
|
one has nothing readable beside its chord, and the Settings app drops it from the
|
||||||
Input & Shortcuts page rather than showing a mystery row.
|
Input & Shortcuts page rather than showing a mystery row.
|
||||||
`tests/quickshell/keybinds-contract.sh` fails if any bind lacks a description, so
|
`tests/quickshell/keybinds-contract.sh` fails if any bind lacks a description, so
|
||||||
this cannot regress silently.
|
this cannot regress silently.
|
||||||
@@ -275,7 +275,7 @@ The mental model is unchanged from Forge:
|
|||||||
| `SUPER + V` | Clipboard history |
|
| `SUPER + V` | Clipboard history |
|
||||||
| `SUPER + .` | Emoji picker |
|
| `SUPER + .` | Emoji picker |
|
||||||
| `SUPER + S` | Quick settings |
|
| `SUPER + S` | Quick settings |
|
||||||
| `SUPER + I` | Panama Settings |
|
| `SUPER + I` | Settings |
|
||||||
| `SUPER + SHIFT + F` | Start or reveal focus session |
|
| `SUPER + SHIFT + F` | Start or reveal focus session |
|
||||||
| `SUPER + B` | Notification centre |
|
| `SUPER + B` | Notification centre |
|
||||||
| `SUPER + grave` | Workspace overview |
|
| `SUPER + grave` | Workspace overview |
|
||||||
@@ -288,7 +288,7 @@ The mental model is unchanged from Forge:
|
|||||||
|
|
||||||
### Apps
|
### Apps
|
||||||
`SUPER + T` terminal · `N` neovim · `W` browser · `F` files · `C` calculator ·
|
`SUPER + T` terminal · `N` neovim · `W` browser · `F` files · `C` calculator ·
|
||||||
`E` mail · `I` Panama Settings · `CTRL + SHIFT + Esc` system monitor. GNOME
|
`E` mail · `I` Settings · `CTRL + SHIFT + Esc` system monitor. GNOME
|
||||||
Settings remains searchable in Vicinae for hardware and account panels.
|
Settings remains searchable in Vicinae for hardware and account panels.
|
||||||
|
|
||||||
## Calendar and notifications
|
## Calendar and notifications
|
||||||
@@ -326,7 +326,7 @@ one place. Home and Phone continue the same surface rather than opening extra
|
|||||||
dashboard windows.
|
dashboard windows.
|
||||||
|
|
||||||
Home shows the first four selected favourites at rest and every selected light
|
Home shows the first four selected favourites at rest and every selected light
|
||||||
when expanded. Use **Panama Settings → Home & Phone** to choose favourites,
|
when expanded. Use **Settings → Home & Phone** to choose favourites,
|
||||||
set Panama-only aliases, and arrange their order. Dragging a brightness control
|
set Panama-only aliases, and arrange their order. Dragging a brightness control
|
||||||
only previews the value; releasing it sends one brightness request. A normal
|
only previews the value; releasing it sends one brightness request. A normal
|
||||||
power toggle leaves Home Assistant responsible for restoring its previous
|
power toggle leaves Home Assistant responsible for restoring its previous
|
||||||
|
|||||||
@@ -9,6 +9,8 @@
|
|||||||
-- instead. They still work here; see the session notes in autostart.lua.
|
-- instead. They still work here; see the session notes in autostart.lua.
|
||||||
-- ─────────────────────────────────────────────────────────────────────────────
|
-- ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
local prefs = require("prefs")
|
||||||
|
|
||||||
-- ── GPU selection ───────────────────────────────────────────────────────────
|
-- ── GPU selection ───────────────────────────────────────────────────────────
|
||||||
-- This box has a discrete RX 7800 XT (0000:03:00.0) and a Granite Ridge iGPU
|
-- This box has a discrete RX 7800 XT (0000:03:00.0) and a Granite Ridge iGPU
|
||||||
-- (0000:12:00.0). The monitor hangs off the dGPU, so the dGPU must render.
|
-- (0000:12:00.0). The monitor hangs off the dGPU, so the dGPU must render.
|
||||||
@@ -47,7 +49,7 @@ end
|
|||||||
-- (it has cursors/ + index.theme and no hyprcursors/ directory or manifest.hl),
|
-- (it has cursors/ + index.theme and no hyprcursors/ directory or manifest.hl),
|
||||||
-- so setting it would point hyprcursor at nothing. Hyprland falls back to the
|
-- so setting it would point hyprcursor at nothing. Hyprland falls back to the
|
||||||
-- XCursor path, which is what we want.
|
-- XCursor path, which is what we want.
|
||||||
hl.env("XCURSOR_THEME", "oreo_blue_cursors")
|
hl.env("XCURSOR_THEME", prefs.get("cursorTheme", "oreo_blue_cursors"))
|
||||||
hl.env("XCURSOR_SIZE", "24")
|
hl.env("XCURSOR_SIZE", "24")
|
||||||
|
|
||||||
-- ── Toolkits ────────────────────────────────────────────────────────────────
|
-- ── Toolkits ────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ local prefs = require("prefs")
|
|||||||
hl.config({
|
hl.config({
|
||||||
input = {
|
input = {
|
||||||
kb_layout = prefs.get("keyboardLayout", "us"),
|
kb_layout = prefs.get("keyboardLayout", "us"),
|
||||||
kb_variant = "",
|
kb_variant = prefs.get("keyboardVariant", ""),
|
||||||
kb_model = "",
|
kb_model = "",
|
||||||
kb_options = "",
|
kb_options = prefs.get("keyboardOptions", "caps:escape_shifted_capslock"),
|
||||||
kb_rules = "",
|
kb_rules = "",
|
||||||
|
|
||||||
numlock_by_default = prefs.get("numlockByDefault", true),
|
numlock_by_default = prefs.get("numlockByDefault", true),
|
||||||
@@ -21,10 +21,18 @@ hl.config({
|
|||||||
repeat_delay = prefs.get("keyRepeatDelay", 500),
|
repeat_delay = prefs.get("keyRepeatDelay", 500),
|
||||||
repeat_rate = prefs.get("keyRepeatRate", 33),
|
repeat_rate = prefs.get("keyRepeatRate", 33),
|
||||||
|
|
||||||
-- 1 = click to focus. GNOME's behaviour; NOT sloppy focus.
|
-- 1 = FOLLOW. The window under the pointer takes focus. This comment
|
||||||
|
-- previously claimed 1 was "click to focus, GNOME's behaviour", which
|
||||||
|
-- is the opposite of what Hyprland does -- `hyprctl descriptions` gives
|
||||||
|
-- map: [{"separate":3},{"detached":2},{"follow":1},{"disabled":0}],
|
||||||
|
-- so click-to-focus is 0. Changing the shipped value is a behaviour
|
||||||
|
-- decision rather than a correction, so the value is left alone and
|
||||||
|
-- only the description is fixed; Settings exposes all four.
|
||||||
follow_mouse = prefs.getInt("followMouse", 1),
|
follow_mouse = prefs.getInt("followMouse", 1),
|
||||||
|
|
||||||
-- Don't refocus on mouse move alone -- only on click.
|
-- Softens follow_mouse: with this off, focus changes only when the
|
||||||
|
-- pointer crosses a window boundary, not on every movement inside one.
|
||||||
|
-- Still focus-follows-pointer, just less twitchy.
|
||||||
mouse_refocus = false,
|
mouse_refocus = false,
|
||||||
|
|
||||||
-- Flat pointer response, no acceleration. Matters for gaming.
|
-- Flat pointer response, no acceleration. Matters for gaming.
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ bind(mod .. " + W", hl.dsp.exec_cmd(browser), { description = "Browser" })
|
|||||||
bind(mod .. " + F", hl.dsp.exec_cmd(files), { description = "Files" })
|
bind(mod .. " + F", hl.dsp.exec_cmd(files), { description = "Files" })
|
||||||
bind(mod .. " + C", hl.dsp.exec_cmd(calculator), { description = "Calculator" })
|
bind(mod .. " + C", hl.dsp.exec_cmd(calculator), { description = "Calculator" })
|
||||||
bind(mod .. " + E", hl.dsp.exec_cmd(mail), { description = "Mail" })
|
bind(mod .. " + E", hl.dsp.exec_cmd(mail), { description = "Mail" })
|
||||||
bind(mod .. " + I", hl.dsp.exec_cmd(settings), { description = "Panama Settings" })
|
bind(mod .. " + I", hl.dsp.exec_cmd(settings), { description = "Settings" })
|
||||||
bind("CTRL + SHIFT + Escape", hl.dsp.exec_cmd(sysmonitor), { description = "System monitor" })
|
bind("CTRL + SHIFT + Escape", hl.dsp.exec_cmd(sysmonitor), { description = "System monitor" })
|
||||||
|
|
||||||
-- ── Launcher ────────────────────────────────────────────────────────────────
|
-- ── Launcher ────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
+49
-20
@@ -22,21 +22,22 @@ hl.config({
|
|||||||
border_size = prefs.get("borderSize", 2),
|
border_size = prefs.get("borderSize", 2),
|
||||||
|
|
||||||
col = {
|
col = {
|
||||||
-- The prism: blue leads, orchid follows, on a diagonal so the pair
|
-- The focused accent role: blue leads, orchid follows, on a
|
||||||
-- is visible on both a tall and a wide window. Same two colours as
|
-- diagonal so the pair is visible on both a tall and a wide
|
||||||
-- the shell's hairline (quickshell/widgets/PrismEdge.qml) and the
|
-- window. ColorScheme never writes this role; a future accent
|
||||||
-- tmux theme this palette came from.
|
-- picker can own it without fighting light/dark mode.
|
||||||
active_border = { colors = { "rgba(82aaffee)", "rgba(b172b0ee)" }, angle = 115 },
|
active_border = { colors = { "rgba(82aaffee)", "rgba(b172b0ee)" }, angle = 115 },
|
||||||
-- Unfocused windows get no colour at all. The gradient only means
|
-- The neutral inactive role follows the colour scheme because a
|
||||||
-- something if exactly one window on screen is wearing it.
|
-- dark neutral disappears against a light desktop.
|
||||||
-- Follows the colour scheme: a dark neutral is invisible against a
|
-- services/ColorScheme.qml applies the same values live; this is
|
||||||
-- light desktop. services/ColorScheme.qml applies changes live;
|
-- the value a fresh session starts from.
|
||||||
-- this is the value a fresh session starts from.
|
|
||||||
inactive_border = prefs.get("colorScheme", "dark") == "light"
|
inactive_border = prefs.get("colorScheme", "dark") == "light"
|
||||||
and "rgba(a8aecb99)" or "rgba(3b426199)",
|
and "rgba(a8aecb99)" or "rgba(3b426199)",
|
||||||
},
|
},
|
||||||
|
|
||||||
resize_on_border = true,
|
resize_on_border = prefs.get("resizeOnBorder", true),
|
||||||
|
extend_border_grab_area = prefs.getInt("borderGrabArea", 15),
|
||||||
|
hover_icon_on_border = prefs.get("hoverIconOnBorder", true),
|
||||||
|
|
||||||
-- Enables the per-window "immediate" rule used for games in rules.lua.
|
-- Enables the per-window "immediate" rule used for games in rules.lua.
|
||||||
-- Harmless on its own; tearing only happens where a rule opts in.
|
-- Harmless on its own; tearing only happens where a rule opts in.
|
||||||
@@ -44,16 +45,22 @@ hl.config({
|
|||||||
|
|
||||||
layout = "dwindle",
|
layout = "dwindle",
|
||||||
|
|
||||||
snap = { enabled = true },
|
snap = {
|
||||||
|
enabled = true,
|
||||||
|
window_gap = prefs.getInt("snapWindowGap", 10),
|
||||||
|
monitor_gap = prefs.getInt("snapMonitorGap", 10),
|
||||||
|
respect_gaps = prefs.get("snapRespectGaps", false),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
decoration = {
|
decoration = {
|
||||||
-- 18 to match the shell's popover radius, so a window and a panel sitting
|
-- 18 to match the shell's popover radius, so a window and a panel sitting
|
||||||
-- next to each other read as the same object family.
|
-- next to each other read as the same object family.
|
||||||
rounding = prefs.get("windowRounding", 18),
|
rounding = prefs.get("windowRounding", 18),
|
||||||
rounding_power = 2,
|
rounding_power = prefs.get("roundingPower", 2),
|
||||||
|
|
||||||
active_opacity = 1.0,
|
active_opacity = prefs.get("activeOpacity", 1.0),
|
||||||
|
fullscreen_opacity = prefs.get("fullscreenOpacity", 1.0),
|
||||||
inactive_opacity = prefs.get("inactiveOpacity", 1.0),
|
inactive_opacity = prefs.get("inactiveOpacity", 1.0),
|
||||||
|
|
||||||
blur = {
|
blur = {
|
||||||
@@ -83,9 +90,13 @@ hl.config({
|
|||||||
shadow = {
|
shadow = {
|
||||||
enabled = prefs.get("shadowEnabled", true),
|
enabled = prefs.get("shadowEnabled", true),
|
||||||
range = prefs.get("shadowRange", 20),
|
range = prefs.get("shadowRange", 20),
|
||||||
render_power = 3,
|
render_power = prefs.getInt("shadowRenderPower", 3),
|
||||||
sharp = false,
|
sharp = prefs.get("shadowSharp", false),
|
||||||
color = "rgba(15161eee)",
|
color = "rgba(15161eee)",
|
||||||
|
-- Deliberately not a setting: a two-axis offset needs a control we
|
||||||
|
-- do not have, and a slider bound to half a value is worse than
|
||||||
|
-- leaving it alone. SystemSettings understands the vec2 shape
|
||||||
|
-- already, so adding it later is only a matter of the widget.
|
||||||
offset = { 0, 4 },
|
offset = { 0, 4 },
|
||||||
scale = 1.0,
|
scale = 1.0,
|
||||||
},
|
},
|
||||||
@@ -110,14 +121,32 @@ hl.config({
|
|||||||
dwindle = {
|
dwindle = {
|
||||||
-- Keep the split orientation a window was created with. Closest match
|
-- Keep the split orientation a window was created with. Closest match
|
||||||
-- to how the Forge extension behaved on GNOME.
|
-- to how the Forge extension behaved on GNOME.
|
||||||
preserve_split = true,
|
preserve_split = prefs.get("preserveSplit", true),
|
||||||
smart_resizing = true,
|
smart_resizing = true,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
-- Only in effect when the tiling layout is "master". Panama ships dwindle,
|
||||||
|
-- but Settings offers master as a choice, and a layout you can select and
|
||||||
|
-- cannot configure is barely a choice at all.
|
||||||
|
master = {
|
||||||
|
mfact = prefs.get("masterFactor", 0.55),
|
||||||
|
orientation = prefs.get("masterOrientation", "left"),
|
||||||
|
new_status = prefs.get("masterNewStatus", "slave"),
|
||||||
|
new_on_top = prefs.get("masterNewOnTop", false),
|
||||||
|
},
|
||||||
|
|
||||||
misc = {
|
misc = {
|
||||||
force_default_wallpaper = 0,
|
force_default_wallpaper = 0,
|
||||||
disable_hyprland_logo = true,
|
-- Stored as "show the logo / show the splash" and written as Hyprland's
|
||||||
disable_splash_rendering = true,
|
-- `disable_*`, matching the `invert` flag on these entries in
|
||||||
|
-- PreferenceSchema so both sides agree about which way round they are.
|
||||||
|
disable_hyprland_logo = not prefs.get("hyprlandLogo", false),
|
||||||
|
disable_splash_rendering = not prefs.get("hyprlandSplash", false),
|
||||||
|
|
||||||
|
-- Keep native Wayland selection paste and GTK's matching preference
|
||||||
|
-- in lockstep. DesktopStyle applies the GTK half only after this value
|
||||||
|
-- has been read back and stored by SystemSettings.
|
||||||
|
middle_click_paste = prefs.get("middleClickPaste", true),
|
||||||
|
|
||||||
-- Same setting as Theme.fontFamily in the shell. If only the QML side
|
-- Same setting as Theme.fontFamily in the shell. If only the QML side
|
||||||
-- followed the preference, the compositor and the shell would disagree
|
-- followed the preference, the compositor and the shell would disagree
|
||||||
@@ -172,8 +201,8 @@ hl.config({
|
|||||||
},
|
},
|
||||||
|
|
||||||
ecosystem = {
|
ecosystem = {
|
||||||
no_update_news = true,
|
no_update_news = not prefs.get("hyprlandUpdateNews", false),
|
||||||
no_donation_nag = true,
|
no_donation_nag = not prefs.get("hyprlandDonationNag", false),
|
||||||
},
|
},
|
||||||
|
|
||||||
xwayland = {
|
xwayland = {
|
||||||
|
|||||||
@@ -98,7 +98,26 @@ Singleton {
|
|||||||
// back to shipped defaults and let the next write replace it.
|
// back to shipped defaults and let the next write replace it.
|
||||||
parsed = {};
|
parsed = {};
|
||||||
}
|
}
|
||||||
root.values = (parsed && typeof parsed === "object") ? parsed : {};
|
const raw = (parsed && typeof parsed === "object") ? parsed : {};
|
||||||
|
|
||||||
|
// Upgrade before anything reads a value. A stored key the current
|
||||||
|
// schema no longer recognises is carried through untouched and silently
|
||||||
|
// stops taking effect, so the conversion has to happen here rather than
|
||||||
|
// being noticed later by whoever owns that setting.
|
||||||
|
const result = Migrations.apply(raw);
|
||||||
|
root.values = result.values;
|
||||||
|
|
||||||
|
if (result.migrated)
|
||||||
|
console.info("Settings migrated from version", result.from, "to", result.to + ":",
|
||||||
|
result.applied.join("; "));
|
||||||
|
|
||||||
|
// Write whenever the version moved, which includes stamping a file
|
||||||
|
// written before versioning existed. Left unwritten, the stamp lives
|
||||||
|
// only in memory and is redone on every launch, and a migration that is
|
||||||
|
// not idempotent would compound.
|
||||||
|
if (result.changed)
|
||||||
|
persistTimer.restart();
|
||||||
|
|
||||||
root.revision++;
|
root.revision++;
|
||||||
root.loaded = true;
|
root.loaded = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
pragma Singleton
|
||||||
|
|
||||||
|
// Versioned upgrades for the settings file.
|
||||||
|
//
|
||||||
|
// The schema is the single source of truth for what a setting IS, but it cannot
|
||||||
|
// describe what a setting USED to be. Renaming a key, changing its units, or
|
||||||
|
// splitting one setting into two all leave a stored value that the new schema
|
||||||
|
// does not recognise -- and an unrecognised key is silently carried through
|
||||||
|
// untouched, so the user's choice simply stops taking effect with nothing to
|
||||||
|
// say why. That is the failure this exists to prevent.
|
||||||
|
//
|
||||||
|
// HOW IT WORKS
|
||||||
|
//
|
||||||
|
// settings.json carries a schemaVersion. On load, every migration with a
|
||||||
|
// version ABOVE the stored one runs in order, then the file is stamped with
|
||||||
|
// `current`. A file with no schemaVersion at all is a file written before this
|
||||||
|
// existed; it is stamped at `baseline` and NOT migrated, because those
|
||||||
|
// migrations were never written for it.
|
||||||
|
//
|
||||||
|
// WRITING ONE
|
||||||
|
//
|
||||||
|
// { version: 2, describe: "rename dockDelay to dockHideDelayMs",
|
||||||
|
// migrate: values => { ... return values; } }
|
||||||
|
//
|
||||||
|
// Rules that make this safe to run against a real user's file:
|
||||||
|
//
|
||||||
|
// * migrate() receives the whole values object and returns it. Mutating and
|
||||||
|
// returning the same object is fine.
|
||||||
|
// * NEVER delete a key you are not replacing. Unknown keys are deliberately
|
||||||
|
// preserved so that rolling back to an older Panama does not discard a
|
||||||
|
// newer version's settings, and a migration is the one place that promise
|
||||||
|
// could quietly be broken.
|
||||||
|
// * A migration must tolerate its input being absent or the wrong type. It
|
||||||
|
// runs against files written by every previous version, including ones
|
||||||
|
// that were hand-edited.
|
||||||
|
// * Migrations never run twice: the stored version only moves forward.
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
|
||||||
|
QtObject {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
// What a file written today is stamped with. Bump this when adding a
|
||||||
|
// migration, to the version of the migration you added.
|
||||||
|
readonly property int current: 1
|
||||||
|
|
||||||
|
// Files predating versioning are stamped here without being migrated.
|
||||||
|
readonly property int baseline: 1
|
||||||
|
|
||||||
|
readonly property string versionKey: "schemaVersion"
|
||||||
|
|
||||||
|
// Ordered by version. Empty is the correct state until the first breaking
|
||||||
|
// schema change -- this exists so that change is a routine edit rather than
|
||||||
|
// an emergency.
|
||||||
|
readonly property var steps: []
|
||||||
|
|
||||||
|
function storedVersion(values: var): int {
|
||||||
|
const raw = values ? values[root.versionKey] : undefined;
|
||||||
|
return (typeof raw === "number" && isFinite(raw)) ? Math.floor(raw) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns { values, migrated, changed, from, to, applied }.
|
||||||
|
//
|
||||||
|
// `applied` names each step that ran, so the caller can log something
|
||||||
|
// meaningful rather than "settings changed somehow". `changed` is the one
|
||||||
|
// the caller should write on: stamping a pre-versioning file changes it
|
||||||
|
// without running any step, and left unwritten the stamp would live only in
|
||||||
|
// memory and be redone on every launch.
|
||||||
|
function apply(values: var): var {
|
||||||
|
return root.applyWith(values, root.steps, root.current, root.baseline);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The same logic with the step list injected, so the machinery can be
|
||||||
|
// tested against fixture migrations. The real list is empty until the first
|
||||||
|
// breaking schema change, and a mechanism that has never run against a
|
||||||
|
// failing step is not one to find out about during an upgrade.
|
||||||
|
function applyWith(values: var, steps: var, current: int, baseline: int): var {
|
||||||
|
const safe = (values && typeof values === "object") ? values : {};
|
||||||
|
const from = root.storedVersion(safe);
|
||||||
|
|
||||||
|
// No version: written before versioning existed. Stamp it and stop.
|
||||||
|
// Running the migration list against it would apply upgrades designed
|
||||||
|
// for schemas this file never had.
|
||||||
|
if (from === 0) {
|
||||||
|
safe[root.versionKey] = baseline;
|
||||||
|
return { values: safe, migrated: false, changed: true, from: 0, to: baseline, applied: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// A file from a NEWER Panama. Left completely alone: downgrading its
|
||||||
|
// keys is not something this can do correctly, and unknown keys are
|
||||||
|
// already preserved, so the older build simply ignores what it does not
|
||||||
|
// understand.
|
||||||
|
if (from > current)
|
||||||
|
return { values: safe, migrated: false, changed: false, from: from, to: from, applied: [] };
|
||||||
|
|
||||||
|
const applied = [];
|
||||||
|
let working = safe;
|
||||||
|
for (const step of steps) {
|
||||||
|
if (step.version <= from || step.version > current)
|
||||||
|
continue;
|
||||||
|
try {
|
||||||
|
const result = step.migrate(working);
|
||||||
|
if (result && typeof result === "object")
|
||||||
|
working = result;
|
||||||
|
applied.push(step.version + ": " + step.describe);
|
||||||
|
} catch (error) {
|
||||||
|
// One bad migration must not cost the user every setting. Stop
|
||||||
|
// at the last good version so the next launch retries from
|
||||||
|
// here rather than skipping the failed step forever.
|
||||||
|
console.warn("Migrations: step", step.version, "failed:", error);
|
||||||
|
working[root.versionKey] = step.version - 1;
|
||||||
|
return { values: working, migrated: applied.length > 0, changed: applied.length > 0,
|
||||||
|
from: from, to: step.version - 1, applied: applied };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
working[root.versionKey] = current;
|
||||||
|
return { values: working, migrated: applied.length > 0, changed: from !== current,
|
||||||
|
from: from, to: current, applied: applied };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -128,10 +128,21 @@ Singleton {
|
|||||||
{
|
{
|
||||||
key: "vrrPolicy", type: "enum", def: 3, group: "display",
|
key: "vrrPolicy", type: "enum", def: 3, group: "display",
|
||||||
label: "Variable refresh rate",
|
label: "Variable refresh rate",
|
||||||
detail: "Content-aware matches the display to what is on screen",
|
detail: "Matches the display's refresh rate to what is on screen",
|
||||||
|
// All four the compositor publishes, rather than the two that were
|
||||||
|
// here. Always-on VRR is a legitimate choice on a panel that
|
||||||
|
// handles it well, and it was simply unreachable -- as was
|
||||||
|
// fullscreen-only, which is what someone wanting VRR for video
|
||||||
|
// rather than games wants.
|
||||||
options: [
|
options: [
|
||||||
{ value: 0, label: "Off" },
|
{ value: 0, label: "Off",
|
||||||
{ value: 3, label: "Content-aware" }
|
detail: "The display runs at a fixed refresh rate" },
|
||||||
|
{ value: 1, label: "Always on",
|
||||||
|
detail: "Best on panels that handle low refresh rates without flicker" },
|
||||||
|
{ value: 2, label: "Fullscreen only",
|
||||||
|
detail: "Any fullscreen window, including video" },
|
||||||
|
{ value: 3, label: "Fullscreen games",
|
||||||
|
detail: "Only fullscreen games, which is the safest default" }
|
||||||
],
|
],
|
||||||
hypr: { path: ["misc", "vrr"], option: "misc:vrr", readAs: "int" }
|
hypr: { path: ["misc", "vrr"], option: "misc:vrr", readAs: "int" }
|
||||||
},
|
},
|
||||||
@@ -140,8 +151,12 @@ Singleton {
|
|||||||
label: "Direct scanout",
|
label: "Direct scanout",
|
||||||
detail: "Lets fullscreen content bypass compositing",
|
detail: "Lets fullscreen content bypass compositing",
|
||||||
options: [
|
options: [
|
||||||
{ value: 0, label: "Off" },
|
{ value: 0, label: "Off",
|
||||||
{ value: 2, label: "Automatic" }
|
detail: "Everything goes through the compositor" },
|
||||||
|
{ value: 1, label: "Always on",
|
||||||
|
detail: "Forced rather than decided per surface; can drop frames on some drivers" },
|
||||||
|
{ value: 2, label: "Automatic",
|
||||||
|
detail: "The compositor decides per surface, which is the safe default" }
|
||||||
],
|
],
|
||||||
hypr: { path: ["render", "direct_scanout"], option: "render:direct_scanout", readAs: "int" }
|
hypr: { path: ["render", "direct_scanout"], option: "render:direct_scanout", readAs: "int" }
|
||||||
},
|
},
|
||||||
@@ -191,6 +206,147 @@ Singleton {
|
|||||||
hypr: { path: ["decoration", "inactive_opacity"], option: "decoration:inactive_opacity", readAs: "float" }
|
hypr: { path: ["decoration", "inactive_opacity"], option: "decoration:inactive_opacity", readAs: "float" }
|
||||||
},
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
key: "activeOpacity", type: "real", def: 1.0, min: 0.5, max: 1.0, step: 0.05,
|
||||||
|
group: "windows",
|
||||||
|
label: "Focused window opacity",
|
||||||
|
detail: "Fade even the focused window; 1.0 is fully opaque",
|
||||||
|
hypr: { path: ["decoration", "active_opacity"], option: "decoration:active_opacity", readAs: "float" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "fullscreenOpacity", type: "real", def: 1.0, min: 0.5, max: 1.0, step: 0.05,
|
||||||
|
group: "windows",
|
||||||
|
label: "Fullscreen opacity",
|
||||||
|
detail: "Applied instead of the focused opacity when a window is fullscreen",
|
||||||
|
hypr: { path: ["decoration", "fullscreen_opacity"], option: "decoration:fullscreen_opacity", readAs: "float" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "roundingPower", type: "real", def: 2.0, min: 2.0, max: 10.0, step: 0.5,
|
||||||
|
group: "windows",
|
||||||
|
label: "Corner shape",
|
||||||
|
detail: "2 is a circular corner; higher values approach a squircle",
|
||||||
|
hypr: { path: ["decoration", "rounding_power"], option: "decoration:rounding_power", readAs: "float" }
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Window edges ────────────────────────────────────────────────────
|
||||||
|
// How the pointer interacts with a window's border, and how windows
|
||||||
|
// behave near each other. All shipped by looks.lua with no way to
|
||||||
|
// change any of it.
|
||||||
|
{
|
||||||
|
key: "resizeOnBorder", type: "bool", def: true, group: "edges",
|
||||||
|
label: "Resize by dragging the border",
|
||||||
|
detail: "Drag a window's edge to resize it, instead of only with the keyboard",
|
||||||
|
hypr: { path: ["general", "resize_on_border"], option: "general:resize_on_border", readAs: "bool" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "borderGrabArea", type: "int", def: 15, min: 0, max: 40, step: 1,
|
||||||
|
unit: "px",
|
||||||
|
group: "edges",
|
||||||
|
label: "Border grab area",
|
||||||
|
detail: "How far outside the border still counts as grabbing it. Larger is easier to hit",
|
||||||
|
hypr: { path: ["general", "extend_border_grab_area"], option: "general:extend_border_grab_area", readAs: "int" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "hoverIconOnBorder", type: "bool", def: true, group: "edges",
|
||||||
|
label: "Show the resize cursor",
|
||||||
|
detail: "Change the pointer when it is over a resizable border",
|
||||||
|
hypr: { path: ["general", "hover_icon_on_border"], option: "general:hover_icon_on_border", readAs: "bool" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "snapWindowGap", type: "int", def: 10, min: 0, max: 60, step: 1,
|
||||||
|
unit: "px",
|
||||||
|
group: "edges",
|
||||||
|
label: "Snap distance between windows",
|
||||||
|
detail: "How close two floating windows must be before they snap together",
|
||||||
|
hypr: { path: ["general", "snap", "window_gap"], option: "general:snap:window_gap", readAs: "int" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "snapMonitorGap", type: "int", def: 10, min: 0, max: 60, step: 1,
|
||||||
|
unit: "px",
|
||||||
|
group: "edges",
|
||||||
|
label: "Snap distance to screen edges",
|
||||||
|
detail: "How close a floating window must be to an edge before it snaps to it",
|
||||||
|
hypr: { path: ["general", "snap", "monitor_gap"], option: "general:snap:monitor_gap", readAs: "int" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "snapRespectGaps", type: "bool", def: false, group: "edges",
|
||||||
|
label: "Snapping respects gaps",
|
||||||
|
detail: "Snapped windows keep the configured gap instead of touching",
|
||||||
|
hypr: { path: ["general", "snap", "respect_gaps"], option: "general:snap:respect_gaps", readAs: "bool" }
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Master layout ───────────────────────────────────────────────────
|
||||||
|
// Only meaningful when the tiling layout is Master and stack. Offering
|
||||||
|
// that layout with none of its options was an omission: it is the one
|
||||||
|
// layout whose whole behaviour is in these settings.
|
||||||
|
{
|
||||||
|
key: "masterFactor", type: "real", def: 0.55, min: 0.1, max: 0.9, step: 0.05,
|
||||||
|
group: "master",
|
||||||
|
label: "Master area size",
|
||||||
|
detail: "How much of the screen the master window takes",
|
||||||
|
hypr: { path: ["master", "mfact"], option: "master:mfact", readAs: "float" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "masterOrientation", type: "enum", def: "left", group: "master",
|
||||||
|
label: "Master area position",
|
||||||
|
detail: "Which side of the screen the master window occupies",
|
||||||
|
options: [
|
||||||
|
{ value: "left", label: "Left" },
|
||||||
|
{ value: "right", label: "Right" },
|
||||||
|
{ value: "top", label: "Top" },
|
||||||
|
{ value: "bottom", label: "Bottom" },
|
||||||
|
{ value: "center", label: "Centre" }
|
||||||
|
],
|
||||||
|
hypr: { path: ["master", "orientation"], option: "master:orientation", readAs: "str" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "masterNewStatus", type: "enum", def: "slave", group: "master",
|
||||||
|
label: "New windows become",
|
||||||
|
detail: "Whether a new window takes the master area or joins the stack",
|
||||||
|
options: [
|
||||||
|
{ value: "master", label: "The master window" },
|
||||||
|
{ value: "slave", label: "Part of the stack" },
|
||||||
|
{ value: "inherit", label: "Whatever the focused window is" }
|
||||||
|
],
|
||||||
|
hypr: { path: ["master", "new_status"], option: "master:new_status", readAs: "str" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "masterNewOnTop", type: "bool", def: false, group: "master",
|
||||||
|
label: "Add new windows at the top",
|
||||||
|
detail: "New stack windows go above the others rather than below",
|
||||||
|
hypr: { path: ["master", "new_on_top"], option: "master:new_on_top", readAs: "bool" }
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Hyprland's own notices ──────────────────────────────────────────
|
||||||
|
// Panama turns all four off on the user's behalf. That is a defensible
|
||||||
|
// default and was not a decision anyone could reverse without editing
|
||||||
|
// looks.lua, which is precisely the kind of thing this app exists to
|
||||||
|
// stop.
|
||||||
|
{
|
||||||
|
key: "hyprlandLogo", type: "bool", def: false, group: "notices",
|
||||||
|
label: "Hyprland wallpaper",
|
||||||
|
detail: "The stock background Hyprland draws when no wallpaper is set",
|
||||||
|
hypr: { path: ["misc", "disable_hyprland_logo"], option: "misc:disable_hyprland_logo", readAs: "bool", invert: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "hyprlandSplash", type: "bool", def: false, group: "notices",
|
||||||
|
label: "Splash text",
|
||||||
|
detail: "The line of text Hyprland renders over the stock background",
|
||||||
|
hypr: { path: ["misc", "disable_splash_rendering"], option: "misc:disable_splash_rendering", readAs: "bool", invert: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "hyprlandUpdateNews", type: "bool", def: false, group: "notices",
|
||||||
|
label: "Update announcements",
|
||||||
|
detail: "The window Hyprland opens after an update to describe what changed",
|
||||||
|
hypr: { path: ["ecosystem", "no_update_news"], option: "ecosystem:no_update_news", readAs: "bool", invert: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "hyprlandDonationNag", type: "bool", def: false, group: "notices",
|
||||||
|
label: "Donation reminders",
|
||||||
|
detail: "The prompt Hyprland shows twice a year asking for support",
|
||||||
|
hypr: { path: ["ecosystem", "no_donation_nag"], option: "ecosystem:no_donation_nag", readAs: "bool", invert: true }
|
||||||
|
},
|
||||||
|
|
||||||
// ── Effects ─────────────────────────────────────────────────────────
|
// ── Effects ─────────────────────────────────────────────────────────
|
||||||
{
|
{
|
||||||
key: "blurEnabled", type: "bool", def: true, group: "effects",
|
key: "blurEnabled", type: "bool", def: true, group: "effects",
|
||||||
@@ -226,6 +382,19 @@ Singleton {
|
|||||||
detail: "How far the shadow spreads from the window edge",
|
detail: "How far the shadow spreads from the window edge",
|
||||||
hypr: { path: ["decoration", "shadow", "range"], option: "decoration:shadow:range", readAs: "int" }
|
hypr: { path: ["decoration", "shadow", "range"], option: "decoration:shadow:range", readAs: "int" }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "shadowSharp", type: "bool", def: false, group: "effects",
|
||||||
|
label: "Hard-edged shadow",
|
||||||
|
detail: "A crisp shadow instead of a soft falloff",
|
||||||
|
hypr: { path: ["decoration", "shadow", "sharp"], option: "decoration:shadow:sharp", readAs: "bool" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "shadowRenderPower", type: "int", def: 3, min: 1, max: 4, step: 1,
|
||||||
|
group: "effects",
|
||||||
|
label: "Shadow falloff",
|
||||||
|
detail: "How sharply the shadow fades out. Higher is tighter to the window",
|
||||||
|
hypr: { path: ["decoration", "shadow", "render_power"], option: "decoration:shadow:render_power", readAs: "int" }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "glowEnabled", type: "bool", def: true, group: "effects",
|
key: "glowEnabled", type: "bool", def: true, group: "effects",
|
||||||
label: "Focus glow",
|
label: "Focus glow",
|
||||||
@@ -257,6 +426,24 @@ Singleton {
|
|||||||
detail: "XKB layout name, or a comma-separated list to switch between",
|
detail: "XKB layout name, or a comma-separated list to switch between",
|
||||||
hypr: { path: ["input", "kb_layout"], option: "input:kb_layout", readAs: "str" }
|
hypr: { path: ["input", "kb_layout"], option: "input:kb_layout", readAs: "str" }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "keyboardVariant", type: "string", def: "", group: "input",
|
||||||
|
// Same shape as the layout list and for the same reason: this is
|
||||||
|
// serialised into an hl.config string.
|
||||||
|
pattern: "^$|^[a-z0-9_]{1,24}(,[a-z0-9_]{1,24})*$",
|
||||||
|
label: "Layout variant",
|
||||||
|
detail: "XKB variant, such as dvorak or colemak. Empty for the standard layout",
|
||||||
|
hypr: { path: ["input", "kb_variant"], option: "input:kb_variant", readAs: "str" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "keyboardOptions", type: "string", def: "caps:escape_shifted_capslock", group: "input",
|
||||||
|
// XKB option names are colon-separated pairs in a comma-separated
|
||||||
|
// list, e.g. "compose:ralt,caps:escape".
|
||||||
|
pattern: "^$|^[a-z0-9_]+:[a-z0-9_]+(,[a-z0-9_]+:[a-z0-9_]+)*$",
|
||||||
|
label: "Keyboard options",
|
||||||
|
detail: "XKB options, such as compose:ralt to make right Alt a compose key",
|
||||||
|
hypr: { path: ["input", "kb_options"], option: "input:kb_options", readAs: "str" }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "numlockByDefault", type: "bool", def: true, group: "input",
|
key: "numlockByDefault", type: "bool", def: true, group: "input",
|
||||||
label: "Num Lock on login",
|
label: "Num Lock on login",
|
||||||
@@ -281,12 +468,28 @@ Singleton {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "followMouse", type: "enum", def: 1, group: "input",
|
key: "followMouse", type: "enum", def: 1, group: "input",
|
||||||
label: "Focus follows pointer",
|
label: "Pointer focus",
|
||||||
detail: "Click to focus matches GNOME; sloppy focus follows the pointer",
|
detail: "What moving the pointer does to which window is focused",
|
||||||
|
// These labels were wrong, and wrong in the worst way: value 1 was
|
||||||
|
// shown as "Click to focus" while Hyprland's 1 means the opposite.
|
||||||
|
// The compositor publishes the authoritative mapping itself --
|
||||||
|
// `hyprctl descriptions` gives
|
||||||
|
// map: [{"separate":3},{"detached":2},{"follow":1},{"disabled":0}]
|
||||||
|
// -- so a desktop labelled "Click to focus" was in fact following
|
||||||
|
// the pointer, and the way to actually get click-to-focus was to
|
||||||
|
// choose "Never". Value 3 was missing entirely.
|
||||||
|
//
|
||||||
|
// enum-hypr-map-contract now pins every mapped enum against that
|
||||||
|
// published map, so this cannot drift again.
|
||||||
options: [
|
options: [
|
||||||
{ value: 0, label: "Never" },
|
{ value: 0, label: "Click to focus",
|
||||||
{ value: 1, label: "Click to focus" },
|
detail: "Moving the pointer never changes focus" },
|
||||||
{ value: 2, label: "Sloppy focus" }
|
{ value: 1, label: "Focus follows pointer",
|
||||||
|
detail: "The window under the pointer takes focus as you move" },
|
||||||
|
{ value: 2, label: "Pointer detached",
|
||||||
|
detail: "The pointer highlights windows on its own; clicking moves keyboard focus" },
|
||||||
|
{ value: 3, label: "Pointer fully separate",
|
||||||
|
detail: "Clicking does not move keyboard focus at all" }
|
||||||
],
|
],
|
||||||
hypr: { path: ["input", "follow_mouse"], option: "input:follow_mouse", readAs: "int" }
|
hypr: { path: ["input", "follow_mouse"], option: "input:follow_mouse", readAs: "int" }
|
||||||
},
|
},
|
||||||
@@ -300,7 +503,7 @@ Singleton {
|
|||||||
{
|
{
|
||||||
key: "cursorInactiveTimeout", type: "int", def: 4, min: 0, max: 60, step: 1,
|
key: "cursorInactiveTimeout", type: "int", def: 4, min: 0, max: 60, step: 1,
|
||||||
unit: "s",
|
unit: "s",
|
||||||
group: "input",
|
group: "pointer",
|
||||||
label: "Hide pointer after",
|
label: "Hide pointer after",
|
||||||
detail: "Seconds of stillness before the pointer fades out; 0 never hides it",
|
detail: "Seconds of stillness before the pointer fades out; 0 never hides it",
|
||||||
// Reported as a float even though it is only ever set to whole
|
// Reported as a float even though it is only ever set to whole
|
||||||
@@ -310,6 +513,209 @@ Singleton {
|
|||||||
hypr: { path: ["cursor", "inactive_timeout"], option: "cursor:inactive_timeout", readAs: "float" }
|
hypr: { path: ["cursor", "inactive_timeout"], option: "cursor:inactive_timeout", readAs: "float" }
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── Pointer ─────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Every `readAs` below was read back off the running compositor rather
|
||||||
|
// than guessed. Two are not what they look like: touchpad drag lock is
|
||||||
|
// an int with three states, not a switch, and scroll factors are floats
|
||||||
|
// even at their default of exactly 1.
|
||||||
|
{
|
||||||
|
key: "naturalScroll", type: "bool", def: false, group: "pointer",
|
||||||
|
label: "Natural scrolling",
|
||||||
|
detail: "Content follows the direction of your fingers, as on a phone",
|
||||||
|
hypr: { path: ["input", "natural_scroll"], option: "input:natural_scroll", readAs: "bool" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "accelProfile", type: "enum", def: "flat", group: "pointer",
|
||||||
|
label: "Acceleration",
|
||||||
|
detail: "Flat moves the pointer the same distance however fast you move",
|
||||||
|
options: [
|
||||||
|
{ value: "flat", label: "Flat" },
|
||||||
|
{ value: "adaptive", label: "Adaptive" }
|
||||||
|
],
|
||||||
|
hypr: { path: ["input", "accel_profile"], option: "input:accel_profile", readAs: "str" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "scrollFactor", type: "real", def: 1.0, min: 0.1, max: 4.0, step: 0.1,
|
||||||
|
group: "pointer",
|
||||||
|
label: "Scroll speed",
|
||||||
|
detail: "Multiplies how far one notch of the wheel scrolls",
|
||||||
|
hypr: { path: ["input", "scroll_factor"], option: "input:scroll_factor", readAs: "float" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "leftHanded", type: "bool", def: false, group: "pointer",
|
||||||
|
label: "Left-handed",
|
||||||
|
detail: "Swap the primary and secondary buttons",
|
||||||
|
hypr: { path: ["input", "left_handed"], option: "input:left_handed", readAs: "bool" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "middleClickPaste", type: "bool", def: true, group: "pointer",
|
||||||
|
label: "Middle-click paste",
|
||||||
|
detail: "Paste the primary selection in GTK and native Wayland applications",
|
||||||
|
hypr: { path: ["misc", "middle_click_paste"], option: "misc:middle_click_paste", readAs: "bool" }
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Touchpad ────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Shown only on machines that have one. These are separate from the
|
||||||
|
// pointer settings above because libinput keeps them separate: a mouse
|
||||||
|
// and a touchpad on the same machine can scroll in opposite directions,
|
||||||
|
// and usually should.
|
||||||
|
{
|
||||||
|
key: "touchpadTapToClick", type: "bool", def: true, group: "touchpad",
|
||||||
|
label: "Tap to click",
|
||||||
|
detail: "A tap counts as a click without pressing down",
|
||||||
|
// The Lua config key and the hyprctl option name genuinely differ
|
||||||
|
// here: hl.config wants input.touchpad.tap_to_click, getoption
|
||||||
|
// answers to input:touchpad:tap-to-click. Using either spelling for
|
||||||
|
// both fails -- a hyphen is not a Lua identifier, and the
|
||||||
|
// underscored name is not a known option to getoption. This is what
|
||||||
|
// the two separate fields are for.
|
||||||
|
hypr: { path: ["input", "touchpad", "tap_to_click"], option: "input:touchpad:tap-to-click", readAs: "bool" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "touchpadNaturalScroll", type: "bool", def: true, group: "touchpad",
|
||||||
|
label: "Natural scrolling",
|
||||||
|
detail: "Content follows the direction of your fingers",
|
||||||
|
hypr: { path: ["input", "touchpad", "natural_scroll"], option: "input:touchpad:natural_scroll", readAs: "bool" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "touchpadDisableWhileTyping", type: "bool", def: true, group: "touchpad",
|
||||||
|
label: "Disable while typing",
|
||||||
|
detail: "Ignore the touchpad briefly after a keystroke, so a palm cannot move the pointer",
|
||||||
|
hypr: { path: ["input", "touchpad", "disable_while_typing"], option: "input:touchpad:disable_while_typing", readAs: "bool" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "touchpadScrollFactor", type: "real", def: 1.0, min: 0.1, max: 4.0, step: 0.1,
|
||||||
|
group: "touchpad",
|
||||||
|
label: "Scroll speed",
|
||||||
|
detail: "Multiplies how far a two-finger scroll travels",
|
||||||
|
hypr: { path: ["input", "touchpad", "scroll_factor"], option: "input:touchpad:scroll_factor", readAs: "float" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "touchpadDragLock", type: "enum", def: 0, group: "touchpad",
|
||||||
|
label: "Drag lock",
|
||||||
|
detail: "Keeps a tap-and-drag active when you lift a finger mid-drag",
|
||||||
|
// An int with three states rather than a switch, which is why this
|
||||||
|
// is an enum: reported as `int` by getoption, not `bool`.
|
||||||
|
options: [
|
||||||
|
{ value: 0, label: "Off" },
|
||||||
|
{ value: 1, label: "On" },
|
||||||
|
{ value: 2, label: "On, until you tap again" }
|
||||||
|
],
|
||||||
|
hypr: { path: ["input", "touchpad", "drag_lock"], option: "input:touchpad:drag_lock", readAs: "int" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "touchpadMiddleButtonEmulation", type: "bool", def: false, group: "touchpad",
|
||||||
|
label: "Middle-click by pressing both buttons",
|
||||||
|
detail: "Pressing left and right together acts as a middle click",
|
||||||
|
hypr: { path: ["input", "touchpad", "middle_button_emulation"], option: "input:touchpad:middle_button_emulation", readAs: "bool" }
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Multitasking ────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// GNOME's Multitasking panel, in Hyprland's terms. The Desktop page
|
||||||
|
// described the first two of these as read-only facts ("Layout:
|
||||||
|
// Tiling"), which was never true -- they are ordinary settings that
|
||||||
|
// happened not to have controls.
|
||||||
|
//
|
||||||
|
// Defaults here are Panama's shipped values from hypr/looks.lua, not
|
||||||
|
// Hyprland's own, so restoring defaults returns the desktop to how it
|
||||||
|
// ships rather than to how Hyprland would behave with no config.
|
||||||
|
{
|
||||||
|
key: "windowLayout", type: "enum", def: "dwindle", group: "multitasking",
|
||||||
|
label: "Tiling layout",
|
||||||
|
detail: "Dwindle splits the focused window; master keeps one large window beside a stack",
|
||||||
|
options: [
|
||||||
|
{ value: "dwindle", label: "Dwindle" },
|
||||||
|
{ value: "master", label: "Master and stack" }
|
||||||
|
],
|
||||||
|
hypr: { path: ["general", "layout"], option: "general:layout", readAs: "str" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "preserveSplit", type: "bool", def: true, group: "multitasking",
|
||||||
|
label: "Keep split direction",
|
||||||
|
detail: "New windows follow the split of the window they replace, instead of always halving the longer side",
|
||||||
|
hypr: { path: ["dwindle", "preserve_split"], option: "dwindle:preserve_split", readAs: "bool" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "forceSplit", type: "enum", def: 0, group: "multitasking",
|
||||||
|
label: "New windows open",
|
||||||
|
detail: "Where a new window lands relative to the one that was focused",
|
||||||
|
options: [
|
||||||
|
{ value: 0, label: "Where the pointer is" },
|
||||||
|
{ value: 1, label: "Always left or above" },
|
||||||
|
{ value: 2, label: "Always right or below" }
|
||||||
|
],
|
||||||
|
hypr: { path: ["dwindle", "force_split"], option: "dwindle:force_split", readAs: "int" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "windowSnapping", type: "bool", def: true, group: "multitasking",
|
||||||
|
label: "Snap floating windows",
|
||||||
|
detail: "Floating windows stick to screen edges and to each other as you drag them",
|
||||||
|
hypr: { path: ["general", "snap", "enabled"], option: "general:snap:enabled", readAs: "bool" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "workspaceBackAndForth", type: "bool", def: false, group: "multitasking",
|
||||||
|
label: "Switch back and forth",
|
||||||
|
detail: "Selecting the workspace you are already on returns you to the previous one",
|
||||||
|
hypr: { path: ["binds", "workspace_back_and_forth"], option: "binds:workspace_back_and_forth", readAs: "bool" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "allowWorkspaceCycles", type: "bool", def: false, group: "multitasking",
|
||||||
|
label: "Wrap around at the ends",
|
||||||
|
detail: "Moving past the last workspace continues from the first",
|
||||||
|
hypr: { path: ["binds", "allow_workspace_cycles"], option: "binds:allow_workspace_cycles", readAs: "bool" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "focusOnActivate", type: "bool", def: false, group: "multitasking",
|
||||||
|
label: "Let applications take focus",
|
||||||
|
detail: "An application asking for attention is switched to, rather than only highlighted",
|
||||||
|
hypr: { path: ["misc", "focus_on_activate"], option: "misc:focus_on_activate", readAs: "bool" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "mouseMoveFocusesMonitor", type: "bool", def: true, group: "multitasking",
|
||||||
|
label: "Pointer changes active display",
|
||||||
|
detail: "Moving the pointer to another display makes it the active one",
|
||||||
|
hypr: { path: ["misc", "mouse_move_focuses_monitor"], option: "misc:mouse_move_focuses_monitor", readAs: "bool" }
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Accessibility ───────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Only what Hyprland can actually deliver. GNOME's sticky keys, slow
|
||||||
|
// keys, bounce keys and mouse keys are AccessX, an X11 server feature:
|
||||||
|
// XKB under Wayland has no accessx option group at all (verified
|
||||||
|
// against evdev.lst), and Hyprland does not implement it. The
|
||||||
|
// compositor will happily STORE "accessx:enable" as a keyboard option
|
||||||
|
// and nothing will ever act on it, which is exactly the kind of switch
|
||||||
|
// this app refuses to ship.
|
||||||
|
{
|
||||||
|
key: "magnifierFactor", type: "real", def: 1.0, min: 1.0, max: 5.0, step: 0.1,
|
||||||
|
group: "accessibility",
|
||||||
|
label: "Magnifier",
|
||||||
|
detail: "Magnifies the screen around the pointer. 1.0 is off",
|
||||||
|
hypr: { path: ["cursor", "zoom_factor"], option: "cursor:zoom_factor", readAs: "float" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "magnifierRigid", type: "bool", def: false, group: "accessibility",
|
||||||
|
label: "Magnifier follows in steps",
|
||||||
|
detail: "Moves the magnified view in increments rather than gliding with the pointer",
|
||||||
|
hypr: { path: ["cursor", "zoom_rigid"], option: "cursor:zoom_rigid", readAs: "bool" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "dimInactive", type: "bool", def: false, group: "accessibility",
|
||||||
|
label: "Dim inactive windows",
|
||||||
|
detail: "Darkens every window except the focused one, so the active window is unmistakable",
|
||||||
|
hypr: { path: ["decoration", "dim_inactive"], option: "decoration:dim_inactive", readAs: "bool" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "dimStrength", type: "real", def: 0.5, min: 0.05, max: 0.9, step: 0.05,
|
||||||
|
group: "accessibility",
|
||||||
|
label: "Dim amount",
|
||||||
|
detail: "How much darker unfocused windows are",
|
||||||
|
hypr: { path: ["decoration", "dim_strength"], option: "decoration:dim_strength", readAs: "float" }
|
||||||
|
},
|
||||||
|
|
||||||
// ── Night light ─────────────────────────────────────────────────────
|
// ── Night light ─────────────────────────────────────────────────────
|
||||||
{
|
{
|
||||||
key: "nightLightEnabled", type: "bool", def: false, group: "nightLight",
|
key: "nightLightEnabled", type: "bool", def: false, group: "nightLight",
|
||||||
@@ -406,6 +812,24 @@ Singleton {
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── Application themes ─────────────────────────────────────────────
|
||||||
|
// ColorScheme owns GTK's light/dark theme. These are the two theme
|
||||||
|
// choices GNOME applications expose independently of that palette:
|
||||||
|
// their icons and pointer. DesktopStyle only accepts names found in
|
||||||
|
// the read-only XDG catalog before storing them.
|
||||||
|
{
|
||||||
|
key: "cursorTheme", type: "string", def: "oreo_blue_cursors", group: "themes",
|
||||||
|
pattern: "^[A-Za-z0-9 ._+@'-]{1,96}$",
|
||||||
|
label: "Pointer theme",
|
||||||
|
detail: "The pointer design used by applications and Hyprland"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "iconTheme", type: "string", def: "Adwaita", group: "themes",
|
||||||
|
pattern: "^[A-Za-z0-9 ._+@'-]{1,96}$",
|
||||||
|
label: "Application icons",
|
||||||
|
detail: "The icon set used by GTK applications"
|
||||||
|
},
|
||||||
|
|
||||||
// ── Typography ──────────────────────────────────────────────────────
|
// ── Typography ──────────────────────────────────────────────────────
|
||||||
// The single largest thing in this desktop that used to be changeable
|
// The single largest thing in this desktop that used to be changeable
|
||||||
// only by editing Theme.qml.
|
// only by editing Theme.qml.
|
||||||
@@ -435,6 +859,91 @@ Singleton {
|
|||||||
label: "Interface text size",
|
label: "Interface text size",
|
||||||
detail: "The base size the rest of the shell's type scales from"
|
detail: "The base size the rest of the shell's type scales from"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "applicationFont", type: "string", def: "Adwaita Sans", group: "typography",
|
||||||
|
pattern: "^[A-Za-z0-9 ._+@'-]{1,96}$",
|
||||||
|
label: "Application font",
|
||||||
|
detail: "Used by menus, controls, and labels in applications"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "applicationFontSize", type: "int", def: 11, min: 6, max: 32, step: 1,
|
||||||
|
unit: "pt", group: "typography",
|
||||||
|
label: "Application text size",
|
||||||
|
detail: "The base text size used by applications"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "documentFont", type: "string", def: "Adwaita Sans", group: "typography",
|
||||||
|
pattern: "^[A-Za-z0-9 ._+@'-]{1,96}$",
|
||||||
|
label: "Document font",
|
||||||
|
detail: "Used for document content when an application follows the system choice"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "documentFontSize", type: "int", def: 12, min: 6, max: 32, step: 1,
|
||||||
|
unit: "pt", group: "typography",
|
||||||
|
label: "Document text size",
|
||||||
|
detail: "The default text size for document content"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "monospaceFont", type: "string", def: "VictorMono Nerd Font", group: "typography",
|
||||||
|
pattern: "^[A-Za-z0-9 ._+@'-]{1,96}$",
|
||||||
|
label: "Monospace font",
|
||||||
|
detail: "Used by terminals, editors, and code fields that follow the system choice"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "monospaceFontSize", type: "int", def: 10, min: 6, max: 32, step: 1,
|
||||||
|
unit: "pt", group: "typography",
|
||||||
|
label: "Monospace text size",
|
||||||
|
detail: "The default text size for terminals and code"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "fontHinting", type: "enum", def: "slight", group: "typography",
|
||||||
|
label: "Font hinting",
|
||||||
|
detail: "How strongly text aligns to the pixel grid",
|
||||||
|
options: [
|
||||||
|
{ value: "none", label: "None" },
|
||||||
|
{ value: "slight", label: "Slight" },
|
||||||
|
{ value: "medium", label: "Medium" },
|
||||||
|
{ value: "full", label: "Full" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "fontAntialiasing", type: "enum", def: "rgba", group: "typography",
|
||||||
|
label: "Text smoothing",
|
||||||
|
detail: "How application text softens its edges",
|
||||||
|
options: [
|
||||||
|
{ value: "none", label: "None" },
|
||||||
|
{ value: "grayscale", label: "Grayscale" },
|
||||||
|
{ value: "rgba", label: "Subpixel" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Application titlebars ──────────────────────────────────────────
|
||||||
|
// These affect applications that honour GNOME's window preferences.
|
||||||
|
// Hyprland itself has no server-side titlebar buttons, so minimize is
|
||||||
|
// deliberately absent rather than presented as a switch that lies.
|
||||||
|
{
|
||||||
|
key: "titlebarButtonSide", type: "enum", def: "right", group: "titlebar",
|
||||||
|
label: "Button side",
|
||||||
|
detail: "Place application titlebar buttons on the left or right",
|
||||||
|
options: [
|
||||||
|
{ value: "left", label: "Left" },
|
||||||
|
{ value: "right", label: "Right" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "titlebarMaximizeButton", type: "bool", def: false, group: "titlebar",
|
||||||
|
label: "Maximize button",
|
||||||
|
detail: "Show a maximize button in application titlebars that support it"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "titlebarDoubleClick", type: "enum", def: "toggle-maximize", group: "titlebar",
|
||||||
|
label: "Double-click titlebar",
|
||||||
|
detail: "Choose what a double-click on an application titlebar does",
|
||||||
|
options: [
|
||||||
|
{ value: "toggle-maximize", label: "Toggle maximize" },
|
||||||
|
{ value: "none", label: "Do nothing" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
// ── Accessibility ───────────────────────────────────────────────────
|
// ── Accessibility ───────────────────────────────────────────────────
|
||||||
// Backed by gsettings so GTK applications agree with the shell, and
|
// Backed by gsettings so GTK applications agree with the shell, and
|
||||||
|
|||||||
@@ -4,3 +4,4 @@ singleton PreferenceSchema 1.0 PreferenceSchema.qml
|
|||||||
singleton HomePreferences 1.0 HomePreferences.qml
|
singleton HomePreferences 1.0 HomePreferences.qml
|
||||||
singleton Settings 1.0 Settings.qml
|
singleton Settings 1.0 Settings.qml
|
||||||
singleton Theme 1.0 Theme.qml
|
singleton Theme 1.0 Theme.qml
|
||||||
|
singleton Migrations 1.0 Migrations.qml
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import QtQuick
|
||||||
|
|
||||||
|
import qs.services
|
||||||
|
|
||||||
|
ShellRoot {
|
||||||
|
// The production singleton still performs its delayed startup scan. The
|
||||||
|
// harness turns it off before its 2200 ms deadline so each fixture drives
|
||||||
|
// only the state transition it is asserting.
|
||||||
|
Component.onCompleted: Health.startupScanEnabled = false
|
||||||
|
|
||||||
|
IpcHandler {
|
||||||
|
target: "health-test"
|
||||||
|
|
||||||
|
function accept(text: string, generation: int): bool { return Health.consumeSnapshot(text, generation); }
|
||||||
|
function queue(): void { Health.refresh(); Health.refresh(); }
|
||||||
|
function status(): string { return JSON.stringify(Health.diagnostics()); }
|
||||||
|
function repair(id: string): bool { return Health.repair(id, false); }
|
||||||
|
function report(): string { return JSON.stringify(Health.snapshot, null, 2); }
|
||||||
|
function copy(): bool { return Health.copyReport(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// Exercises Migrations.applyWith against fixture steps and prints a verdict per
|
||||||
|
// case. Run by tests/quickshell/migrations-contract.sh.
|
||||||
|
//
|
||||||
|
// Fixture steps rather than the real list: the real one is empty until the
|
||||||
|
// first breaking schema change, and a mechanism that has never been run against
|
||||||
|
// a failing step is not one to discover the behaviour of during an upgrade.
|
||||||
|
|
||||||
|
import Quickshell
|
||||||
|
import QtQuick
|
||||||
|
import qs.config
|
||||||
|
|
||||||
|
ShellRoot {
|
||||||
|
Component.onCompleted: {
|
||||||
|
const steps = [
|
||||||
|
{ version: 2, describe: "add b", migrate: v => { v.b = (v.a ?? 0) + 1; return v; } },
|
||||||
|
{ version: 3, describe: "add c", migrate: v => { v.c = "three"; return v; } },
|
||||||
|
{ version: 4, describe: "explode", migrate: v => { throw new Error("boom"); } }
|
||||||
|
];
|
||||||
|
const results = {};
|
||||||
|
|
||||||
|
// No version at all: a file written before versioning. Stamped, never
|
||||||
|
// migrated.
|
||||||
|
let r = Migrations.applyWith({ a: 1 }, steps, 3, 1);
|
||||||
|
results.unversioned = { v: r.values.schemaVersion, migrated: r.migrated,
|
||||||
|
changed: r.changed, untouched: r.values.b === undefined };
|
||||||
|
|
||||||
|
// Older file: every step above its version runs, in order.
|
||||||
|
r = Migrations.applyWith({ a: 1, schemaVersion: 1 }, steps, 3, 1);
|
||||||
|
results.upgrade = { v: r.values.schemaVersion, b: r.values.b, c: r.values.c,
|
||||||
|
count: r.applied.length, migrated: r.migrated };
|
||||||
|
|
||||||
|
// Already current: nothing runs.
|
||||||
|
r = Migrations.applyWith({ schemaVersion: 3, keep: "me" }, steps, 3, 1);
|
||||||
|
results.current = { v: r.values.schemaVersion, migrated: r.migrated,
|
||||||
|
kept: r.values.keep === "me", b: r.values.b === undefined };
|
||||||
|
|
||||||
|
// From the future: left completely alone, including its unknown keys.
|
||||||
|
r = Migrations.applyWith({ schemaVersion: 9, futureKey: "x" }, steps, 3, 1);
|
||||||
|
results.future = { v: r.values.schemaVersion, migrated: r.migrated,
|
||||||
|
changed: r.changed, kept: r.values.futureKey === "x" };
|
||||||
|
|
||||||
|
// A failing step stops at the last good version rather than losing the
|
||||||
|
// file or skipping past the failure forever.
|
||||||
|
r = Migrations.applyWith({ schemaVersion: 1, a: 5 }, steps, 4, 1);
|
||||||
|
results.failure = { v: r.values.schemaVersion, b: r.values.b, c: r.values.c,
|
||||||
|
kept: r.values.a === 5, count: r.applied.length };
|
||||||
|
|
||||||
|
// Unknown keys survive a migration: rolling back to an older Panama
|
||||||
|
// must not discard a newer version's settings.
|
||||||
|
r = Migrations.applyWith({ schemaVersion: 1, unknownFromFuture: true }, steps, 3, 1);
|
||||||
|
results.preserved = { kept: r.values.unknownFromFuture === true };
|
||||||
|
|
||||||
|
console.info("PANAMA-MIGRATIONS " + JSON.stringify(results));
|
||||||
|
Qt.callLater(() => Qt.quit());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -108,6 +108,10 @@ PanelWindow {
|
|||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
}
|
}
|
||||||
|
|
||||||
|
HealthIndicator {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
}
|
||||||
|
|
||||||
ActivityIndicator {
|
ActivityIndicator {
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
// A deliberately absent-until-needed health affordance. Healthy and merely
|
||||||
|
// unconfigured systems leave no ornament or layout residue in the bar.
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
|
import qs.config
|
||||||
|
import qs.services
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
signal activated
|
||||||
|
|
||||||
|
readonly property int issueCount: Health.summary.warnings + Health.summary.errors
|
||||||
|
readonly property color tone: Health.status === "error"
|
||||||
|
? Theme.danger
|
||||||
|
: (Health.status === "warning" ? Theme.warn : "transparent")
|
||||||
|
readonly property string statusText: root.issueCount === 1
|
||||||
|
? "1 system health issue"
|
||||||
|
: root.issueCount + " system health issues"
|
||||||
|
readonly property string accessibleLabel: Health.status === "error"
|
||||||
|
? "System Health: " + root.issueCount + (root.issueCount === 1 ? " issue requires action" : " issues require action")
|
||||||
|
: "System Health: " + root.issueCount + (root.issueCount === 1 ? " issue needs attention" : " issues need attention")
|
||||||
|
readonly property string tooltipText: root.accessibleLabel
|
||||||
|
|
||||||
|
visible: Health.actionable
|
||||||
|
implicitWidth: visible ? content.implicitWidth + 16 : 0
|
||||||
|
implicitHeight: visible ? 24 : 0
|
||||||
|
width: implicitWidth
|
||||||
|
height: implicitHeight
|
||||||
|
radius: 9
|
||||||
|
color: visible ? Theme.alpha(root.tone, 0.09) : "transparent"
|
||||||
|
border.width: activeFocus ? 2 : 1
|
||||||
|
border.color: visible ? Theme.alpha(root.tone, activeFocus ? 0.72 : 0.20) : "transparent"
|
||||||
|
|
||||||
|
activeFocusOnTab: visible
|
||||||
|
Accessible.role: Accessible.Button
|
||||||
|
Accessible.name: root.accessibleLabel
|
||||||
|
Accessible.description: "Open System Health"
|
||||||
|
Accessible.onPressAction: root.activated()
|
||||||
|
|
||||||
|
onActivated: {
|
||||||
|
ShellState.openSettings("services");
|
||||||
|
Health.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
Keys.onReturnPressed: root.activated()
|
||||||
|
Keys.onEnterPressed: root.activated()
|
||||||
|
Keys.onSpacePressed: root.activated()
|
||||||
|
|
||||||
|
Row {
|
||||||
|
id: content
|
||||||
|
|
||||||
|
anchors.centerIn: parent
|
||||||
|
spacing: 6
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: "\u{F0ECD}" // md-shield-alert-outline
|
||||||
|
color: root.tone
|
||||||
|
font.family: Theme.fontMono
|
||||||
|
font.pixelSize: 14
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: String(root.issueCount)
|
||||||
|
color: root.tone
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.features: Theme.tabularFigures
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
font.weight: Font.DemiBold
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: pointer
|
||||||
|
|
||||||
|
anchors.fill: parent
|
||||||
|
enabled: root.visible
|
||||||
|
hoverEnabled: true
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
onClicked: root.activated()
|
||||||
|
}
|
||||||
|
|
||||||
|
ToolTip.visible: pointer.containsMouse && root.visible
|
||||||
|
ToolTip.delay: 500
|
||||||
|
ToolTip.text: root.tooltipText
|
||||||
|
}
|
||||||
@@ -4,11 +4,20 @@ import qs.config
|
|||||||
import qs.services
|
import qs.services
|
||||||
|
|
||||||
SettingsPage {
|
SettingsPage {
|
||||||
title: "About Panama"
|
id: root
|
||||||
|
|
||||||
|
title: "About"
|
||||||
lede: "A curated Hyprland desktop built around focus, speed, and good taste."
|
lede: "A curated Hyprland desktop built around focus, speed, and good taste."
|
||||||
|
|
||||||
|
Component.onCompleted: {
|
||||||
|
if (!MachineInfo.scanned)
|
||||||
|
MachineInfo.refresh();
|
||||||
|
if (GraphicsDevices.devices.length === 0)
|
||||||
|
GraphicsDevices.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Panama Desktop"
|
title: "Desktop"
|
||||||
subtitle: "Tokyo Night Moon · Prism glass · native tiling"
|
subtitle: "Tokyo Night Moon · Prism glass · native tiling"
|
||||||
|
|
||||||
TextRow {
|
TextRow {
|
||||||
@@ -31,6 +40,59 @@ SettingsPage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// What GNOME's About panel answers and this page did not: what am I running
|
||||||
|
// on. Rows come from MachineInfo, except graphics, which is joined from
|
||||||
|
// GraphicsDevices rather than read a second time -- two readouts of the same
|
||||||
|
// hardware are two things that can disagree.
|
||||||
|
//
|
||||||
|
// The join splices the GPU rows in directly after Processor rather than
|
||||||
|
// appending them, so the card reads the way fastfetch does: what the system
|
||||||
|
// is, then what is installed on it, then the hardware underneath. A GPU
|
||||||
|
// listed after "Disk" reads as an afterthought.
|
||||||
|
readonly property var machineRows: {
|
||||||
|
const rows = (MachineInfo.facts ?? []).slice();
|
||||||
|
const gpus = GraphicsDevices.devices ?? [];
|
||||||
|
if (gpus.length === 0)
|
||||||
|
return rows;
|
||||||
|
|
||||||
|
const graphics = gpus.map((device, index) => ({
|
||||||
|
label: gpus.length > 1 ? "Graphics " + (index + 1) : "Graphics",
|
||||||
|
value: device.name
|
||||||
|
}));
|
||||||
|
|
||||||
|
const after = rows.findIndex(row => row.label === "Processor");
|
||||||
|
if (after < 0)
|
||||||
|
return rows.concat(graphics);
|
||||||
|
return rows.slice(0, after + 1).concat(graphics, rows.slice(after + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
title: "This machine"
|
||||||
|
subtitle: "Hardware and system, as the kernel reports it."
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: root.machineRows
|
||||||
|
|
||||||
|
TextRow {
|
||||||
|
id: machineRow
|
||||||
|
required property var modelData
|
||||||
|
required property int index
|
||||||
|
|
||||||
|
label: machineRow.modelData.label
|
||||||
|
value: machineRow.modelData.value
|
||||||
|
divider: machineRow.index < root.machineRows.length - 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TextRow {
|
||||||
|
visible: MachineInfo.scanned && root.machineRows.length === 0
|
||||||
|
label: "Hardware"
|
||||||
|
detail: "The system did not report anything readable"
|
||||||
|
value: "Unavailable"
|
||||||
|
divider: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Design principles"
|
title: "Design principles"
|
||||||
|
|
||||||
|
|||||||
@@ -25,32 +25,61 @@ SettingsPage {
|
|||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Text"
|
title: "Text"
|
||||||
subtitle: "Scales text in applications. Panama's own panels are drawn at their design size, so the shell is unaffected."
|
subtitle: "Scales text in applications. The shell's own panels are drawn at their design size, so they are unaffected."
|
||||||
|
|
||||||
SliderRow { setting: "textScale"; divider: false }
|
SliderRow { setting: "textScale"; divider: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Motion"
|
title: "Motion"
|
||||||
subtitle: "Panama never animates while idle. This affects motion you asked for — windows opening, workspaces sliding, panels appearing."
|
subtitle: "Nothing animates while idle. This affects motion you asked for — windows opening, workspaces sliding, panels appearing."
|
||||||
|
|
||||||
ToggleRow { setting: "animationsEnabled"; divider: false }
|
ToggleRow { setting: "animationsEnabled"; divider: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Zoom, done by the compositor rather than handed to GNOME. Hyprland has a
|
||||||
|
// real magnifier (cursor:zoom_factor) that follows the pointer, so there is
|
||||||
|
// no reason to send someone to another application for it.
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Contrast"
|
title: "Magnifier"
|
||||||
subtitle: "Unfocused windows can be faded to make the focused one obvious, or left at full strength if that is harder to read."
|
subtitle: "Magnifies the screen around the pointer. Set the magnification to 1× to turn it off."
|
||||||
|
|
||||||
SliderRow { setting: "inactiveOpacity"; divider: false }
|
SliderRow { setting: "magnifierFactor"; zeroLabel: "Off" }
|
||||||
|
ToggleRow { setting: "magnifierRigid"; divider: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "System accessibility"
|
title: "Contrast"
|
||||||
subtitle: "Screen reader, zoom, and on-screen keyboard are provided by GNOME's accessibility stack."
|
subtitle: "Unfocused windows can be faded or darkened to make the focused one obvious, or left alone if that is harder to read."
|
||||||
|
|
||||||
|
SliderRow { setting: "inactiveOpacity" }
|
||||||
|
ToggleRow { setting: "dimInactive" }
|
||||||
|
SliderRow { setting: "dimStrength"; divider: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
// What this session genuinely cannot do, said plainly.
|
||||||
|
//
|
||||||
|
// Sticky keys, slow keys, bounce keys and mouse keys are AccessX, which is
|
||||||
|
// an X11 SERVER feature. XKB under Wayland has no accessx option group at
|
||||||
|
// all, and Hyprland does not implement one. The compositor will accept
|
||||||
|
// "accessx:enable" as a keyboard option and store it, and nothing will ever
|
||||||
|
// act on it -- so there is no switch here, and pointing at GNOME's panel
|
||||||
|
// would be no better, since the daemon that applies those keys is not
|
||||||
|
// running either.
|
||||||
|
SettingsCard {
|
||||||
|
title: "Keyboard accessibility"
|
||||||
|
subtitle: "Sticky, slow and bounce keys are an X11 feature with no Wayland equivalent, so they are unavailable in this session. Offering them here would store a preference that nothing acts on."
|
||||||
|
|
||||||
|
ActionRow {
|
||||||
|
label: "Screen reader"
|
||||||
|
detail: "Orca reads the screen aloud and works over the accessibility bus, which does run here"
|
||||||
|
action: "Start Orca"
|
||||||
|
onTriggered: SystemSettings.openApplication("orca")
|
||||||
|
}
|
||||||
|
|
||||||
ActionRow {
|
ActionRow {
|
||||||
label: "GNOME accessibility settings"
|
label: "GNOME accessibility settings"
|
||||||
detail: "Opens in GNOME Settings"
|
detail: "For the parts GNOME's own stack still owns"
|
||||||
action: "Open"
|
action: "Open"
|
||||||
divider: false
|
divider: false
|
||||||
onTriggered: SystemSettings.openGnomePanel("universal-access")
|
onTriggered: SystemSettings.openGnomePanel("universal-access")
|
||||||
|
|||||||
@@ -17,8 +17,10 @@ import qs.services
|
|||||||
SettingsPage {
|
SettingsPage {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
|
property string expandedPicker: ""
|
||||||
|
|
||||||
title: "Appearance"
|
title: "Appearance"
|
||||||
lede: "Drag anything below. The preview above is your real geometry, to scale."
|
lede: "Tune the Prism shell and the applications that live inside it. The preview above is your real geometry, to scale."
|
||||||
|
|
||||||
header: Component {
|
header: Component {
|
||||||
Column {
|
Column {
|
||||||
@@ -46,7 +48,7 @@ SettingsPage {
|
|||||||
title: "Background"
|
title: "Background"
|
||||||
subtitle: Wallpaper.lastError !== ""
|
subtitle: Wallpaper.lastError !== ""
|
||||||
? Wallpaper.lastError
|
? Wallpaper.lastError
|
||||||
: "Applied to every display. Panama looks in ~/Pictures/Wallpapers, ~/Pictures/Backgrounds, ~/.local/share/backgrounds, and /usr/share/backgrounds."
|
: "Applied to every display. Looked for in ~/Pictures/Wallpapers, ~/Pictures/Backgrounds, ~/.local/share/backgrounds, and /usr/share/backgrounds."
|
||||||
|
|
||||||
WallpaperPicker {
|
WallpaperPicker {
|
||||||
id: wallpapers
|
id: wallpapers
|
||||||
@@ -83,7 +85,7 @@ SettingsPage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Typography"
|
title: "Shell typography"
|
||||||
subtitle: Fonts.lastError !== ""
|
subtitle: Fonts.lastError !== ""
|
||||||
? Fonts.lastError
|
? Fonts.lastError
|
||||||
: "Every piece of text in the shell. Samples are drawn in the font they name."
|
: "Every piece of text in the shell. Samples are drawn in the font they name."
|
||||||
@@ -123,6 +125,137 @@ SettingsPage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
title: "Application typography"
|
||||||
|
subtitle: DesktopStyle.lastError !== ""
|
||||||
|
? DesktopStyle.lastError
|
||||||
|
: "Fonts used by applications that follow the desktop defaults. Open one family at a time to keep the page calm."
|
||||||
|
|
||||||
|
ActionRow {
|
||||||
|
label: "Application font"
|
||||||
|
detail: DesktopStyle.applicationFont
|
||||||
|
action: root.expandedPicker === "application-font" ? "Close" : "Choose"
|
||||||
|
onTriggered: root.expandedPicker = root.expandedPicker === "application-font" ? "" : "application-font"
|
||||||
|
}
|
||||||
|
|
||||||
|
FontPicker {
|
||||||
|
visible: root.expandedPicker === "application-font"
|
||||||
|
width: parent.width
|
||||||
|
families: Fonts.interfaceFonts
|
||||||
|
current: DesktopStyle.applicationFont
|
||||||
|
emptyText: Fonts.scanning ? "Reading installed fonts…" : "No application fonts found"
|
||||||
|
onPicked: family => {
|
||||||
|
if (DesktopStyle.setApplicationFont(family))
|
||||||
|
root.expandedPicker = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SliderRow { setting: "applicationFontSize" }
|
||||||
|
|
||||||
|
ActionRow {
|
||||||
|
label: "Document font"
|
||||||
|
detail: DesktopStyle.documentFont
|
||||||
|
action: root.expandedPicker === "document-font" ? "Close" : "Choose"
|
||||||
|
onTriggered: root.expandedPicker = root.expandedPicker === "document-font" ? "" : "document-font"
|
||||||
|
}
|
||||||
|
|
||||||
|
FontPicker {
|
||||||
|
visible: root.expandedPicker === "document-font"
|
||||||
|
width: parent.width
|
||||||
|
families: Fonts.interfaceFonts
|
||||||
|
current: DesktopStyle.documentFont
|
||||||
|
emptyText: Fonts.scanning ? "Reading installed fonts…" : "No document fonts found"
|
||||||
|
onPicked: family => {
|
||||||
|
if (DesktopStyle.setDocumentFont(family))
|
||||||
|
root.expandedPicker = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SliderRow { setting: "documentFontSize" }
|
||||||
|
|
||||||
|
ActionRow {
|
||||||
|
label: "Monospace font"
|
||||||
|
detail: DesktopStyle.monospaceFont
|
||||||
|
action: root.expandedPicker === "monospace-font" ? "Close" : "Choose"
|
||||||
|
onTriggered: root.expandedPicker = root.expandedPicker === "monospace-font" ? "" : "monospace-font"
|
||||||
|
}
|
||||||
|
|
||||||
|
FontPicker {
|
||||||
|
visible: root.expandedPicker === "monospace-font"
|
||||||
|
width: parent.width
|
||||||
|
families: Fonts.monospaceFonts
|
||||||
|
current: DesktopStyle.monospaceFont
|
||||||
|
emptyText: Fonts.scanning ? "Reading installed fonts…" : "No monospace fonts found"
|
||||||
|
onPicked: family => {
|
||||||
|
if (DesktopStyle.setMonospaceFont(family))
|
||||||
|
root.expandedPicker = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SliderRow { setting: "monospaceFontSize" }
|
||||||
|
ChoiceRow { setting: "fontHinting" }
|
||||||
|
ChoiceRow { setting: "fontAntialiasing"; divider: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
title: "Icons & pointer"
|
||||||
|
subtitle: DesktopStyle.lastError !== ""
|
||||||
|
? DesktopStyle.lastError
|
||||||
|
: "Installed themes only. The pointer updates in applications and Hyprland together."
|
||||||
|
|
||||||
|
ActionRow {
|
||||||
|
label: "Application icons"
|
||||||
|
detail: DesktopStyle.iconTheme
|
||||||
|
action: root.expandedPicker === "icon-theme" ? "Close" : "Choose"
|
||||||
|
enabled: DesktopStyle.catalogLoaded
|
||||||
|
onTriggered: root.expandedPicker = root.expandedPicker === "icon-theme" ? "" : "icon-theme"
|
||||||
|
}
|
||||||
|
|
||||||
|
SearchPicker {
|
||||||
|
visible: root.expandedPicker === "icon-theme"
|
||||||
|
width: parent.width
|
||||||
|
items: DesktopStyle.iconThemes
|
||||||
|
current: DesktopStyle.iconTheme
|
||||||
|
placeholder: "Search icon themes"
|
||||||
|
emptyText: DesktopStyle.scanning ? "Reading installed icon themes…" : "No icon themes found"
|
||||||
|
onPicked: value => {
|
||||||
|
if (DesktopStyle.setIconTheme(value))
|
||||||
|
root.expandedPicker = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ActionRow {
|
||||||
|
label: "Pointer theme"
|
||||||
|
detail: DesktopStyle.cursorTheme
|
||||||
|
action: root.expandedPicker === "cursor-theme" ? "Close" : "Choose"
|
||||||
|
enabled: DesktopStyle.catalogLoaded
|
||||||
|
divider: false
|
||||||
|
onTriggered: root.expandedPicker = root.expandedPicker === "cursor-theme" ? "" : "cursor-theme"
|
||||||
|
}
|
||||||
|
|
||||||
|
SearchPicker {
|
||||||
|
visible: root.expandedPicker === "cursor-theme"
|
||||||
|
width: parent.width
|
||||||
|
items: DesktopStyle.cursorThemes
|
||||||
|
current: DesktopStyle.cursorTheme
|
||||||
|
placeholder: "Search pointer themes"
|
||||||
|
emptyText: DesktopStyle.scanning ? "Reading installed pointer themes…" : "No pointer themes found"
|
||||||
|
onPicked: value => {
|
||||||
|
if (DesktopStyle.setCursorTheme(value))
|
||||||
|
root.expandedPicker = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
title: "Titlebars"
|
||||||
|
subtitle: "For applications that draw GNOME-compatible titlebars. Hyprland itself does not add titlebar buttons to tiled windows."
|
||||||
|
|
||||||
|
ChoiceRow { setting: "titlebarButtonSide" }
|
||||||
|
ToggleRow { setting: "titlebarMaximizeButton" }
|
||||||
|
ChoiceRow { setting: "titlebarDoubleClick"; divider: false }
|
||||||
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Windows"
|
title: "Windows"
|
||||||
subtitle: "Spacing and shape of tiled windows. Each change is applied to the compositor and confirmed before it is saved."
|
subtitle: "Spacing and shape of tiled windows. Each change is applied to the compositor and confirmed before it is saved."
|
||||||
@@ -131,7 +264,10 @@ SettingsPage {
|
|||||||
SliderRow { setting: "gapsIn" }
|
SliderRow { setting: "gapsIn" }
|
||||||
SliderRow { setting: "gapsOut" }
|
SliderRow { setting: "gapsOut" }
|
||||||
SliderRow { setting: "borderSize"; zeroLabel: "None" }
|
SliderRow { setting: "borderSize"; zeroLabel: "None" }
|
||||||
SliderRow { setting: "inactiveOpacity"; divider: false }
|
SliderRow { setting: "roundingPower" }
|
||||||
|
SliderRow { setting: "inactiveOpacity" }
|
||||||
|
SliderRow { setting: "activeOpacity" }
|
||||||
|
SliderRow { setting: "fullscreenOpacity"; divider: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
@@ -143,6 +279,8 @@ SettingsPage {
|
|||||||
SliderRow { setting: "blurPasses" }
|
SliderRow { setting: "blurPasses" }
|
||||||
ToggleRow { setting: "shadowEnabled" }
|
ToggleRow { setting: "shadowEnabled" }
|
||||||
SliderRow { setting: "shadowRange"; zeroLabel: "None" }
|
SliderRow { setting: "shadowRange"; zeroLabel: "None" }
|
||||||
|
SliderRow { setting: "shadowRenderPower" }
|
||||||
|
ToggleRow { setting: "shadowSharp" }
|
||||||
ToggleRow { setting: "glowEnabled" }
|
ToggleRow { setting: "glowEnabled" }
|
||||||
SliderRow { setting: "glowRange"; zeroLabel: "None" }
|
SliderRow { setting: "glowRange"; zeroLabel: "None" }
|
||||||
ToggleRow { setting: "animationsEnabled"; divider: false }
|
ToggleRow { setting: "animationsEnabled"; divider: false }
|
||||||
@@ -182,11 +320,4 @@ SettingsPage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
SettingsCard {
|
|
||||||
title: "Theme"
|
|
||||||
subtitle: "Panama has one curated visual identity rather than a matrix of partially compatible themes. The controls above adjust its parameters — how much space, how soft, how much motion — without replacing it."
|
|
||||||
|
|
||||||
TextRow { label: "Color palette"; detail: "Tokyo Night Moon"; value: "Prism" }
|
|
||||||
TextRow { label: "Interface type"; detail: "Adwaita Sans"; value: "System"; divider: false }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ SettingsPage {
|
|||||||
lede: "Choose what opens your files and links, and what starts with your session."
|
lede: "Choose what opens your files and links, and what starts with your session."
|
||||||
|
|
||||||
property string expandedRole: ""
|
property string expandedRole: ""
|
||||||
|
property bool addingAutostart: false
|
||||||
readonly property var applications: DesktopEntries.applications.values
|
readonly property var applications: DesktopEntries.applications.values
|
||||||
readonly property var roles: [
|
readonly property var roles: [
|
||||||
{ key: "browser", label: "Browser", detail: "Web links and HTML pages", categorySets: [["webbrowser"]], terms: ["web browser", "browser"] },
|
{ key: "browser", label: "Browser", detail: "Web links and HTML pages", categorySets: [["webbrowser"]], terms: ["web browser", "browser"] },
|
||||||
@@ -142,9 +143,64 @@ SettingsPage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GNOME's Search panel, answered honestly.
|
||||||
|
//
|
||||||
|
// It configures which applications provide results in gnome-shell's
|
||||||
|
// overview and which folders are indexed. gnome-shell does not run here,
|
||||||
|
// so those settings would do nothing. Under Panama searching is the
|
||||||
|
// launcher's job, and Vicinae carries its own preferences -- reimplementing
|
||||||
|
// them here would give two places to change one thing.
|
||||||
|
SettingsCard {
|
||||||
|
title: "Search"
|
||||||
|
subtitle: "Applications, files, the calculator, clipboard history, emoji, and open windows are all searched from the launcher."
|
||||||
|
|
||||||
|
TextRow {
|
||||||
|
label: "Launcher"
|
||||||
|
detail: SystemSettings.vicinaeActive
|
||||||
|
? "Running as a user service"
|
||||||
|
: "Not running — Super+Shift+R opens the fallback launcher"
|
||||||
|
value: "Vicinae"
|
||||||
|
}
|
||||||
|
|
||||||
|
TextRow {
|
||||||
|
label: "Open search"
|
||||||
|
detail: "Three keys open it, because Super+A and Super+R were GNOME's app grid and run dialog"
|
||||||
|
value: "Super+Space"
|
||||||
|
}
|
||||||
|
|
||||||
|
ActionRow {
|
||||||
|
label: "Change these shortcuts"
|
||||||
|
detail: "Every launcher chord is rebindable, including clipboard history and emoji"
|
||||||
|
action: "Open keyboard"
|
||||||
|
divider: false
|
||||||
|
onTriggered: ShellState.openSettings("shortcuts")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "User autostart"
|
title: "User autostart"
|
||||||
subtitle: "These desktop entries live in your user configuration. Select a row to toggle it."
|
subtitle: "Choose what starts with your session. Entries live in your user configuration, not the compositor."
|
||||||
|
|
||||||
|
ActionRow {
|
||||||
|
label: "Add an application"
|
||||||
|
detail: root.addingAutostart
|
||||||
|
? "Search the applications installed on this machine"
|
||||||
|
: "Start another installed application when you sign in"
|
||||||
|
action: root.addingAutostart ? "Close" : "Choose"
|
||||||
|
divider: !root.addingAutostart || DefaultApps.autostartEntries.length > 0
|
||||||
|
enabled: !DefaultApps.busy
|
||||||
|
onTriggered: root.addingAutostart = !root.addingAutostart
|
||||||
|
}
|
||||||
|
|
||||||
|
AutostartAppPicker {
|
||||||
|
visible: root.addingAutostart
|
||||||
|
width: parent.width
|
||||||
|
existing: DefaultApps.autostartEntries.map(entry => entry.id)
|
||||||
|
onPicked: id => {
|
||||||
|
DefaultApps.addAutostart(id);
|
||||||
|
root.addingAutostart = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
TextRow {
|
TextRow {
|
||||||
visible: !DefaultApps.busy && DefaultApps.autostartEntries.length === 0
|
visible: !DefaultApps.busy && DefaultApps.autostartEntries.length === 0
|
||||||
@@ -175,7 +231,7 @@ SettingsPage {
|
|||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Compositor autostart"
|
title: "Compositor autostart"
|
||||||
subtitle: "Panama starts these from Hyprland configuration. They are read-only here."
|
subtitle: "These are started from the Hyprland configuration. They are read-only here."
|
||||||
|
|
||||||
TextRow {
|
TextRow {
|
||||||
visible: !DefaultApps.busy && DefaultApps.luaAutostartEntries.length === 0
|
visible: !DefaultApps.busy && DefaultApps.luaAutostartEntries.length === 0
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
// Adds an installed application to the user's freedesktop autostart directory.
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import Quickshell
|
||||||
|
import qs.config
|
||||||
|
import qs.modules.clipboard
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
required property var existing
|
||||||
|
signal picked(string id)
|
||||||
|
|
||||||
|
spacing: 0
|
||||||
|
|
||||||
|
function desktopId(entry: var): string {
|
||||||
|
const id = String(entry?.id ?? "");
|
||||||
|
return id.endsWith(".desktop") ? id : id + ".desktop";
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly property var matches: {
|
||||||
|
const needle = search.text.trim().toLowerCase();
|
||||||
|
if (needle === "")
|
||||||
|
return [];
|
||||||
|
const out = [];
|
||||||
|
for (const entry of DesktopEntries.applications.values) {
|
||||||
|
const desktopId = root.desktopId(entry);
|
||||||
|
if (entry.noDisplay || root.existing.indexOf(desktopId) >= 0)
|
||||||
|
continue;
|
||||||
|
const haystack = `${entry.name ?? ""} ${entry.genericName ?? ""} ${desktopId}`.toLowerCase();
|
||||||
|
if (haystack.indexOf(needle) >= 0)
|
||||||
|
out.push(entry);
|
||||||
|
if (out.length >= 8)
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
SearchField {
|
||||||
|
id: search
|
||||||
|
width: parent.width
|
||||||
|
placeholder: "Search installed applications"
|
||||||
|
}
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: root.matches
|
||||||
|
|
||||||
|
SettingRow {
|
||||||
|
id: candidate
|
||||||
|
|
||||||
|
required property var modelData
|
||||||
|
required property int index
|
||||||
|
|
||||||
|
label: String(candidate.modelData.name || root.desktopId(candidate.modelData))
|
||||||
|
detail: String(candidate.modelData.genericName || root.desktopId(candidate.modelData))
|
||||||
|
divider: candidate.index < root.matches.length - 1
|
||||||
|
controlWidth: 86
|
||||||
|
|
||||||
|
SettingsButton {
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: "Add"
|
||||||
|
onClicked: {
|
||||||
|
root.picked(root.desktopId(candidate.modelData));
|
||||||
|
search.text = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingRow {
|
||||||
|
visible: search.text.trim() !== "" && root.matches.length === 0
|
||||||
|
label: "No matching applications"
|
||||||
|
detail: "Only installed desktop applications can start with the session"
|
||||||
|
divider: false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,13 +19,18 @@ import qs.services
|
|||||||
SettingsPage {
|
SettingsPage {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
|
|
||||||
title: "Network & Devices"
|
title: "Network & Devices"
|
||||||
lede: Connectivity.activeNetwork
|
lede: Connectivity.activeNetwork
|
||||||
? "Connected to " + Connectivity.activeNetwork.name
|
? "Connected to " + Connectivity.activeNetwork.name
|
||||||
: "Wi-Fi, Bluetooth, and the things Fedora owns."
|
: "Wi-Fi, Bluetooth, and the things Fedora owns."
|
||||||
|
|
||||||
// Drive the scanners only while this page is the one being shown.
|
// Drive the scanners only while this page is the one being shown.
|
||||||
Component.onCompleted: Connectivity.active = true
|
Component.onCompleted: {
|
||||||
|
Connectivity.active = true;
|
||||||
|
if (!WifiShare.scanned)
|
||||||
|
WifiShare.refresh();
|
||||||
|
}
|
||||||
Component.onDestruction: Connectivity.active = false
|
Component.onDestruction: Connectivity.active = false
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
@@ -68,6 +73,69 @@ SettingsPage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sharing a network by QR, the way GNOME's Wi-Fi panel does. The
|
||||||
|
// alternative is reading a passphrase out loud.
|
||||||
|
//
|
||||||
|
// The image holds the password in machine-readable form, so it is generated
|
||||||
|
// on demand rather than up front, and the helper writes it to tmpfs under
|
||||||
|
// XDG_RUNTIME_DIR instead of anywhere persistent.
|
||||||
|
SettingsCard {
|
||||||
|
visible: Connectivity.wifiDevice !== null && WifiShare.shareable.length > 0
|
||||||
|
title: "Share a network"
|
||||||
|
subtitle: WifiShare.sharing !== ""
|
||||||
|
? "Point a phone's camera at the code to join " + WifiShare.sharing + "."
|
||||||
|
: "Shows a QR code a phone can scan to join, without reading the password out."
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: WifiShare.shareable
|
||||||
|
|
||||||
|
ActionRow {
|
||||||
|
id: shareRow
|
||||||
|
required property var modelData
|
||||||
|
required property int index
|
||||||
|
|
||||||
|
label: shareRow.modelData.ssid
|
||||||
|
detail: WifiShare.sharing === shareRow.modelData.name
|
||||||
|
? "Showing a code below — anyone who can see the screen can join"
|
||||||
|
: "Saved network"
|
||||||
|
action: WifiShare.sharing === shareRow.modelData.name ? "Hide" : "Show code"
|
||||||
|
divider: shareRow.index < WifiShare.shareable.length - 1 || WifiShare.sharing !== ""
|
||||||
|
onTriggered: WifiShare.sharing === shareRow.modelData.name
|
||||||
|
? WifiShare.stopSharing()
|
||||||
|
: WifiShare.share(shareRow.modelData.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drawn at its natural size on a white plate: a QR code inverted or
|
||||||
|
// tinted to match a dark theme is unreliable to scan, and this one has
|
||||||
|
// exactly one job.
|
||||||
|
Item {
|
||||||
|
width: parent.width
|
||||||
|
visible: WifiShare.sharing !== "" && WifiShare.imagePath !== ""
|
||||||
|
implicitHeight: visible ? plate.height + 20 : 0
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: plate
|
||||||
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
|
y: 10
|
||||||
|
width: 208
|
||||||
|
height: 208
|
||||||
|
radius: 10
|
||||||
|
color: "white"
|
||||||
|
|
||||||
|
Image {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
width: 184
|
||||||
|
height: 184
|
||||||
|
smooth: false
|
||||||
|
fillMode: Image.PreserveAspectFit
|
||||||
|
cache: false
|
||||||
|
source: WifiShare.imagePath !== "" ? "file://" + WifiShare.imagePath : ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Bluetooth"
|
title: "Bluetooth"
|
||||||
visible: Connectivity.adapter !== null
|
visible: Connectivity.adapter !== null
|
||||||
|
|||||||
@@ -47,18 +47,16 @@ SettingsPage {
|
|||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Window layout"
|
title: "Window layout"
|
||||||
subtitle: "Panama follows the Forge mental model with native Hyprland tiling."
|
subtitle: "Follows the Forge mental model, with native Hyprland tiling."
|
||||||
|
|
||||||
TextRow {
|
// These were two read-only rows reporting "Tiling" and "Dynamic", which
|
||||||
label: "Layout"
|
// described settings rather than facts -- both are ordinary Hyprland
|
||||||
detail: "Dwindle with preserved split direction"
|
// options that simply had no controls. TextRow's own documentation says
|
||||||
value: "Tiling"
|
// a setting the user could reasonably change does not belong in it.
|
||||||
}
|
ChoiceRow { setting: "windowLayout" }
|
||||||
TextRow {
|
ToggleRow { setting: "preserveSplit" }
|
||||||
label: "Workspace movement"
|
ChoiceRow { setting: "forceSplit" }
|
||||||
detail: "Alt+H / Alt+L, add Shift to move a window"
|
ToggleRow { setting: "windowSnapping" }
|
||||||
value: "Dynamic"
|
|
||||||
}
|
|
||||||
ActionRow {
|
ActionRow {
|
||||||
label: "Gaps, corners, and effects"
|
label: "Gaps, corners, and effects"
|
||||||
detail: "Adjusted on the Appearance page, beside a live preview"
|
detail: "Adjusted on the Appearance page, beside a live preview"
|
||||||
@@ -68,6 +66,56 @@ SettingsPage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GNOME's Multitasking panel, in Hyprland's terms.
|
||||||
|
// Only meaningful when the layout above is Master and stack. Hidden
|
||||||
|
// otherwise, because a card of settings that do nothing under the layout
|
||||||
|
// you are actually running is worse than not offering the layout at all.
|
||||||
|
SettingsCard {
|
||||||
|
visible: DesktopPreferences.get("windowLayout") === "master"
|
||||||
|
title: "Master and stack"
|
||||||
|
subtitle: "How the master area behaves. These apply only while the tiling layout above is Master and stack."
|
||||||
|
|
||||||
|
SliderRow { setting: "masterFactor" }
|
||||||
|
ChoiceRow { setting: "masterOrientation" }
|
||||||
|
ChoiceRow { setting: "masterNewStatus" }
|
||||||
|
ToggleRow { setting: "masterNewOnTop"; divider: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
title: "Window edges"
|
||||||
|
subtitle: "How the pointer grabs a window's border, and how floating windows behave near each other and the screen edge."
|
||||||
|
|
||||||
|
ToggleRow { setting: "resizeOnBorder" }
|
||||||
|
SliderRow { setting: "borderGrabArea"; zeroLabel: "Border only" }
|
||||||
|
ToggleRow { setting: "hoverIconOnBorder" }
|
||||||
|
SliderRow { setting: "snapWindowGap"; zeroLabel: "Touching" }
|
||||||
|
SliderRow { setting: "snapMonitorGap"; zeroLabel: "Touching" }
|
||||||
|
ToggleRow { setting: "snapRespectGaps"; divider: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hyprland's own interruptions. Panama turns all four off, which is a
|
||||||
|
// defensible default and was not previously a decision anyone could
|
||||||
|
// reverse without editing looks.lua.
|
||||||
|
SettingsCard {
|
||||||
|
title: "Hyprland notices"
|
||||||
|
subtitle: "Panama hides all of these by default. They are the compositor's own, not Panama's."
|
||||||
|
|
||||||
|
ToggleRow { setting: "hyprlandLogo" }
|
||||||
|
ToggleRow { setting: "hyprlandSplash" }
|
||||||
|
ToggleRow { setting: "hyprlandUpdateNews" }
|
||||||
|
ToggleRow { setting: "hyprlandDonationNag"; divider: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
title: "Workspaces & focus"
|
||||||
|
subtitle: "Hyprland's workspaces are created and destroyed as you use them, so there is no fixed count to set."
|
||||||
|
|
||||||
|
ToggleRow { setting: "workspaceBackAndForth" }
|
||||||
|
ToggleRow { setting: "allowWorkspaceCycles" }
|
||||||
|
ToggleRow { setting: "focusOnActivate" }
|
||||||
|
ToggleRow { setting: "mouseMoveFocusesMonitor"; divider: false }
|
||||||
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Focus"
|
title: "Focus"
|
||||||
|
|
||||||
@@ -111,10 +159,10 @@ SettingsPage {
|
|||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Reset"
|
title: "Reset"
|
||||||
subtitle: "Restores Panama's appearance, dock, clock, focus, and display policy, and clears your Home accessory arrangement. Pinned applications, files, and paired devices are not changed."
|
subtitle: "Restores the appearance, dock, clock, focus, and display policy, and clears your Home accessory arrangement. Pinned applications, files, and paired devices are not changed."
|
||||||
|
|
||||||
ActionRow {
|
ActionRow {
|
||||||
label: "Restore Panama defaults"
|
label: "Restore defaults"
|
||||||
detail: "Applies immediately, including to the compositor"
|
detail: "Applies immediately, including to the compositor"
|
||||||
action: "Restore defaults"
|
action: "Restore defaults"
|
||||||
divider: false
|
divider: false
|
||||||
|
|||||||
@@ -89,9 +89,12 @@ Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The real bar carries a clock here, not a name. Standing in with the
|
||||||
|
// actual time keeps the preview a picture of this desktop rather than
|
||||||
|
// a picture of a product.
|
||||||
Text {
|
Text {
|
||||||
anchors.centerIn: parent
|
anchors.centerIn: parent
|
||||||
text: "Panama"
|
text: Qt.formatTime(new Date(), "h:mm")
|
||||||
color: Theme.alpha(Theme.fg, 0.45)
|
color: Theme.alpha(Theme.fg, 0.45)
|
||||||
font.family: Theme.fontFamily
|
font.family: Theme.fontFamily
|
||||||
font.pixelSize: 9
|
font.pixelSize: 9
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ SettingsPage {
|
|||||||
ActionRow {
|
ActionRow {
|
||||||
visible: Displays.isOverridden(root.monitor ? root.monitor.name : "")
|
visible: Displays.isOverridden(root.monitor ? root.monitor.name : "")
|
||||||
label: "Using a custom display setting"
|
label: "Using a custom display setting"
|
||||||
detail: "Forget it to go back to the resolution and scale Panama ships"
|
detail: "Forget it to go back to the shipped resolution and scale"
|
||||||
action: "Forget"
|
action: "Forget"
|
||||||
divider: false
|
divider: false
|
||||||
onTriggered: Displays.forget(root.monitor.name)
|
onTriggered: Displays.forget(root.monitor.name)
|
||||||
@@ -248,7 +248,7 @@ SettingsPage {
|
|||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Gaming display policy"
|
title: "Gaming display policy"
|
||||||
subtitle: "Applied immediately and restored when Panama starts."
|
subtitle: "Applied immediately and restored when the session starts."
|
||||||
|
|
||||||
ToggleRow { setting: "autoHdr" }
|
ToggleRow { setting: "autoHdr" }
|
||||||
ChoiceRow { setting: "vrrPolicy" }
|
ChoiceRow { setting: "vrrPolicy" }
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import QtQuick
|
||||||
|
import qs.config
|
||||||
|
import qs.services
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
required property var check
|
||||||
|
property bool issue: false
|
||||||
|
property bool divider: true
|
||||||
|
property int actionActivationCount: 0
|
||||||
|
signal actionRequested(var check)
|
||||||
|
|
||||||
|
readonly property bool repairWorking: Health.repairingId === root.check.id
|
||||||
|
readonly property bool repairFailed: root.check.status !== "ok"
|
||||||
|
&& Health.lastRepair.checkId === root.check.id
|
||||||
|
&& (Health.lastRepair.accepted === false || Health.lastRepair.exitCode !== 0)
|
||||||
|
|
||||||
|
objectName: `health-check-row:${root.issue ? "issue" : "quiet"}:${root.check.id}`
|
||||||
|
implicitHeight: 62
|
||||||
|
|
||||||
|
// The isolated contract uses the same signal path as a pointer or keyboard
|
||||||
|
// activation instead of calling HealthPage's action handler directly.
|
||||||
|
function activateAction(): bool {
|
||||||
|
if (!actionButton.visible || !actionButton.enabled)
|
||||||
|
return false;
|
||||||
|
root.actionActivationCount += 1;
|
||||||
|
actionButton.clicked();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(status: string): string {
|
||||||
|
if (status === "ok") return "Healthy";
|
||||||
|
if (status === "warning") return "Needs attention";
|
||||||
|
if (status === "error") return "Action required";
|
||||||
|
return "Not set up";
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusColor(status: string): color {
|
||||||
|
if (status === "ok") return Theme.ok;
|
||||||
|
if (status === "warning") return Theme.warn;
|
||||||
|
if (status === "error") return Theme.danger;
|
||||||
|
return Theme.fgMuted;
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayedStatus(): string {
|
||||||
|
if (root.repairWorking)
|
||||||
|
return "Working…";
|
||||||
|
if (root.repairFailed)
|
||||||
|
return "Repair failed";
|
||||||
|
return root.statusLabel(root.check.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: issueMark
|
||||||
|
|
||||||
|
visible: root.issue
|
||||||
|
width: 7
|
||||||
|
height: 7
|
||||||
|
radius: 4
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.leftMargin: 1
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
color: root.statusColor(root.check.status)
|
||||||
|
border.width: 1
|
||||||
|
border.color: Theme.alpha(Theme.bgPanel, 0.9)
|
||||||
|
}
|
||||||
|
|
||||||
|
Column {
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.leftMargin: root.issue ? 22 : 0
|
||||||
|
anchors.right: trailing.left
|
||||||
|
anchors.rightMargin: 16
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
spacing: 3
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
text: root.check.title
|
||||||
|
color: Theme.fg
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSize
|
||||||
|
font.weight: Font.Medium
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
text: root.check.detail
|
||||||
|
color: Theme.fgDim
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Row {
|
||||||
|
id: trailing
|
||||||
|
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
spacing: 12
|
||||||
|
|
||||||
|
Text {
|
||||||
|
objectName: `health-status-text:${root.issue ? "issue" : "quiet"}:${root.check.id}`
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: root.displayedStatus()
|
||||||
|
color: root.statusColor(root.check.status)
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsButton {
|
||||||
|
id: actionButton
|
||||||
|
|
||||||
|
objectName: `health-row-action:${root.issue ? "issue" : "quiet"}:${root.check.id}`
|
||||||
|
visible: root.check.action !== undefined
|
||||||
|
text: root.check.action?.label ?? ""
|
||||||
|
enabled: visible && !root.repairWorking && !Health.busy
|
||||||
|
activeFocusOnTab: enabled
|
||||||
|
border.width: activeFocus ? 2 : 1
|
||||||
|
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
|
||||||
|
onClicked: root.actionRequested(root.check)
|
||||||
|
Keys.onReturnPressed: if (enabled) root.actionRequested(root.check)
|
||||||
|
Keys.onSpacePressed: if (enabled) root.actionRequested(root.check)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
visible: root.divider
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
height: 1
|
||||||
|
color: Theme.alpha(Theme.fg, 0.07)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,458 @@
|
|||||||
|
import QtQuick
|
||||||
|
import qs.config
|
||||||
|
import qs.services
|
||||||
|
|
||||||
|
SettingsPage {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
objectName: "system-health-page"
|
||||||
|
|
||||||
|
title: "System Health"
|
||||||
|
lede: "Checks the parts of the desktop this app owns, and explains what needs attention."
|
||||||
|
|
||||||
|
property var pendingConfirmation: null
|
||||||
|
property string instructionTarget: ""
|
||||||
|
|
||||||
|
readonly property var issueChecks: Health.checks.filter(check => check.status === "warning" || check.status === "error")
|
||||||
|
readonly property var groups: [
|
||||||
|
{
|
||||||
|
group: "desktop-foundation",
|
||||||
|
title: "Desktop foundation",
|
||||||
|
subtitle: "Compositor, shell, portals, wallpaper, idle policy, and launcher."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: "input-media",
|
||||||
|
title: "Input & media",
|
||||||
|
subtitle: "Sound, clipboard, capture, OCR, and display controls."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: "integrations",
|
||||||
|
title: "Integrations",
|
||||||
|
subtitle: "Only configured integrations affect health."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: "panama-tools",
|
||||||
|
title: "Desktop tools",
|
||||||
|
subtitle: "Tracked links, launcher commands, apps, and inhibitors."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
readonly property var applicationTargets: ({
|
||||||
|
"integration.nextcloud": "nextcloud",
|
||||||
|
"integration.rustdesk": "rustdesk",
|
||||||
|
"integration.kdeconnect": "kdeconnect",
|
||||||
|
"integration.bluebubbles": "bluebubbles"
|
||||||
|
})
|
||||||
|
|
||||||
|
function statusLabel(status: string): string {
|
||||||
|
if (status === "ok") return "Healthy";
|
||||||
|
if (status === "warning") return "Needs attention";
|
||||||
|
if (status === "error") return "Action required";
|
||||||
|
return "Not set up";
|
||||||
|
}
|
||||||
|
|
||||||
|
function checksForGroup(group: string): var {
|
||||||
|
return Health.checks.filter(check => check.group === group
|
||||||
|
&& (check.status === "ok" || check.status === "unconfigured"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAction(check: var): void {
|
||||||
|
if (!check || !check.action)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (check.action.kind === "open") {
|
||||||
|
if (check.action.target) {
|
||||||
|
ShellState.openSettings(check.action.target);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const application = root.applicationTargets[check.id];
|
||||||
|
if (application)
|
||||||
|
SystemSettings.openApplication(application);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (check.action.kind === "instructions") {
|
||||||
|
root.instructionTarget = check.action.target || "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (check.action.kind !== "repair")
|
||||||
|
return;
|
||||||
|
if (check.action.confirm) {
|
||||||
|
root.pendingConfirmation = check;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Health.repair(check.id, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmRepair(): void {
|
||||||
|
const check = root.pendingConfirmation;
|
||||||
|
root.pendingConfirmation = null;
|
||||||
|
if (check)
|
||||||
|
Health.repair(check.id, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function descendants(item: var, prefix: string): var {
|
||||||
|
let matches = [];
|
||||||
|
if (!item)
|
||||||
|
return matches;
|
||||||
|
if (String(item.objectName || "").indexOf(prefix) === 0)
|
||||||
|
matches.push(item);
|
||||||
|
for (const child of item.children || [])
|
||||||
|
matches = matches.concat(root.descendants(child, prefix));
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
function activateRenderedAction(id: string): bool {
|
||||||
|
const suffix = `:${id}`;
|
||||||
|
const rows = root.descendants(root, "health-check-row:").filter(row =>
|
||||||
|
row.visible && String(row.objectName).endsWith(suffix));
|
||||||
|
return rows.length === 1 && rows[0].activateAction();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderedFocusChain(): var {
|
||||||
|
// Repeater delegates enter Qt's tab chain lazily after their enabled
|
||||||
|
// binding changes at the end of a scan. Touch each rendered action's
|
||||||
|
// real next-focus link before traversing from the first hero control.
|
||||||
|
const renderedActions = root.descendants(root, "health-row-action:").filter(item =>
|
||||||
|
item.visible && item.enabled && item.activeFocusOnTab);
|
||||||
|
for (const action of renderedActions)
|
||||||
|
action.nextItemInFocusChain(true);
|
||||||
|
|
||||||
|
const starts = root.descendants(root, "health-copy-report-button").filter(item => item.visible && item.enabled);
|
||||||
|
if (starts.length !== 1)
|
||||||
|
return [];
|
||||||
|
|
||||||
|
const names = [];
|
||||||
|
const start = starts[0];
|
||||||
|
let current = start;
|
||||||
|
for (let index = 0; index < 128; index++) {
|
||||||
|
const name = String(current.objectName || "");
|
||||||
|
if (name !== "" && names.indexOf(name) < 0)
|
||||||
|
names.push(name);
|
||||||
|
current = current.nextItemInFocusChain(true);
|
||||||
|
if (!current || current === start)
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deterministic, read-only fixture seam used by the offscreen contract.
|
||||||
|
function uiDiagnostics(): var {
|
||||||
|
const summaries = root.descendants(root, "health-summary");
|
||||||
|
const rows = root.descendants(root, "health-check-row:").filter(row => row.visible);
|
||||||
|
const checkingLabels = root.descendants(root, "health-checking-label").filter(label => label.visible);
|
||||||
|
const confirmationSheets = root.descendants(root, "health-confirmation-sheet:").filter(sheet => sheet.visible);
|
||||||
|
const emptyGroups = root.descendants(root, "health-empty-group:").filter(label => label.visible);
|
||||||
|
const fedoraHandoffs = root.descendants(root, "health-fedora-handoff:").filter(row => row.visible);
|
||||||
|
return {
|
||||||
|
renderedRows: rows.map(row => {
|
||||||
|
const objectName = String(row.objectName);
|
||||||
|
const parts = objectName.split(":");
|
||||||
|
const statusTexts = root.descendants(row, "health-status-text:").filter(text => text.visible);
|
||||||
|
return {
|
||||||
|
objectName: objectName,
|
||||||
|
id: parts.slice(2).join(":"),
|
||||||
|
section: parts[1],
|
||||||
|
statusText: statusTexts.length === 1 ? statusTexts[0].text : ""
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
summaryHeight: summaries.length > 0 ? summaries[0].height : 0,
|
||||||
|
rowHeights: rows.map(row => row.height),
|
||||||
|
checking: Health.busy,
|
||||||
|
checkingText: checkingLabels.length > 0 ? checkingLabels[0].text : "",
|
||||||
|
focusChain: root.renderedFocusChain(),
|
||||||
|
activatedRows: rows.filter(row => row.actionActivationCount > 0).map(row => String(row.objectName)),
|
||||||
|
emptyQuietGroups: emptyGroups.map(label => String(label.objectName).slice("health-empty-group:".length)),
|
||||||
|
fedoraHandoffs: fedoraHandoffs.map(row => ({
|
||||||
|
id: String(row.objectName).slice("health-fedora-handoff:".length),
|
||||||
|
label: row.label,
|
||||||
|
action: row.action
|
||||||
|
})),
|
||||||
|
confirmationVisible: confirmationSheets.length === 1,
|
||||||
|
confirmationId: confirmationSheets.length === 1
|
||||||
|
? String(confirmationSheets[0].objectName).slice("health-confirmation-sheet:".length)
|
||||||
|
: ""
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Component.onCompleted: Health.refresh()
|
||||||
|
|
||||||
|
header: Component {
|
||||||
|
Rectangle {
|
||||||
|
objectName: `health-confirmation-sheet:${root.pendingConfirmation ? root.pendingConfirmation.id : ""}`
|
||||||
|
visible: root.pendingConfirmation !== null
|
||||||
|
implicitHeight: visible ? confirmRow.implicitHeight + 28 : 0
|
||||||
|
radius: Theme.cardRadius
|
||||||
|
color: Theme.mix(Theme.bgPanel, Theme.warn, 0.1)
|
||||||
|
border.width: 1
|
||||||
|
border.color: Theme.alpha(Theme.warn, 0.34)
|
||||||
|
|
||||||
|
Row {
|
||||||
|
id: confirmRow
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
anchors.margins: 16
|
||||||
|
spacing: 14
|
||||||
|
|
||||||
|
Column {
|
||||||
|
width: parent.width - cancelButton.width - repairButton.width - 28
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
spacing: 3
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
text: root.pendingConfirmation
|
||||||
|
? `${root.pendingConfirmation.action.label}?`
|
||||||
|
: "Restart the shell?"
|
||||||
|
color: Theme.fg
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSize
|
||||||
|
font.weight: Font.DemiBold
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
text: "The desktop chrome will disappear briefly and return when Quickshell restarts."
|
||||||
|
color: Theme.fgDim
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsButton {
|
||||||
|
id: cancelButton
|
||||||
|
text: "Cancel"
|
||||||
|
activeFocusOnTab: true
|
||||||
|
border.width: activeFocus ? 2 : 1
|
||||||
|
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
|
||||||
|
onClicked: root.pendingConfirmation = null
|
||||||
|
Keys.onReturnPressed: root.pendingConfirmation = null
|
||||||
|
Keys.onSpacePressed: root.pendingConfirmation = null
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsButton {
|
||||||
|
id: repairButton
|
||||||
|
text: root.pendingConfirmation ? root.pendingConfirmation.action.label : "Restart the shell"
|
||||||
|
activeFocusOnTab: true
|
||||||
|
border.width: activeFocus ? 2 : 1
|
||||||
|
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
|
||||||
|
onClicked: root.confirmRepair()
|
||||||
|
Keys.onReturnPressed: root.confirmRepair()
|
||||||
|
Keys.onSpacePressed: root.confirmRepair()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HealthSummary {}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
visible: root.issueChecks.length > 0
|
||||||
|
title: Health.status === "error" ? "Action required" : "Needs attention"
|
||||||
|
subtitle: Health.status === "error"
|
||||||
|
? "Resolve these items first. Healthy systems remain listed below."
|
||||||
|
: "Nothing here prevents you from using the desktop."
|
||||||
|
|
||||||
|
Item {
|
||||||
|
width: parent.width
|
||||||
|
implicitHeight: issueRows.implicitHeight
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.leftMargin: 4
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.topMargin: 25
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
anchors.bottomMargin: 25
|
||||||
|
width: 1
|
||||||
|
visible: root.issueChecks.length > 1
|
||||||
|
color: Theme.alpha(Health.status === "error" ? Theme.danger : Theme.warn, 0.44)
|
||||||
|
}
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: issueRows
|
||||||
|
width: parent.width
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
id: issueRepeater
|
||||||
|
model: root.issueChecks
|
||||||
|
|
||||||
|
HealthCheckRow {
|
||||||
|
required property var modelData
|
||||||
|
required property int index
|
||||||
|
|
||||||
|
width: issueRows.width
|
||||||
|
check: modelData
|
||||||
|
issue: true
|
||||||
|
divider: index < issueRepeater.count - 1
|
||||||
|
onActionRequested: check => root.handleAction(check)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
visible: root.instructionTarget === "ddc-permissions"
|
||||||
|
title: "External monitor brightness"
|
||||||
|
subtitle: "The monitor reports DDC/CI support, but this session cannot reach the monitor bus."
|
||||||
|
|
||||||
|
Item {
|
||||||
|
width: parent.width
|
||||||
|
implicitHeight: Math.max(instructionCopy.implicitHeight, doneButton.implicitHeight) + 8
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: instructionCopy
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: doneButton.left
|
||||||
|
anchors.rightMargin: 18
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: "Reload the installed udev rules and trigger the i2c-dev and DRM devices. Sign out and back in if monitor access is still unavailable."
|
||||||
|
color: Theme.fgDim
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsButton {
|
||||||
|
id: doneButton
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: "Done"
|
||||||
|
activeFocusOnTab: true
|
||||||
|
border.width: activeFocus ? 2 : 1
|
||||||
|
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
|
||||||
|
onClicked: root.instructionTarget = ""
|
||||||
|
Keys.onReturnPressed: root.instructionTarget = ""
|
||||||
|
Keys.onSpacePressed: root.instructionTarget = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Grid {
|
||||||
|
id: groupGrid
|
||||||
|
|
||||||
|
width: parent.width
|
||||||
|
columns: width >= 700 ? 2 : 1
|
||||||
|
columnSpacing: 16
|
||||||
|
rowSpacing: 16
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: root.groups
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
required property var modelData
|
||||||
|
readonly property var quietChecks: root.checksForGroup(modelData.group)
|
||||||
|
|
||||||
|
width: groupGrid.columns === 2
|
||||||
|
? (groupGrid.width - groupGrid.columnSpacing) / 2
|
||||||
|
: groupGrid.width
|
||||||
|
title: modelData.title
|
||||||
|
subtitle: modelData.subtitle
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: groupRows
|
||||||
|
width: parent.width
|
||||||
|
|
||||||
|
Text {
|
||||||
|
objectName: `health-empty-group:${modelData.group}`
|
||||||
|
width: parent.width
|
||||||
|
visible: quietChecks.length === 0
|
||||||
|
text: "Items needing attention are listed above."
|
||||||
|
color: Theme.fgMuted
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
topPadding: 8
|
||||||
|
bottomPadding: 8
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
}
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
id: groupRepeater
|
||||||
|
model: quietChecks
|
||||||
|
|
||||||
|
HealthCheckRow {
|
||||||
|
required property var modelData
|
||||||
|
required property int index
|
||||||
|
|
||||||
|
width: groupRows.width
|
||||||
|
check: modelData
|
||||||
|
divider: index < groupRepeater.count - 1
|
||||||
|
onActionRequested: check => root.handleAction(check)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
title: "Fedora system settings"
|
||||||
|
subtitle: "These areas remain owned by Fedora and GNOME's mature system panels."
|
||||||
|
|
||||||
|
Item {
|
||||||
|
width: parent.width
|
||||||
|
implicitHeight: 42
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: gnomeSettingsButton.left
|
||||||
|
anchors.rightMargin: 18
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: "Use GNOME Settings for the parts of the system this app does not manage."
|
||||||
|
color: Theme.fgDim
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsButton {
|
||||||
|
id: gnomeSettingsButton
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: "Open GNOME Settings"
|
||||||
|
activeFocusOnTab: true
|
||||||
|
border.width: activeFocus ? 2 : 1
|
||||||
|
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
|
||||||
|
onClicked: SystemSettings.openGnomePanel("network")
|
||||||
|
Keys.onReturnPressed: SystemSettings.openGnomePanel("network")
|
||||||
|
Keys.onSpacePressed: SystemSettings.openGnomePanel("network")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ActionRow {
|
||||||
|
objectName: "health-fedora-handoff:users"
|
||||||
|
label: "Users"
|
||||||
|
detail: "Accounts, passwords, and automatic login"
|
||||||
|
action: "Open users"
|
||||||
|
onTriggered: SystemSettings.openGnomePanel("system", "users")
|
||||||
|
}
|
||||||
|
|
||||||
|
ActionRow {
|
||||||
|
objectName: "health-fedora-handoff:sharing"
|
||||||
|
label: "Sharing"
|
||||||
|
detail: "Remote desktop, media sharing, and remote login"
|
||||||
|
action: "Open sharing"
|
||||||
|
onTriggered: SystemSettings.openGnomePanel("sharing")
|
||||||
|
}
|
||||||
|
|
||||||
|
ActionRow {
|
||||||
|
objectName: "health-fedora-handoff:color"
|
||||||
|
label: "Colour profiles"
|
||||||
|
detail: "ICC profiles for displays, printers, and scanners"
|
||||||
|
action: "Open colour"
|
||||||
|
onTriggered: SystemSettings.openGnomePanel("color")
|
||||||
|
}
|
||||||
|
|
||||||
|
ActionRow {
|
||||||
|
objectName: "health-fedora-handoff:wellbeing"
|
||||||
|
label: "Digital wellbeing"
|
||||||
|
detail: "Screen time and break reminders"
|
||||||
|
action: "Open wellbeing"
|
||||||
|
divider: false
|
||||||
|
onTriggered: SystemSettings.openGnomePanel("wellbeing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import QtQuick
|
||||||
|
import qs.config
|
||||||
|
import qs.services
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
objectName: "health-summary"
|
||||||
|
implicitHeight: 126
|
||||||
|
|
||||||
|
readonly property int observationCount: Health.summary.warnings + Health.summary.errors
|
||||||
|
readonly property string heroTitle: {
|
||||||
|
if (Health.diagnosticUnavailable)
|
||||||
|
return "Health check unavailable";
|
||||||
|
if (Health.checks.length === 0)
|
||||||
|
return "Checking the desktop";
|
||||||
|
if (Health.status === "error")
|
||||||
|
return "Action required";
|
||||||
|
if (Health.status === "warning")
|
||||||
|
return "Needs attention";
|
||||||
|
return "Healthy";
|
||||||
|
}
|
||||||
|
readonly property string heroDetail: {
|
||||||
|
if (Health.diagnosticUnavailable)
|
||||||
|
return Health.lastError || "The latest health check could not be completed.";
|
||||||
|
if (Health.checks.length === 0)
|
||||||
|
return "Checking the desktop services, tools, and integrations this app owns.";
|
||||||
|
if (Health.status === "error")
|
||||||
|
return root.observationCount === 1
|
||||||
|
? "One part of the desktop needs action."
|
||||||
|
: `${root.observationCount} parts of the desktop need action.`;
|
||||||
|
if (Health.status === "warning")
|
||||||
|
return root.observationCount === 1
|
||||||
|
? "Your desktop is working. One feature needs a decision."
|
||||||
|
: `Your desktop is working. ${root.observationCount} features need a decision.`;
|
||||||
|
return "Desktop services and tools are working normally.";
|
||||||
|
}
|
||||||
|
readonly property color statusColor: {
|
||||||
|
if (Health.diagnosticUnavailable || Health.status === "error")
|
||||||
|
return Theme.danger;
|
||||||
|
if (Health.status === "warning")
|
||||||
|
return Theme.warn;
|
||||||
|
if (Health.checks.length === 0)
|
||||||
|
return Theme.fgMuted;
|
||||||
|
return Theme.ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scanTime(): string {
|
||||||
|
const generatedAt = String(Health.snapshot.generatedAt || "");
|
||||||
|
if (generatedAt === "")
|
||||||
|
return "No completed check yet";
|
||||||
|
const date = new Date(generatedAt);
|
||||||
|
if (Number.isNaN(date.getTime()))
|
||||||
|
return "Last check completed";
|
||||||
|
return `Last checked ${date.toLocaleTimeString(Qt.locale(), Locale.ShortFormat)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
Item {
|
||||||
|
width: parent.width
|
||||||
|
implicitHeight: 96
|
||||||
|
|
||||||
|
Column {
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: actions.left
|
||||||
|
anchors.rightMargin: 22
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
spacing: 4
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
text: Health.diagnosticUnavailable
|
||||||
|
? "DIAGNOSTICS"
|
||||||
|
: root.observationCount > 0
|
||||||
|
? root.observationCount + (root.observationCount === 1 ? " OBSERVATION" : " OBSERVATIONS")
|
||||||
|
: "PANAMA DESKTOP"
|
||||||
|
color: root.statusColor
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
font.weight: Font.DemiBold
|
||||||
|
font.letterSpacing: 1.15
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
text: root.heroTitle
|
||||||
|
color: Theme.fg
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: 24
|
||||||
|
font.weight: Font.DemiBold
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
text: root.heroDetail
|
||||||
|
color: Theme.fgDim
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
|
||||||
|
Row {
|
||||||
|
spacing: 10
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: root.scanTime()
|
||||||
|
color: Theme.fgMuted
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
objectName: "health-checking-label"
|
||||||
|
visible: Health.busy
|
||||||
|
text: "Checking…"
|
||||||
|
color: Theme.fgDim
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
visible: Health.lastCopyResult !== ""
|
||||||
|
text: Health.lastCopyResult
|
||||||
|
color: Health.lastCopyResult === "Report copied." ? Theme.ok : Theme.warn
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Row {
|
||||||
|
id: actions
|
||||||
|
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
SettingsButton {
|
||||||
|
id: copyButton
|
||||||
|
|
||||||
|
objectName: "health-copy-report-button"
|
||||||
|
visible: !Health.diagnosticUnavailable
|
||||||
|
text: "Copy report"
|
||||||
|
enabled: Health.checks.length > 0
|
||||||
|
activeFocusOnTab: enabled
|
||||||
|
border.width: activeFocus ? 2 : 1
|
||||||
|
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
|
||||||
|
onClicked: Health.copyReport()
|
||||||
|
Keys.onReturnPressed: if (enabled) Health.copyReport()
|
||||||
|
Keys.onSpacePressed: if (enabled) Health.copyReport()
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsButton {
|
||||||
|
id: refreshButton
|
||||||
|
|
||||||
|
objectName: "health-refresh-button"
|
||||||
|
text: Health.diagnosticUnavailable ? "Retry" : "Refresh"
|
||||||
|
tone: Health.diagnosticUnavailable ? "normal" : "accent"
|
||||||
|
enabled: !Health.busy
|
||||||
|
activeFocusOnTab: enabled
|
||||||
|
border.width: activeFocus ? 2 : (tone === "accent" ? 0 : 1)
|
||||||
|
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
|
||||||
|
onClicked: Health.refresh()
|
||||||
|
Keys.onReturnPressed: if (enabled) Health.refresh()
|
||||||
|
Keys.onSpacePressed: if (enabled) Health.refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ SettingsPage {
|
|||||||
readonly property string greeting: openedHour < 12 ? "Good morning" : (openedHour < 18 ? "Good afternoon" : "Good evening")
|
readonly property string greeting: openedHour < 12 ? "Good morning" : (openedHour < 18 ? "Good afternoon" : "Good evening")
|
||||||
|
|
||||||
title: `${root.greeting}, Gabriel`
|
title: `${root.greeting}, Gabriel`
|
||||||
lede: "Your Panama desktop is configured and ready."
|
lede: "Your desktop is configured and ready."
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: SystemSettings.monitorDescription || "Active display"
|
title: SystemSettings.monitorDescription || "Active display"
|
||||||
|
|||||||
@@ -202,7 +202,7 @@ SettingsPage {
|
|||||||
|
|
||||||
SettingRow {
|
SettingRow {
|
||||||
label: "Private configuration"
|
label: "Private configuration"
|
||||||
detail: "Saved with owner-only permissions in Panama's private environment file"
|
detail: "Saved with owner-only permissions in a private environment file"
|
||||||
controlWidth: 216
|
controlWidth: 216
|
||||||
|
|
||||||
Row {
|
Row {
|
||||||
@@ -239,7 +239,7 @@ SettingsPage {
|
|||||||
|
|
||||||
SettingRow {
|
SettingRow {
|
||||||
label: "Light catalog"
|
label: "Light catalog"
|
||||||
detail: "Panama reads light state through the Home Assistant helper."
|
detail: "Light state is read through the Home Assistant helper."
|
||||||
divider: false
|
divider: false
|
||||||
controlWidth: 176
|
controlWidth: 176
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
// Mouse & Touchpad.
|
||||||
|
//
|
||||||
|
// GNOME splits pointing devices out of Keyboard, and so does this: the settings
|
||||||
|
// are unrelated, and burying pointer speed under a page called "Shortcuts" is
|
||||||
|
// where it was before. These all reach Hyprland's input section, which until now
|
||||||
|
// could only be changed by editing hypr/input.lua by hand.
|
||||||
|
//
|
||||||
|
// The touchpad card renders only when a touchpad is attached. On a desktop it
|
||||||
|
// would be worse than useless -- every switch on it appears to work, because the
|
||||||
|
// preference is stored and the compositor accepts an option for a device class
|
||||||
|
// with no members, so the user would be toggling settings that can never affect
|
||||||
|
// anything with nothing to say so.
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import qs.config
|
||||||
|
import qs.services
|
||||||
|
|
||||||
|
SettingsPage {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
title: "Mouse & Touchpad"
|
||||||
|
lede: InputDevices.hasTouchpad
|
||||||
|
? "Pointer behaviour for your mouse and touchpad."
|
||||||
|
: "Pointer behaviour. Touchpad settings appear when a touchpad is attached."
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
title: "Mouse"
|
||||||
|
subtitle: "Applied to every pointing device that is not a touchpad."
|
||||||
|
|
||||||
|
SliderRow { setting: "pointerSensitivity" }
|
||||||
|
ChoiceRow { setting: "accelProfile" }
|
||||||
|
ToggleRow { setting: "naturalScroll" }
|
||||||
|
SliderRow { setting: "scrollFactor" }
|
||||||
|
ToggleRow { setting: "leftHanded" }
|
||||||
|
ToggleRow {
|
||||||
|
setting: "middleClickPaste"
|
||||||
|
detail: "Paste the primary selection in GTK and native Wayland applications; individual apps may choose not to support it"
|
||||||
|
divider: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
visible: InputDevices.hasTouchpad
|
||||||
|
title: "Touchpad"
|
||||||
|
subtitle: "Separate from the mouse on purpose: libinput keeps them apart, and a touchpad and a mouse usually want to scroll in opposite directions."
|
||||||
|
|
||||||
|
ToggleRow { setting: "touchpadTapToClick" }
|
||||||
|
ToggleRow { setting: "touchpadNaturalScroll" }
|
||||||
|
ToggleRow { setting: "touchpadDisableWhileTyping" }
|
||||||
|
SliderRow { setting: "touchpadScrollFactor" }
|
||||||
|
ChoiceRow { setting: "touchpadDragLock" }
|
||||||
|
ToggleRow { setting: "touchpadMiddleButtonEmulation"; divider: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
title: "Pointer"
|
||||||
|
|
||||||
|
ChoiceRow { setting: "followMouse" }
|
||||||
|
SliderRow { setting: "cursorInactiveTimeout"; zeroLabel: "Never" }
|
||||||
|
SliderRow { setting: "cursorSize"; divider: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,7 +24,7 @@ SettingsPage {
|
|||||||
|
|
||||||
TextRow {
|
TextRow {
|
||||||
label: "Notification history"
|
label: "Notification history"
|
||||||
detail: "Live notifications retained by Panama"
|
detail: "Live notifications retained by the shell"
|
||||||
value: `${Notifs.history.length} items`
|
value: `${Notifs.history.length} items`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
// Online Accounts.
|
||||||
|
//
|
||||||
|
// GNOME's panel of the same name, except for one step. The accounts are GNOME
|
||||||
|
// Online Accounts objects on D-Bus, the daemon already runs here, and listing,
|
||||||
|
// per-service toggles and removal are all done natively on this page.
|
||||||
|
//
|
||||||
|
// Signing in to an OAuth provider is handed to GNOME's panel, because the
|
||||||
|
// credentials are OAuth tokens and the code that obtains them ships without a
|
||||||
|
// scriptable binding. That is stated on the page rather than hidden behind a
|
||||||
|
// button that looks native, because a hand-off the user does not expect reads
|
||||||
|
// as a bug.
|
||||||
|
//
|
||||||
|
// Accounts needing attention are surfaced first. GOA knows when a token has
|
||||||
|
// expired and nothing outside its own panel says so, which is how an account
|
||||||
|
// quietly stops syncing for weeks.
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import qs.config
|
||||||
|
import qs.services
|
||||||
|
|
||||||
|
SettingsPage {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
title: "Online Accounts"
|
||||||
|
lede: OnlineAccounts.attentionCount > 0
|
||||||
|
? OnlineAccounts.attentionCount + (OnlineAccounts.attentionCount === 1
|
||||||
|
? " account needs you to sign in again."
|
||||||
|
: " accounts need you to sign in again.")
|
||||||
|
: "Accounts your mail, calendar, contacts, and files come from."
|
||||||
|
|
||||||
|
Component.onCompleted: if (!OnlineAccounts.scanned) OnlineAccounts.refresh()
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
visible: !OnlineAccounts.available
|
||||||
|
title: "Accounts unavailable"
|
||||||
|
subtitle: OnlineAccounts.lastError
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
visible: OnlineAccounts.scanned && OnlineAccounts.available && OnlineAccounts.accounts.length === 0
|
||||||
|
title: "No accounts yet"
|
||||||
|
subtitle: "Adding one lets mail, calendar, contacts, and file managers share a single sign-in."
|
||||||
|
|
||||||
|
ActionRow {
|
||||||
|
label: "Add an account"
|
||||||
|
detail: "Google, Nextcloud, Microsoft Exchange, IMAP, WebDAV, and Kerberos"
|
||||||
|
action: "Add account"
|
||||||
|
divider: false
|
||||||
|
onTriggered: SystemSettings.openGnomePanel("online-accounts")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: OnlineAccounts.accounts
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
id: accountCard
|
||||||
|
required property var modelData
|
||||||
|
|
||||||
|
title: accountCard.modelData.identity || accountCard.modelData.providerName
|
||||||
|
subtitle: accountCard.modelData.needsAttention
|
||||||
|
? accountCard.modelData.providerName + " · sign-in expired, so this account has stopped syncing"
|
||||||
|
: accountCard.modelData.providerName
|
||||||
|
|
||||||
|
// Only when it is true, because it is the one thing on this page
|
||||||
|
// that needs acting on.
|
||||||
|
ActionRow {
|
||||||
|
visible: accountCard.modelData.needsAttention
|
||||||
|
label: "Sign in again"
|
||||||
|
detail: "Re-authorising uses the provider's own sign-in page, which GNOME's panel hosts"
|
||||||
|
action: "Sign in"
|
||||||
|
onTriggered: SystemSettings.openGnomePanel("online-accounts")
|
||||||
|
}
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: accountCard.modelData.services
|
||||||
|
|
||||||
|
SettingRow {
|
||||||
|
id: serviceRow
|
||||||
|
required property var modelData
|
||||||
|
required property int index
|
||||||
|
|
||||||
|
label: serviceRow.modelData.label
|
||||||
|
detail: serviceRow.modelData.enabled
|
||||||
|
? "Applications using " + serviceRow.modelData.label.toLowerCase() + " can see this account"
|
||||||
|
: "Hidden from applications"
|
||||||
|
controlWidth: 48
|
||||||
|
|
||||||
|
SettingsToggle {
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
checked: serviceRow.modelData.enabled
|
||||||
|
onToggled: value => OnlineAccounts.setService(
|
||||||
|
accountCard.modelData.path, serviceRow.modelData.key, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ActionRow {
|
||||||
|
label: "Remove this account"
|
||||||
|
detail: "Signs out and removes it from every application that was using it"
|
||||||
|
action: "Remove"
|
||||||
|
divider: false
|
||||||
|
onTriggered: OnlineAccounts.remove(accountCard.modelData.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
visible: OnlineAccounts.available && OnlineAccounts.accounts.length > 0
|
||||||
|
title: "Add another account"
|
||||||
|
subtitle: "Signing in happens on the provider's own page. GNOME's panel hosts that step; everything after it is managed here."
|
||||||
|
|
||||||
|
ActionRow {
|
||||||
|
label: "Add an account"
|
||||||
|
detail: "Google, Nextcloud, Microsoft Exchange, IMAP, WebDAV, and Kerberos"
|
||||||
|
action: "Add account"
|
||||||
|
divider: false
|
||||||
|
onTriggered: SystemSettings.openGnomePanel("online-accounts")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,14 +14,48 @@ import qs.services
|
|||||||
SettingsPage {
|
SettingsPage {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
|
// Probing the power daemon is a D-Bus round trip, so it happens when the
|
||||||
|
// page opens rather than at shell startup.
|
||||||
|
Component.onCompleted: if (!PowerProfiles.scanned) PowerProfiles.refresh()
|
||||||
|
|
||||||
title: "Power & Lock"
|
title: "Power & Lock"
|
||||||
lede: "When the screen turns off, when the session locks, and whether it ever sleeps."
|
lede: "When the screen turns off, when the session locks, and whether it ever sleeps."
|
||||||
|
|
||||||
|
// The same profiles GNOME's Power panel offers. Not a stored preference --
|
||||||
|
// the daemon owns it, it survives Panama restarts, and anything else on the
|
||||||
|
// system can change it, so a copy here would drift.
|
||||||
|
SettingsCard {
|
||||||
|
visible: PowerProfiles.available || PowerProfiles.lastError !== ""
|
||||||
|
title: "Power profile"
|
||||||
|
subtitle: PowerProfiles.degraded !== ""
|
||||||
|
? "Performance is limited right now: " + PowerProfiles.degraded
|
||||||
|
: (PowerProfiles.available
|
||||||
|
? "Applies to the whole system and persists across sessions."
|
||||||
|
: PowerProfiles.lastError)
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: PowerProfiles.profiles
|
||||||
|
|
||||||
|
SettingRow {
|
||||||
|
id: profileRow
|
||||||
|
required property var modelData
|
||||||
|
required property int index
|
||||||
|
|
||||||
|
label: PowerProfiles.label(profileRow.modelData)
|
||||||
|
detail: PowerProfiles.detail(profileRow.modelData)
|
||||||
|
value: profileRow.modelData === PowerProfiles.active ? "Active" : ""
|
||||||
|
divider: profileRow.index < PowerProfiles.profiles.length - 1
|
||||||
|
activatable: profileRow.modelData !== PowerProfiles.active && !PowerProfiles.busy
|
||||||
|
onActivated: PowerProfiles.set(profileRow.modelData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Idle behaviour"
|
title: "Idle behaviour"
|
||||||
subtitle: IdleLock.managed
|
subtitle: IdleLock.managed
|
||||||
? "Panama is managing hypridle. Changes take effect immediately."
|
? "Idle timings are managed here. Changes take effect immediately."
|
||||||
: "hypridle is running Panama's shipped configuration. Turn on management below to make these adjustable."
|
: "hypridle is running its shipped configuration. Turn on management below to make these adjustable."
|
||||||
|
|
||||||
SliderRow { setting: "screenBlankMinutes"; zeroLabel: "Never" }
|
SliderRow { setting: "screenBlankMinutes"; zeroLabel: "Never" }
|
||||||
SliderRow { setting: "lockMinutes"; zeroLabel: "Never" }
|
SliderRow { setting: "lockMinutes"; zeroLabel: "Never" }
|
||||||
@@ -41,10 +75,10 @@ SettingsPage {
|
|||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Management"
|
title: "Management"
|
||||||
subtitle: "Panama generates hypridle's configuration into your state directory and points the service at it with a systemd drop-in. ~/.config/hypr is a symlink into the Panama repository, so the shipped configuration cannot be rewritten in place."
|
subtitle: "hypridle's configuration is generated into your state directory and the service is pointed at it with a systemd drop-in. ~/.config/hypr is a symlink into the configuration repository, so the shipped file cannot be rewritten in place."
|
||||||
|
|
||||||
SettingRow {
|
SettingRow {
|
||||||
label: "Let Panama manage idle timings"
|
label: "Manage idle timings here"
|
||||||
detail: IdleLock.serviceState === "active"
|
detail: IdleLock.serviceState === "active"
|
||||||
? "hypridle is running"
|
? "hypridle is running"
|
||||||
: "hypridle is " + IdleLock.serviceState
|
: "hypridle is " + IdleLock.serviceState
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
// Privacy & Security.
|
||||||
|
//
|
||||||
|
// GNOME's Privacy panel covers screen lock, camera and microphone access, file
|
||||||
|
// history, trash, and device security. Panama covers the parts it genuinely
|
||||||
|
// owns and is explicit about the parts it does not.
|
||||||
|
//
|
||||||
|
// The file-history and trash settings are the notable omission, and the reason
|
||||||
|
// is worth stating: those are GNOME preferences enforced by gsd-housekeeping,
|
||||||
|
// which is not running in a Hyprland session. Offering switches for them would
|
||||||
|
// store a preference, change nothing, and give no sign of it -- the exact
|
||||||
|
// failure this codebase keeps designing against. So they are delegated by name
|
||||||
|
// rather than reimplemented as controls that lie.
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import qs.config
|
||||||
|
import qs.services
|
||||||
|
|
||||||
|
SettingsPage {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
title: "Privacy & Security"
|
||||||
|
lede: DeviceSecurity.scanned && DeviceSecurity.attentionCount === 0
|
||||||
|
? "Screen lock, device access, and a machine whose security settings all check out."
|
||||||
|
: "Screen lock, which applications can see you, and how this machine is protected."
|
||||||
|
|
||||||
|
Component.onCompleted: {
|
||||||
|
if (!DeviceSecurity.scanned)
|
||||||
|
DeviceSecurity.refresh();
|
||||||
|
if (!Keyring.scanned)
|
||||||
|
Keyring.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
title: "Screen lock"
|
||||||
|
subtitle: "The same settings as Power & Lock, which is where the idle timings live."
|
||||||
|
|
||||||
|
SliderRow { setting: "lockMinutes"; zeroLabel: "Never" }
|
||||||
|
ToggleRow { setting: "lockOnSleep"; divider: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
// The login keyring, which nothing else surfaces.
|
||||||
|
//
|
||||||
|
// It is unlocked at sign-in by PAM, so this card normally just confirms
|
||||||
|
// that. It earns its place on the rare occasion it is not: a locked keyring
|
||||||
|
// breaks saved passwords everywhere at once, and does it without ever
|
||||||
|
// saying the word "keyring" -- you get a mail account that will not
|
||||||
|
// authenticate and a git push that cannot find its key.
|
||||||
|
SettingsCard {
|
||||||
|
visible: Keyring.scanned
|
||||||
|
title: "Saved passwords"
|
||||||
|
subtitle: !Keyring.available
|
||||||
|
? "No secret service is answering, so saved passwords are unavailable."
|
||||||
|
: Keyring.locked
|
||||||
|
? "The login keyring is locked. Saved passwords cannot be read until it is unlocked, and applications that need one will appear to fail for unrelated reasons."
|
||||||
|
: "The login keyring is unlocked, as it is after every normal sign-in."
|
||||||
|
|
||||||
|
// Two rows rather than one with a conditional button: a locked keyring
|
||||||
|
// needs an action, an unlocked one is a statement of fact, and ActionRow
|
||||||
|
// and TextRow already say exactly those two things.
|
||||||
|
ActionRow {
|
||||||
|
visible: Keyring.available && Keyring.locked
|
||||||
|
label: "Login keyring"
|
||||||
|
detail: "Unlock to restore access to stored passwords and keys"
|
||||||
|
action: Keyring.unlocking ? "Waiting…" : "Unlock"
|
||||||
|
enabled: !Keyring.unlocking
|
||||||
|
divider: Keyring.replacementDaemon || Keyring.lastError !== ""
|
||||||
|
onTriggered: Keyring.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
TextRow {
|
||||||
|
visible: !(Keyring.available && Keyring.locked)
|
||||||
|
label: "Login keyring"
|
||||||
|
detail: Keyring.available
|
||||||
|
? "Unlocked at sign-in by PAM, the same way GNOME does it"
|
||||||
|
: "No secret service is answering on this session"
|
||||||
|
value: Keyring.available ? "Unlocked" : "Unavailable"
|
||||||
|
divider: Keyring.replacementDaemon || Keyring.lastError !== ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only shown when it is true, because it is a diagnostic rather than a
|
||||||
|
// setting: it means the daemon holding your secrets is not the one PAM
|
||||||
|
// started, so whatever unlocked it will not survive a restart.
|
||||||
|
SettingRow {
|
||||||
|
visible: Keyring.replacementDaemon
|
||||||
|
label: "Keyring service"
|
||||||
|
detail: "The original keyring service was replaced during this session, usually after it crashed. Signing out and back in restores the one PAM unlocks."
|
||||||
|
value: "Replaced"
|
||||||
|
divider: Keyring.lastError !== ""
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingRow {
|
||||||
|
visible: Keyring.lastError !== ""
|
||||||
|
label: "Keyring problem"
|
||||||
|
detail: Keyring.lastError
|
||||||
|
divider: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
title: "Camera & microphone"
|
||||||
|
subtitle: PrivacyState.anyActive
|
||||||
|
? "In use right now — the bar shows an indicator whenever this is true."
|
||||||
|
: "Nothing is using your camera or microphone."
|
||||||
|
|
||||||
|
TextRow {
|
||||||
|
label: "Camera"
|
||||||
|
detail: PrivacyState.cameraActive
|
||||||
|
? "In use by " + (PrivacyState.cameraApp || "an application")
|
||||||
|
: "Not in use"
|
||||||
|
value: PrivacyState.cameraActive ? "Active" : "Idle"
|
||||||
|
}
|
||||||
|
|
||||||
|
TextRow {
|
||||||
|
label: "Microphone"
|
||||||
|
detail: PrivacyState.microphoneActive
|
||||||
|
? "In use by " + (PrivacyState.microphoneApp || "an application")
|
||||||
|
: "Not in use"
|
||||||
|
value: PrivacyState.microphoneActive ? "Active" : "Idle"
|
||||||
|
}
|
||||||
|
|
||||||
|
TextRow {
|
||||||
|
label: "Screen sharing"
|
||||||
|
detail: PrivacyState.screenSharingActive
|
||||||
|
? "Being shared by " + (PrivacyState.screenSharingApp || "an application")
|
||||||
|
: "Not being shared"
|
||||||
|
value: PrivacyState.screenSharingActive ? "Active" : "Idle"
|
||||||
|
divider: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
title: "Device security"
|
||||||
|
subtitle: DeviceSecurity.attentionCount === 0
|
||||||
|
? "Everything below is in its recommended state."
|
||||||
|
: DeviceSecurity.attentionCount + " item"
|
||||||
|
+ (DeviceSecurity.attentionCount === 1 ? "" : "s") + " below may deserve attention."
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: DeviceSecurity.facts
|
||||||
|
|
||||||
|
TextRow {
|
||||||
|
id: factRow
|
||||||
|
required property var modelData
|
||||||
|
required property int index
|
||||||
|
|
||||||
|
label: factRow.modelData.label
|
||||||
|
detail: factRow.modelData.detail
|
||||||
|
value: factRow.modelData.value
|
||||||
|
divider: factRow.index < DeviceSecurity.facts.length - 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
title: "Owned by Fedora"
|
||||||
|
subtitle: "File history and trash retention are GNOME preferences, applied by a housekeeping service that does not run in a Hyprland session. They are not offered as switches here, because storing that preference would change nothing."
|
||||||
|
|
||||||
|
ActionRow {
|
||||||
|
label: "File history & trash"
|
||||||
|
detail: "Opens GNOME Settings, which owns these"
|
||||||
|
action: "Open privacy"
|
||||||
|
divider: false
|
||||||
|
onTriggered: SystemSettings.openGnomePanel("privacy")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,36 @@
|
|||||||
# Panama Settings
|
# Settings
|
||||||
|
|
||||||
The control centre for everything Panama owns. Anything the system owns —
|
The control centre for everything Panama owns. Anything the system owns —
|
||||||
hardware, accounts, printers — is delegated to GNOME Settings and labelled as
|
hardware, accounts, printers — is delegated to GNOME Settings and labelled as
|
||||||
such rather than half-reimplemented.
|
such rather than half-reimplemented.
|
||||||
|
|
||||||
|
## System Health
|
||||||
|
|
||||||
|
The stable internal `services` route renders **System Health**. It is reachable
|
||||||
|
from the Settings sidebar and its live 54px footer, the degraded-only bar
|
||||||
|
indicator, and Vicinae's **Panama: Check System Health** command. Healthy scans
|
||||||
|
reserve no bar space and produce no notification.
|
||||||
|
|
||||||
|
`services/Health.qml` owns the last accepted redacted snapshot. It invokes
|
||||||
|
`scripts/panama-doctor` for scans and bounded repairs, `wl-copy` only for an
|
||||||
|
explicit **Copy Report**, and bounded `notify-send` only when an external repair
|
||||||
|
fails. For a concise terminal view, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
~/.config/quickshell/scripts/panama-doctor --summary
|
||||||
|
```
|
||||||
|
|
||||||
|
The helper diagnoses Panama-owned desktop services, dependencies, links, and
|
||||||
|
configured integrations. It does not read secret values, clipboard or
|
||||||
|
notification contents, calendar events, SSIDs, addresses, or arbitrary command
|
||||||
|
output. Its repair interface is an authored allow-list: it never installs a
|
||||||
|
package, runs `sudo`, deletes user data, or repairs a service Panama does not
|
||||||
|
own. A repair remains degraded until a fresh scan observes recovery.
|
||||||
|
|
||||||
|
The final card is the ownership boundary. Network configuration and the exact
|
||||||
|
Users, Sharing, Colour profiles, and Digital wellbeing handoffs open GNOME
|
||||||
|
Settings because Fedora's system services own those areas.
|
||||||
|
|
||||||
## Adding a setting
|
## Adding a setting
|
||||||
|
|
||||||
One schema entry. That is the whole job.
|
One schema entry. That is the whole job.
|
||||||
@@ -32,6 +59,36 @@ If it is compositor-backed, add the matching `prefs.get("blurSize", 8)` in
|
|||||||
`hypr/looks.lua` so the Hyprland config still stands alone with no settings
|
`hypr/looks.lua` so the Hyprland config still stands alone with no settings
|
||||||
file.
|
file.
|
||||||
|
|
||||||
|
## Setting ownership
|
||||||
|
|
||||||
|
Every preference has **one primary page**, derived from its schema `group` and
|
||||||
|
the route in `services/SettingsSearch.qml`. Search results always open that
|
||||||
|
owner. A control may appear on a second page only when the same adaptation is
|
||||||
|
part of another established mental model; otherwise use a labelled handoff to
|
||||||
|
the owner instead of duplicating it.
|
||||||
|
|
||||||
|
### Intentional mirrors
|
||||||
|
|
||||||
|
| Setting | Primary page | Mirror | Why the mirror earns its place |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `animationsEnabled` | Appearance | Accessibility | Reduced motion belongs both to visual polish and motion accessibility. |
|
||||||
|
| `cursorInactiveTimeout` | Mouse | Accessibility | Pointer visibility is configured with pointer behaviour but affects motor and visual access. |
|
||||||
|
| `cursorSize` | Accessibility | Mouse | Large cursors are an accessibility adaptation that users also look for beside pointer controls. |
|
||||||
|
| `inactiveOpacity` | Appearance | Accessibility | Window translucency is an appearance choice with a direct readability impact. |
|
||||||
|
| `lockMinutes` | Power | Privacy | Idle timing owns the mechanism; privacy owns the expectation that the unattended desktop locks. |
|
||||||
|
| `lockOnSleep` | Power | Privacy | Suspend owns the transition; privacy owns whether waking requires authentication. |
|
||||||
|
|
||||||
|
Mirrors must remain the same schema-backed control, never a second preference
|
||||||
|
or a copied default. Additions to this table require a concrete discoverability
|
||||||
|
reason and an update to `tests/quickshell/settings-ownership-contract.sh`.
|
||||||
|
|
||||||
|
Window border colour follows the same ownership rule. The inactive border is a
|
||||||
|
**scheme-relative role** owned by `ColorScheme.qml`: it changes only to retain
|
||||||
|
neutral contrast in light and dark modes. The focused Prism border is the
|
||||||
|
accent role owned by the visual theme (and, eventually, an accent picker).
|
||||||
|
`ColorScheme.qml` must never write the focused border, so changing schemes
|
||||||
|
cannot erase a user-selected accent.
|
||||||
|
|
||||||
## The rows
|
## The rows
|
||||||
|
|
||||||
| Component | For |
|
| Component | For |
|
||||||
@@ -83,11 +140,12 @@ slot**, because that is the row's default property, so only the right-hand edge
|
|||||||
becomes clickable. Use `activatable: true` with `onActivated` for a whole-row
|
becomes clickable. Use `activatable: true` with `onActivated` for a whole-row
|
||||||
target.
|
target.
|
||||||
|
|
||||||
**A copy of the Quickshell config shares the live shell's ID.** Quickshell
|
**A content-identical Quickshell entry can share the live shell's ID.**
|
||||||
derives the Shell ID from config *content*, not path, so
|
Quickshell derives the Shell ID from config *content*, not path. Runtime
|
||||||
`cp -a config/dot/quickshell $tmp && qs -p $tmp kill` kills the running
|
harnesses therefore create a distinct semantic entry file, address that exact
|
||||||
desktop, and `qs -p $tmp ipc call …` can drive it. Harnesses that point at a
|
file with `qs -p`, and discover its PID from the exact Config path in
|
||||||
single distinct `.qml` file are safe; copying the whole directory is not.
|
`qs list --all`. They terminate only that recorded PID with `kill`; never use
|
||||||
|
`qs kill` from a copied configuration.
|
||||||
|
|
||||||
## Where state lives
|
## Where state lives
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
// Region & Language.
|
||||||
|
//
|
||||||
|
// GNOME keeps language and formats under System; the setting itself is the
|
||||||
|
// machine's locale, which localectl owns. Panama does not store a copy of it --
|
||||||
|
// there is exactly one system locale and localectl is where it lives, so a
|
||||||
|
// preference here would be a second source of truth that drifts the moment
|
||||||
|
// anything else changes it.
|
||||||
|
//
|
||||||
|
// Keyboard layout is deliberately not repeated here even though GNOME groups it
|
||||||
|
// with region. It is a compositor setting that applies instantly, it lives on
|
||||||
|
// the Keyboard page with the rest of the typing settings, and showing it twice
|
||||||
|
// invites the two views to disagree.
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import qs.config
|
||||||
|
import qs.services
|
||||||
|
|
||||||
|
SettingsPage {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
title: "Region & Language"
|
||||||
|
lede: SystemLocale.pendingRestart
|
||||||
|
? "Your new language applies to programs started after you sign out and back in."
|
||||||
|
: "The language and regional formats this machine uses."
|
||||||
|
|
||||||
|
Component.onCompleted: if (SystemLocale.locales.length === 0) SystemLocale.refresh()
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
title: "Language"
|
||||||
|
subtitle: "Changing this needs your password, and takes effect for programs started afterwards."
|
||||||
|
|
||||||
|
TextRow {
|
||||||
|
label: "Current language"
|
||||||
|
detail: SystemLocale.pendingRestart
|
||||||
|
? "Chosen, but not in use until you sign out and back in"
|
||||||
|
: "Used by programs that ask the system what language to speak"
|
||||||
|
value: SystemLocale.currentLabel || "Reading…"
|
||||||
|
}
|
||||||
|
|
||||||
|
SearchPicker {
|
||||||
|
width: parent.width
|
||||||
|
items: SystemLocale.locales
|
||||||
|
current: SystemLocale.current
|
||||||
|
placeholder: "Search languages and regions"
|
||||||
|
emptyText: SystemLocale.scanning ? "Reading installed locales…" : "No locales are installed"
|
||||||
|
onPicked: value => SystemLocale.set(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
visible: SystemLocale.lastError !== ""
|
||||||
|
title: "Language problem"
|
||||||
|
subtitle: SystemLocale.lastError
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
title: "Formats"
|
||||||
|
subtitle: "Dates, times, and numbers follow the language above. The desktop's own clock formatting is on the Appearance page."
|
||||||
|
|
||||||
|
ActionRow {
|
||||||
|
label: "Clock and date display"
|
||||||
|
detail: "How the desktop itself shows the time"
|
||||||
|
action: "Open appearance"
|
||||||
|
onTriggered: ShellState.openSettings("appearance")
|
||||||
|
}
|
||||||
|
|
||||||
|
ActionRow {
|
||||||
|
label: "Regional formats"
|
||||||
|
detail: "Separate per-category formats (LC_TIME, LC_NUMERIC) are owned by Fedora"
|
||||||
|
action: "Open system"
|
||||||
|
divider: false
|
||||||
|
onTriggered: SystemSettings.openGnomePanel("system", "region")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@ SettingsPage {
|
|||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Read anything on screen"
|
title: "Read anything on screen"
|
||||||
subtitle: "Select a region, window, or display. Panama recognizes it locally and gives you clean follow-up actions."
|
subtitle: "Select a region, window, or display. It is recognized locally, with clean follow-up actions."
|
||||||
|
|
||||||
SettingRow {
|
SettingRow {
|
||||||
icon: ""
|
icon: ""
|
||||||
@@ -52,7 +52,7 @@ SettingsPage {
|
|||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Local recognition"
|
title: "Local recognition"
|
||||||
subtitle: "The screen image stays in Panama's cache and is deleted when you dismiss the result."
|
subtitle: "The screen image stays in a local cache and is deleted when you dismiss the result."
|
||||||
|
|
||||||
TextRow {
|
TextRow {
|
||||||
label: "Text recognition"
|
label: "Text recognition"
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
// A searchable list of choices, for settings with far too many values to put in
|
||||||
|
// a dropdown and no useful preview to show.
|
||||||
|
//
|
||||||
|
// SearchPicker {
|
||||||
|
// items: Locale.locales // [{ value, label, detail }]
|
||||||
|
// current: Locale.current
|
||||||
|
// placeholder: "Search languages"
|
||||||
|
// onPicked: value => Locale.set(value)
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// FontPicker is the same idea specialised: it draws each candidate in its own
|
||||||
|
// family, which is the whole reason it is a list rather than a text field. This
|
||||||
|
// one is for choices whose only useful preview is their name.
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import qs.config
|
||||||
|
// SearchField lives with the clipboard module, which is where it was first
|
||||||
|
// needed; FontPicker imports it from the same place.
|
||||||
|
import qs.modules.clipboard
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
// [{ value, label, detail }]
|
||||||
|
property var items: []
|
||||||
|
property string current: ""
|
||||||
|
property string emptyText: "Nothing to choose from"
|
||||||
|
property string placeholder: "Search"
|
||||||
|
|
||||||
|
signal picked(string value)
|
||||||
|
|
||||||
|
spacing: 0
|
||||||
|
|
||||||
|
// Capped and filtered rather than listing everything: 327 locales inside a
|
||||||
|
// page that is already scrolling is worse than a search box.
|
||||||
|
readonly property var matches: {
|
||||||
|
const needle = filter.text.trim().toLowerCase();
|
||||||
|
const list = root.items.filter(item =>
|
||||||
|
needle === ""
|
||||||
|
? true
|
||||||
|
: (String(item.label).toLowerCase().indexOf(needle) >= 0
|
||||||
|
|| String(item.detail).toLowerCase().indexOf(needle) >= 0));
|
||||||
|
|
||||||
|
// The current choice stays visible while browsing, so it is always
|
||||||
|
// clear what would be replaced.
|
||||||
|
const selected = list.find(item => item.value === root.current);
|
||||||
|
if (needle === "" && selected !== undefined)
|
||||||
|
return [selected].concat(list.filter(item => item.value !== root.current)).slice(0, 12);
|
||||||
|
return list.slice(0, 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
SearchField {
|
||||||
|
id: filter
|
||||||
|
width: parent.width
|
||||||
|
placeholder: root.placeholder
|
||||||
|
}
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: root.matches
|
||||||
|
|
||||||
|
SettingRow {
|
||||||
|
id: candidate
|
||||||
|
|
||||||
|
required property var modelData
|
||||||
|
required property int index
|
||||||
|
|
||||||
|
readonly property bool selected: candidate.modelData.value === root.current
|
||||||
|
|
||||||
|
label: candidate.modelData.label
|
||||||
|
detail: candidate.selected ? "Currently in use" : candidate.modelData.detail
|
||||||
|
controlWidth: 120
|
||||||
|
divider: candidate.index < root.matches.length - 1
|
||||||
|
activatable: !candidate.selected
|
||||||
|
onActivated: root.picked(candidate.modelData.value)
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
visible: candidate.selected
|
||||||
|
text: "✓"
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSize + 2
|
||||||
|
color: Theme.accent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingRow {
|
||||||
|
width: parent.width
|
||||||
|
visible: root.matches.length === 0
|
||||||
|
label: root.emptyText
|
||||||
|
divider: false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
import QtQuick
|
|
||||||
import qs.config
|
|
||||||
import qs.services
|
|
||||||
|
|
||||||
SettingsPage {
|
|
||||||
title: "Startup & Services"
|
|
||||||
lede: "A clear view of the background tools that make the desktop feel complete."
|
|
||||||
|
|
||||||
function status(active: bool): string {
|
|
||||||
return active ? "Running" : "Stopped";
|
|
||||||
}
|
|
||||||
|
|
||||||
Item {
|
|
||||||
width: parent.width
|
|
||||||
implicitHeight: refresh.implicitHeight
|
|
||||||
|
|
||||||
SettingsButton {
|
|
||||||
id: refresh
|
|
||||||
anchors.right: parent.right
|
|
||||||
text: SystemSettings.busy ? "Refreshing…" : "Refresh"
|
|
||||||
enabled: !SystemSettings.busy
|
|
||||||
onClicked: SystemSettings.refresh()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
SettingsCard {
|
|
||||||
title: "Your services"
|
|
||||||
|
|
||||||
SettingRow {
|
|
||||||
label: "Nextcloud"
|
|
||||||
detail: "File synchronization and tray status"
|
|
||||||
controlWidth: 190
|
|
||||||
|
|
||||||
Row {
|
|
||||||
anchors.right: parent.right
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
spacing: 10
|
|
||||||
Text {
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
text: status(SystemSettings.nextcloudActive)
|
|
||||||
color: Theme.fgDim
|
|
||||||
font.family: Theme.fontFamily
|
|
||||||
font.pixelSize: Theme.fontSize
|
|
||||||
}
|
|
||||||
SettingsButton {
|
|
||||||
text: "Open"
|
|
||||||
onClicked: SystemSettings.openApplication("nextcloud")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
SettingRow {
|
|
||||||
label: "RustDesk"
|
|
||||||
detail: "Remote access through the enabled system service"
|
|
||||||
controlWidth: 190
|
|
||||||
|
|
||||||
Row {
|
|
||||||
anchors.right: parent.right
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
spacing: 10
|
|
||||||
Text {
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
text: status(SystemSettings.rustdeskActive)
|
|
||||||
color: Theme.fgDim
|
|
||||||
font.family: Theme.fontFamily
|
|
||||||
font.pixelSize: Theme.fontSize
|
|
||||||
}
|
|
||||||
SettingsButton {
|
|
||||||
text: "Open"
|
|
||||||
onClicked: SystemSettings.openApplication("rustdesk")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
SettingRow {
|
|
||||||
label: "KDE Connect"
|
|
||||||
detail: "Phone pairing, clipboard, files, and remote controls"
|
|
||||||
divider: false
|
|
||||||
controlWidth: 190
|
|
||||||
|
|
||||||
Row {
|
|
||||||
anchors.right: parent.right
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
spacing: 10
|
|
||||||
Text {
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
text: status(SystemSettings.kdeconnectActive)
|
|
||||||
color: Theme.fgDim
|
|
||||||
font.family: Theme.fontFamily
|
|
||||||
font.pixelSize: Theme.fontSize
|
|
||||||
}
|
|
||||||
SettingsButton {
|
|
||||||
text: "Open"
|
|
||||||
onClicked: SystemSettings.openApplication("kdeconnect")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
SettingsCard {
|
|
||||||
title: "Desktop foundation"
|
|
||||||
|
|
||||||
TextRow {
|
|
||||||
label: "Hyprpaper"
|
|
||||||
detail: "Wallpaper service"
|
|
||||||
value: status(SystemSettings.hyprpaperActive)
|
|
||||||
}
|
|
||||||
TextRow {
|
|
||||||
label: "Hypridle"
|
|
||||||
detail: "Idle and lock policy"
|
|
||||||
value: status(SystemSettings.hypridleActive)
|
|
||||||
}
|
|
||||||
TextRow {
|
|
||||||
label: "Vicinae"
|
|
||||||
detail: "Spotlight-style launcher daemon"
|
|
||||||
value: status(SystemSettings.vicinaeActive)
|
|
||||||
divider: false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
SettingsCard {
|
|
||||||
title: "Fedora system settings"
|
|
||||||
subtitle: "These remain owned by trusted system services and GNOME's mature panels."
|
|
||||||
|
|
||||||
ActionRow {
|
|
||||||
label: "Network, Bluetooth, printers, users, and accounts"
|
|
||||||
detail: "GNOME Settings remains searchable from the launcher too"
|
|
||||||
divider: false
|
|
||||||
action: "Open network"
|
|
||||||
onTriggered: SystemSettings.openGnomePanel("network")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -11,6 +11,19 @@ Rectangle {
|
|||||||
&& pageLoader.item.objectName === "home-phone-page"
|
&& pageLoader.item.objectName === "home-phone-page"
|
||||||
? pageLoader.item.pageDiagnostics
|
? pageLoader.item.pageDiagnostics
|
||||||
: ({})
|
: ({})
|
||||||
|
readonly property var healthDiagnostics: pageLoader.status === Loader.Ready
|
||||||
|
&& pageLoader.item
|
||||||
|
&& pageLoader.item.objectName === "system-health-page"
|
||||||
|
? pageLoader.item.uiDiagnostics()
|
||||||
|
: ({})
|
||||||
|
|
||||||
|
function requestHealthAction(id: string): bool {
|
||||||
|
if (pageLoader.status !== Loader.Ready
|
||||||
|
|| !pageLoader.item
|
||||||
|
|| pageLoader.item.objectName !== "system-health-page")
|
||||||
|
return false;
|
||||||
|
return pageLoader.item.activateRenderedAction(id);
|
||||||
|
}
|
||||||
|
|
||||||
color: Theme.bg
|
color: Theme.bg
|
||||||
radius: 18
|
radius: 18
|
||||||
@@ -40,7 +53,7 @@ Rectangle {
|
|||||||
anchors.left: parent.left
|
anchors.left: parent.left
|
||||||
anchors.leftMargin: 18
|
anchors.leftMargin: 18
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
text: "Panama Settings"
|
text: "Settings"
|
||||||
color: Theme.fg
|
color: Theme.fg
|
||||||
font.family: Theme.fontFamily
|
font.family: Theme.fontFamily
|
||||||
font.pixelSize: Theme.fontSize
|
font.pixelSize: Theme.fontSize
|
||||||
@@ -99,11 +112,15 @@ Rectangle {
|
|||||||
case "notifications": return notificationsPage;
|
case "notifications": return notificationsPage;
|
||||||
case "screen-intelligence": return screenIntelligencePage;
|
case "screen-intelligence": return screenIntelligencePage;
|
||||||
case "shortcuts": return shortcutsPage;
|
case "shortcuts": return shortcutsPage;
|
||||||
|
case "mouse": return mousePage;
|
||||||
|
case "privacy": return privacyPage;
|
||||||
|
case "region": return regionPage;
|
||||||
|
case "accounts": return onlineAccountsPage;
|
||||||
case "accessibility": return accessibilityPage;
|
case "accessibility": return accessibilityPage;
|
||||||
case "power": return powerPage;
|
case "power": return powerPage;
|
||||||
case "datetime": return dateTimePage;
|
case "datetime": return dateTimePage;
|
||||||
case "applications": return applicationsPage;
|
case "applications": return applicationsPage;
|
||||||
case "services": return servicesPage;
|
case "services": return healthPage;
|
||||||
case "about": return aboutPage;
|
case "about": return aboutPage;
|
||||||
default: return homePage;
|
default: return homePage;
|
||||||
}
|
}
|
||||||
@@ -153,7 +170,11 @@ Rectangle {
|
|||||||
Component { id: notificationsPage; NotificationsPage {} }
|
Component { id: notificationsPage; NotificationsPage {} }
|
||||||
Component { id: screenIntelligencePage; ScreenIntelligencePage {} }
|
Component { id: screenIntelligencePage; ScreenIntelligencePage {} }
|
||||||
Component { id: shortcutsPage; ShortcutsPage {} }
|
Component { id: shortcutsPage; ShortcutsPage {} }
|
||||||
Component { id: servicesPage; ServicesPage {} }
|
Component { id: mousePage; MousePage {} }
|
||||||
|
Component { id: privacyPage; PrivacyPage {} }
|
||||||
|
Component { id: regionPage; RegionPage {} }
|
||||||
|
Component { id: onlineAccountsPage; OnlineAccountsPage {} }
|
||||||
|
Component { id: healthPage; HealthPage {} }
|
||||||
Component { id: aboutPage; AboutPage {} }
|
Component { id: aboutPage; AboutPage {} }
|
||||||
|
|
||||||
Shortcut {
|
Shortcut {
|
||||||
|
|||||||
@@ -31,13 +31,17 @@ Rectangle {
|
|||||||
{ page: "sound", label: "Sound", icon: "\u{F057E}" },
|
{ page: "sound", label: "Sound", icon: "\u{F057E}" },
|
||||||
{ page: "notifications", label: "Notifications & Focus", icon: "\u{F009A}" },
|
{ page: "notifications", label: "Notifications & Focus", icon: "\u{F009A}" },
|
||||||
{ page: "screen-intelligence", label: "Screen Intelligence", icon: "\u{F05A8}" },
|
{ page: "screen-intelligence", label: "Screen Intelligence", icon: "\u{F05A8}" },
|
||||||
{ page: "shortcuts", label: "Input & Shortcuts", icon: "\u{F030C}" },
|
{ page: "shortcuts", label: "Keyboard", icon: "\u{F030C}" },
|
||||||
|
{ page: "mouse", label: "Mouse & Touchpad", icon: "\u{F037D}" },
|
||||||
|
{ page: "privacy", label: "Privacy & Security", icon: "\u{F0483}" },
|
||||||
|
{ page: "region", label: "Region & Language", icon: "\u{F0AC2}" },
|
||||||
|
{ page: "accounts", label: "Online Accounts", icon: "\u{F0004}" },
|
||||||
{ page: "accessibility", label: "Accessibility", icon: "\u{F0208}" },
|
{ page: "accessibility", label: "Accessibility", icon: "\u{F0208}" },
|
||||||
{ page: "power", label: "Power & Lock", icon: "\u{F0425}" },
|
{ page: "power", label: "Power & Lock", icon: "\u{F0425}" },
|
||||||
{ page: "datetime", label: "Date & Time", icon: "\u{F0954}" },
|
{ page: "datetime", label: "Date & Time", icon: "\u{F0954}" },
|
||||||
{ page: "applications", label: "Applications", icon: "\u{F003B}" },
|
{ page: "applications", label: "Applications", icon: "\u{F003B}" },
|
||||||
{ page: "services", label: "Startup & Services", icon: "\u{F0493}" },
|
{ page: "services", label: "System Health", icon: "\u{F0493}" },
|
||||||
{ page: "about", label: "About Panama", icon: "\u{F02FD}" }
|
{ page: "about", label: "About", icon: "\u{F02FD}" }
|
||||||
]
|
]
|
||||||
|
|
||||||
width: 272
|
width: 272
|
||||||
@@ -56,15 +60,6 @@ Rectangle {
|
|||||||
height: implicitHeight
|
height: implicitHeight
|
||||||
spacing: 12
|
spacing: 12
|
||||||
|
|
||||||
Text {
|
|
||||||
text: "PANAMA"
|
|
||||||
color: Theme.fg
|
|
||||||
font.family: Theme.fontFamily
|
|
||||||
font.pixelSize: Theme.fontSizeLarge
|
|
||||||
font.weight: Font.DemiBold
|
|
||||||
font.letterSpacing: 2.2
|
|
||||||
}
|
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
width: parent.width
|
width: parent.width
|
||||||
height: 36
|
height: 36
|
||||||
@@ -297,8 +292,34 @@ Rectangle {
|
|||||||
anchors.right: parent.right
|
anchors.right: parent.right
|
||||||
anchors.bottom: parent.bottom
|
anchors.bottom: parent.bottom
|
||||||
height: 54
|
height: 54
|
||||||
color: Theme.alpha(Theme.bg, 0.35)
|
activeFocusOnTab: true
|
||||||
border.width: 0
|
color: footerTap.hovered || activeFocus
|
||||||
|
? Theme.alpha(Theme.fg, 0.07)
|
||||||
|
: Theme.alpha(Theme.bg, 0.35)
|
||||||
|
border.width: activeFocus ? 2 : 0
|
||||||
|
border.color: Theme.accent
|
||||||
|
|
||||||
|
function footerText(): string {
|
||||||
|
if (Health.checks.length === 0)
|
||||||
|
return Health.diagnosticUnavailable ? "Health check unavailable" : "Checking the desktop";
|
||||||
|
if (Health.status === "error")
|
||||||
|
return "Desktop needs attention";
|
||||||
|
if (Health.status === "warning") {
|
||||||
|
const count = Health.summary.warnings + Health.summary.errors;
|
||||||
|
return count + (count === 1 ? " health observation" : " health observations");
|
||||||
|
}
|
||||||
|
return "Desktop is healthy";
|
||||||
|
}
|
||||||
|
|
||||||
|
function footerColor(): color {
|
||||||
|
if (Health.checks.length === 0)
|
||||||
|
return Health.diagnosticUnavailable ? Theme.danger : Theme.fgMuted;
|
||||||
|
if (Health.status === "error")
|
||||||
|
return Theme.danger;
|
||||||
|
if (Health.status === "warning")
|
||||||
|
return Theme.warn;
|
||||||
|
return Theme.ok;
|
||||||
|
}
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
width: 7
|
width: 7
|
||||||
@@ -307,17 +328,28 @@ Rectangle {
|
|||||||
anchors.left: parent.left
|
anchors.left: parent.left
|
||||||
anchors.leftMargin: 19
|
anchors.leftMargin: 19
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
color: Theme.ok
|
color: healthFooter.footerColor()
|
||||||
}
|
}
|
||||||
|
|
||||||
Text {
|
Text {
|
||||||
anchors.left: parent.left
|
anchors.left: parent.left
|
||||||
anchors.leftMargin: 36
|
anchors.leftMargin: 36
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.rightMargin: 12
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
text: "Panama desktop is healthy"
|
text: healthFooter.footerText()
|
||||||
color: Theme.fgDim
|
color: Theme.fgDim
|
||||||
font.family: Theme.fontFamily
|
font.family: Theme.fontFamily
|
||||||
font.pixelSize: Theme.fontSizeSmall
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
elide: Text.ElideRight
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TapHandler {
|
||||||
|
id: footerTap
|
||||||
|
onTapped: root.pageRequested("services")
|
||||||
|
}
|
||||||
|
|
||||||
|
Keys.onReturnPressed: root.pageRequested("services")
|
||||||
|
Keys.onSpacePressed: root.pageRequested("services")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ FloatingWindow {
|
|||||||
|
|
||||||
readonly property var homePhoneDiagnostics: settingsShell.homePhoneDiagnostics
|
readonly property var homePhoneDiagnostics: settingsShell.homePhoneDiagnostics
|
||||||
|
|
||||||
title: "Panama Settings"
|
title: "Settings"
|
||||||
visible: ShellState.settingsOpen
|
visible: ShellState.settingsOpen
|
||||||
implicitWidth: 1120
|
implicitWidth: 1120
|
||||||
implicitHeight: 760
|
implicitHeight: 760
|
||||||
|
|||||||
@@ -21,6 +21,25 @@ SettingsPage {
|
|||||||
// when nothing is being captured. Held here rather than per row so that
|
// when nothing is being captured. Held here rather than per row so that
|
||||||
// starting a new capture cancels any other.
|
// starting a new capture cancels any other.
|
||||||
property string capturingChord: ""
|
property string capturingChord: ""
|
||||||
|
readonly property string storedXkbOptions: String(DesktopPreferences.get("keyboardOptions") ?? "")
|
||||||
|
|
||||||
|
function xkbOptions(): var {
|
||||||
|
return root.storedXkbOptions
|
||||||
|
.split(",")
|
||||||
|
.map(option => option.trim())
|
||||||
|
.filter(option => option !== "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentXkbOption(prefix: string): string {
|
||||||
|
return root.xkbOptions().find(option => option.indexOf(prefix) === 0) ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function setXkbOption(prefix: string, option: string): void {
|
||||||
|
const options = root.xkbOptions().filter(option => option.indexOf(prefix) !== 0);
|
||||||
|
if (option !== "")
|
||||||
|
options.push(option);
|
||||||
|
SystemSettings.commitPreference("keyboardOptions", options.join(","));
|
||||||
|
}
|
||||||
|
|
||||||
title: "Input & Shortcuts"
|
title: "Input & Shortcuts"
|
||||||
lede: "The Forge mental model, carried forward into native tiling."
|
lede: "The Forge mental model, carried forward into native tiling."
|
||||||
@@ -28,24 +47,65 @@ SettingsPage {
|
|||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Keyboard"
|
title: "Keyboard"
|
||||||
|
|
||||||
TextRow {
|
// These were read-only text, on the grounds that a layout change needed
|
||||||
label: "Keyboard layout"
|
// a compositor reload. It does not: setting input:kb_variant through
|
||||||
detail: "XKB layout name. Changing it needs a compositor reload, so it is shown here rather than offered as a control that appears to apply instantly."
|
// hl.config re-keymaps attached keyboards immediately -- verified by
|
||||||
value: Settings ? DesktopPreferences.get("keyboardLayout") : "us"
|
// watching active_keymap on a real keyboard change and change back. So
|
||||||
|
// they are real controls.
|
||||||
|
TextEntryRow { setting: "keyboardLayout"; placeholder: "us" }
|
||||||
|
TextEntryRow { setting: "keyboardVariant"; placeholder: "none" }
|
||||||
|
|
||||||
|
ChoiceGrid {
|
||||||
|
width: parent.width
|
||||||
|
label: "Caps Lock"
|
||||||
|
detail: "Keep it conventional, or turn a prime keyboard position into Escape or Control"
|
||||||
|
current: root.currentXkbOption("caps:")
|
||||||
|
options: [
|
||||||
|
{ value: "", label: "Standard" },
|
||||||
|
{ value: "caps:escape_shifted_capslock", label: "Esc · Shift for Caps" },
|
||||||
|
{ value: "caps:escape", label: "Escape" },
|
||||||
|
{ value: "caps:ctrl_modifier", label: "Control" }
|
||||||
|
]
|
||||||
|
onPicked: value => root.setXkbOption("caps:", value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ChoiceGrid {
|
||||||
|
width: parent.width
|
||||||
|
label: "Compose key"
|
||||||
|
detail: "Type accented characters and symbols with memorable key sequences"
|
||||||
|
current: root.currentXkbOption("compose:")
|
||||||
|
options: [
|
||||||
|
{ value: "", label: "Off" },
|
||||||
|
{ value: "compose:ralt", label: "Right Alt" },
|
||||||
|
{ value: "compose:rwin", label: "Right Super" },
|
||||||
|
{ value: "compose:menu", label: "Menu" }
|
||||||
|
]
|
||||||
|
onPicked: value => root.setXkbOption("compose:", value)
|
||||||
|
}
|
||||||
|
|
||||||
|
ChoiceGrid {
|
||||||
|
width: parent.width
|
||||||
|
label: "Layout switching"
|
||||||
|
detail: "Used when Keyboard layout contains more than one comma-separated layout"
|
||||||
|
current: root.currentXkbOption("grp:")
|
||||||
|
options: [
|
||||||
|
{ value: "", label: "Off" },
|
||||||
|
{ value: "grp:win_space_toggle", label: "Super + Space" },
|
||||||
|
{ value: "grp:alt_shift_toggle", label: "Alt + Shift" },
|
||||||
|
{ value: "grp:ctrl_shift_toggle", label: "Ctrl + Shift" },
|
||||||
|
{ value: "grp:caps_toggle", label: "Caps Lock" }
|
||||||
|
]
|
||||||
|
onPicked: value => root.setXkbOption("grp:", value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Presets preserve every option outside their own category. The raw
|
||||||
|
// value remains visible for less common xkeyboard-config features.
|
||||||
|
TextEntryRow { setting: "keyboardOptions"; placeholder: "compose:ralt" }
|
||||||
SliderRow { setting: "keyRepeatDelay" }
|
SliderRow { setting: "keyRepeatDelay" }
|
||||||
SliderRow { setting: "keyRepeatRate" }
|
SliderRow { setting: "keyRepeatRate" }
|
||||||
ToggleRow { setting: "numlockByDefault"; divider: false }
|
ToggleRow { setting: "numlockByDefault"; divider: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
SettingsCard {
|
|
||||||
title: "Pointer"
|
|
||||||
|
|
||||||
ChoiceRow { setting: "followMouse" }
|
|
||||||
SliderRow { setting: "pointerSensitivity" }
|
|
||||||
SliderRow { setting: "cursorInactiveTimeout"; zeroLabel: "Never"; divider: false }
|
|
||||||
}
|
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Hardware input"
|
title: "Hardware input"
|
||||||
|
|
||||||
@@ -155,7 +215,7 @@ SettingsPage {
|
|||||||
SettingsCard {
|
SettingsCard {
|
||||||
visible: Object.keys(Keybinds.overrides).length > 0
|
visible: Object.keys(Keybinds.overrides).length > 0
|
||||||
title: "Changed shortcuts"
|
title: "Changed shortcuts"
|
||||||
subtitle: "Rebinding stores only the new chord; what a shortcut does always comes from Panama's configuration."
|
subtitle: "Rebinding stores only the new chord; what a shortcut does always comes from the desktop's configuration."
|
||||||
|
|
||||||
ActionRow {
|
ActionRow {
|
||||||
label: "Restore every shipped shortcut"
|
label: "Restore every shipped shortcut"
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
// A free-text setting, bound to a schema key by name.
|
||||||
|
//
|
||||||
|
// TextEntryRow { setting: "keyboardOptions"; placeholder: "compose:ralt" }
|
||||||
|
//
|
||||||
|
// For the settings whose value is a short string with too many legal values to
|
||||||
|
// enumerate -- XKB layouts and options, chiefly. Label, explanation, and
|
||||||
|
// validation all come from PreferenceSchema, so a row cannot drift from the
|
||||||
|
// setting it edits.
|
||||||
|
//
|
||||||
|
// Rejected input is shown as rejected rather than silently dropped or quietly
|
||||||
|
// sanitised. Several of these strings are serialised into an hl.config payload,
|
||||||
|
// where stripping an unexpected character would turn a typo into a different
|
||||||
|
// working setting -- so the schema's pattern decides, the field turns red when
|
||||||
|
// it fails, and nothing is committed until it passes.
|
||||||
|
//
|
||||||
|
// Committed on Enter or when focus leaves, not per keystroke: half a layout
|
||||||
|
// name is a valid string that means something else, and each commit is a round
|
||||||
|
// trip to the compositor.
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import qs.config
|
||||||
|
import qs.services
|
||||||
|
|
||||||
|
SettingRow {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
required property string setting
|
||||||
|
property string placeholder: ""
|
||||||
|
|
||||||
|
readonly property var spec: PreferenceSchema.spec(root.setting)
|
||||||
|
readonly property string stored: String(DesktopPreferences.get(root.setting) ?? "")
|
||||||
|
|
||||||
|
// Empty is always allowed to be typed through, even where the pattern
|
||||||
|
// forbids it, so a field can be cleared on the way to a new value.
|
||||||
|
readonly property bool valid: input.text === ""
|
||||||
|
|| !root.spec?.pattern
|
||||||
|
|| new RegExp(root.spec.pattern).test(input.text)
|
||||||
|
|
||||||
|
label: root.spec ? root.spec.label : root.setting
|
||||||
|
detail: root.spec ? root.spec.detail : ""
|
||||||
|
controlWidth: 210
|
||||||
|
|
||||||
|
function commit(): void {
|
||||||
|
if (!root.valid || input.text === root.stored)
|
||||||
|
return;
|
||||||
|
SystemSettings.commitPreference(root.setting, input.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
function revert(): void {
|
||||||
|
input.text = root.stored;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Follows the store when the value changes elsewhere -- a reset, a restored
|
||||||
|
// backup -- but never while the field has focus, which would overwrite what
|
||||||
|
// is being typed.
|
||||||
|
onStoredChanged: if (!input.activeFocus) input.text = root.stored
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
width: root.controlWidth
|
||||||
|
height: 30
|
||||||
|
radius: 8
|
||||||
|
color: Theme.alpha(Theme.fg, 0.05)
|
||||||
|
border.width: 1
|
||||||
|
border.color: !root.valid
|
||||||
|
? Theme.danger
|
||||||
|
: (input.activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.12))
|
||||||
|
|
||||||
|
TextInput {
|
||||||
|
id: input
|
||||||
|
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.leftMargin: 10
|
||||||
|
anchors.rightMargin: 10
|
||||||
|
verticalAlignment: TextInput.AlignVCenter
|
||||||
|
clip: true
|
||||||
|
|
||||||
|
text: root.stored
|
||||||
|
color: root.valid ? Theme.fg : Theme.danger
|
||||||
|
font.family: Theme.fontMono
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
selectByMouse: true
|
||||||
|
selectionColor: Theme.alpha(Theme.accent, 0.35)
|
||||||
|
|
||||||
|
onAccepted: root.commit()
|
||||||
|
|
||||||
|
// Commit, then show what is actually stored. If the compositor
|
||||||
|
// refused the value, the field snaps back to the value in effect
|
||||||
|
// rather than displaying a setting that was never applied; if it
|
||||||
|
// accepted, onStoredChanged brings the field back up to date the
|
||||||
|
// moment the write lands.
|
||||||
|
onActiveFocusChanged: if (!activeFocus) { root.commit(); root.revert(); }
|
||||||
|
|
||||||
|
Keys.onEscapePressed: {
|
||||||
|
root.revert();
|
||||||
|
input.focus = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
visible: input.text === "" && !input.activeFocus
|
||||||
|
text: root.placeholder
|
||||||
|
color: Theme.fgMuted
|
||||||
|
font: input.font
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -156,7 +156,7 @@ Item {
|
|||||||
width: parent.width - 40
|
width: parent.width - 40
|
||||||
horizontalAlignment: Text.AlignHCenter
|
horizontalAlignment: Text.AlignHCenter
|
||||||
wrapMode: Text.WordWrap
|
wrapMode: Text.WordWrap
|
||||||
text: "No images found. Panama looks in ~/Pictures/Wallpapers, ~/Pictures/Backgrounds, ~/.local/share/backgrounds, and /usr/share/backgrounds."
|
text: "No images found. Looked in ~/Pictures/Wallpapers, ~/Pictures/Backgrounds, ~/.local/share/backgrounds, and /usr/share/backgrounds."
|
||||||
color: Theme.fgMuted
|
color: Theme.fgMuted
|
||||||
font.family: Theme.fontFamily
|
font.family: Theme.fontFamily
|
||||||
font.pixelSize: Theme.fontSizeSmall
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ DisplaysPage 1.0 DisplaysPage.qml
|
|||||||
HomePage 1.0 HomePage.qml
|
HomePage 1.0 HomePage.qml
|
||||||
NotificationsPage 1.0 NotificationsPage.qml
|
NotificationsPage 1.0 NotificationsPage.qml
|
||||||
ScreenIntelligencePage 1.0 ScreenIntelligencePage.qml
|
ScreenIntelligencePage 1.0 ScreenIntelligencePage.qml
|
||||||
ServicesPage 1.0 ServicesPage.qml
|
HealthPage 1.0 HealthPage.qml
|
||||||
|
HealthSummary 1.0 HealthSummary.qml
|
||||||
|
HealthCheckRow 1.0 HealthCheckRow.qml
|
||||||
SettingRow 1.0 SettingRow.qml
|
SettingRow 1.0 SettingRow.qml
|
||||||
SettingsCard 1.0 SettingsCard.qml
|
SettingsCard 1.0 SettingsCard.qml
|
||||||
SettingsButton 1.0 SettingsButton.qml
|
SettingsButton 1.0 SettingsButton.qml
|
||||||
@@ -32,6 +34,7 @@ DateTimePage 1.0 DateTimePage.qml
|
|||||||
AccessibilityPage 1.0 AccessibilityPage.qml
|
AccessibilityPage 1.0 AccessibilityPage.qml
|
||||||
WallpaperPicker 1.0 WallpaperPicker.qml
|
WallpaperPicker 1.0 WallpaperPicker.qml
|
||||||
ApplicationsPage 1.0 ApplicationsPage.qml
|
ApplicationsPage 1.0 ApplicationsPage.qml
|
||||||
|
AutostartAppPicker 1.0 AutostartAppPicker.qml
|
||||||
DockPinsEditor 1.0 DockPinsEditor.qml
|
DockPinsEditor 1.0 DockPinsEditor.qml
|
||||||
DockAppPicker 1.0 DockAppPicker.qml
|
DockAppPicker 1.0 DockAppPicker.qml
|
||||||
ShortcutCapture 1.0 ShortcutCapture.qml
|
ShortcutCapture 1.0 ShortcutCapture.qml
|
||||||
@@ -46,3 +49,9 @@ SoundDeviceRow 1.0 SoundDeviceRow.qml
|
|||||||
TimeOfDayRow 1.0 TimeOfDayRow.qml
|
TimeOfDayRow 1.0 TimeOfDayRow.qml
|
||||||
LocationPicker 1.0 LocationPicker.qml
|
LocationPicker 1.0 LocationPicker.qml
|
||||||
FontPicker 1.0 FontPicker.qml
|
FontPicker 1.0 FontPicker.qml
|
||||||
|
MousePage 1.0 MousePage.qml
|
||||||
|
TextEntryRow 1.0 TextEntryRow.qml
|
||||||
|
PrivacyPage 1.0 PrivacyPage.qml
|
||||||
|
RegionPage 1.0 RegionPage.qml
|
||||||
|
SearchPicker 1.0 SearchPicker.qml
|
||||||
|
OnlineAccountsPage 1.0 OnlineAccountsPage.qml
|
||||||
|
|||||||
Executable
+127
@@ -0,0 +1,127 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# What this machine is, as JSON: {label, value} pairs in display order.
|
||||||
|
#
|
||||||
|
# GNOME's About panel answers "what am I running on" in one screen, and
|
||||||
|
# fastfetch answers it in more detail; this covers both -- model, OS, kernel,
|
||||||
|
# uptime, package counts, shell, resolution, processor, memory, swap, disk and
|
||||||
|
# locale. Rows are ordered roughly the way fastfetch presents them: what the
|
||||||
|
# system is, then what is installed on it, then the hardware underneath.
|
||||||
|
#
|
||||||
|
# Graphics is deliberately absent: GraphicsDevices already enumerates GPUs for
|
||||||
|
# the vitals readout, and naming them again here would be a second source of
|
||||||
|
# truth that could disagree with the first. The page joins the two.
|
||||||
|
#
|
||||||
|
# Anything unreadable is omitted rather than reported as "Unknown". These are
|
||||||
|
# facts about hardware, and a row saying "Processor: Unknown" is noise where
|
||||||
|
# simply not having the row is not.
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
facts=()
|
||||||
|
|
||||||
|
emit() {
|
||||||
|
[[ -n "${2:-}" ]] || return 0
|
||||||
|
facts+=("$(jq -cn --arg label "$1" --arg value "$2" '{label: $label, value: $value}')")
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── System ───────────────────────────────────────────────────────────────────
|
||||||
|
if [[ -r /etc/os-release ]]; then
|
||||||
|
# Sourced in a subshell so the variables cannot leak into this script.
|
||||||
|
os="$( . /etc/os-release 2>/dev/null && printf '%s' "${PRETTY_NAME:-$NAME}" )"
|
||||||
|
emit "Operating system" "$os"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# DMI strings are frequently placeholders ("To Be Filled By O.E.M.", "Default
|
||||||
|
# string"). Those are worse than nothing, so they are filtered out.
|
||||||
|
dmi() {
|
||||||
|
local value
|
||||||
|
value="$(cat "/sys/class/dmi/id/$1" 2>/dev/null)" || return 0
|
||||||
|
case "$value" in
|
||||||
|
""|"To Be Filled By O.E.M."*|"Default string"|"System Product Name"|"Unknown"|"None") return 0 ;;
|
||||||
|
esac
|
||||||
|
printf '%s' "$value"
|
||||||
|
}
|
||||||
|
|
||||||
|
vendor="$(dmi sys_vendor)"
|
||||||
|
product="$(dmi product_name)"
|
||||||
|
if [[ -n "$vendor" && -n "$product" ]]; then
|
||||||
|
emit "Model" "$vendor $product"
|
||||||
|
else
|
||||||
|
emit "Model" "${product:-$vendor}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
emit "Hostname" "$(hostnamectl hostname 2>/dev/null || hostname 2>/dev/null)"
|
||||||
|
emit "Kernel" "$(uname -r 2>/dev/null)"
|
||||||
|
|
||||||
|
# `uptime -p` already reads as prose ("1 week, 23 hours, 5 minutes"); only the
|
||||||
|
# leading "up " needs removing.
|
||||||
|
emit "Uptime" "$(uptime -p 2>/dev/null | sed 's/^up //')"
|
||||||
|
|
||||||
|
# ── Installed ────────────────────────────────────────────────────────────────
|
||||||
|
# Counted rather than listed. rpm -qa on a full workstation is a few thousand
|
||||||
|
# lines and takes a moment, which is part of why this whole helper runs on
|
||||||
|
# demand rather than at startup.
|
||||||
|
packages=""
|
||||||
|
if command -v rpm >/dev/null 2>&1; then
|
||||||
|
rpm_count="$(rpm -qa 2>/dev/null | wc -l)"
|
||||||
|
[[ "$rpm_count" -gt 0 ]] && packages="$rpm_count rpm"
|
||||||
|
fi
|
||||||
|
if command -v flatpak >/dev/null 2>&1; then
|
||||||
|
flatpak_count="$(flatpak list --app 2>/dev/null | wc -l)"
|
||||||
|
if [[ "$flatpak_count" -gt 0 ]]; then
|
||||||
|
[[ -n "$packages" ]] && packages="$packages, "
|
||||||
|
packages="$packages$flatpak_count flatpak"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
emit "Packages" "$packages"
|
||||||
|
|
||||||
|
# $SHELL is the login shell, which is the one worth reporting -- the shell this
|
||||||
|
# script happens to run under is an implementation detail of the caller.
|
||||||
|
if [[ -n "${SHELL:-}" ]]; then
|
||||||
|
shell_name="$(basename "$SHELL")"
|
||||||
|
shell_version="$("$SHELL" --version 2>/dev/null | head -1 | grep -oE '[0-9]+\.[0-9]+(\.[0-9]+)?' | head -1)"
|
||||||
|
emit "Shell" "${shell_name}${shell_version:+ $shell_version}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
emit "Locale" "${LANG:-}"
|
||||||
|
|
||||||
|
case "${XDG_SESSION_TYPE:-}" in
|
||||||
|
wayland) emit "Windowing system" "Wayland" ;;
|
||||||
|
x11) emit "Windowing system" "X11" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# ── Hardware ─────────────────────────────────────────────────────────────────
|
||||||
|
# The focused monitor's actual mode, including the fractional scale, since a
|
||||||
|
# 4500x3000 panel at 1.5 presents very differently from one at 1.
|
||||||
|
if command -v hyprctl >/dev/null 2>&1; then
|
||||||
|
emit "Resolution" "$(hyprctl -j monitors 2>/dev/null \
|
||||||
|
| jq -r 'map(select(.focused)) + . | .[0]
|
||||||
|
| select(. != null)
|
||||||
|
| "\(.width)x\(.height) @ \(.refreshRate | floor)Hz · scale \(.scale)"' 2>/dev/null)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cpu="$(awk -F': ' '/^model name/ { print $2; exit }' /proc/cpuinfo 2>/dev/null)"
|
||||||
|
threads="$(nproc 2>/dev/null)"
|
||||||
|
if [[ -n "$cpu" ]]; then
|
||||||
|
# The marketing name usually already says "6-Core", so the thread count is
|
||||||
|
# the part that adds information.
|
||||||
|
[[ -n "$threads" ]] && cpu="$cpu ($threads threads)"
|
||||||
|
emit "Processor" "$cpu"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Reported as the kernel sees it, which is a little under the sticker figure
|
||||||
|
# because firmware and integrated graphics reserve some before Linux starts.
|
||||||
|
#
|
||||||
|
# Total rather than used: About is not a monitor, and a "12.4 GiB used" figure
|
||||||
|
# is stale before it finishes drawing. The Home page's vitals readout is where
|
||||||
|
# live numbers belong.
|
||||||
|
emit "Memory" "$(awk '/^MemTotal:/ { printf "%.1f GiB", $2 / 1048576 }' /proc/meminfo 2>/dev/null)"
|
||||||
|
|
||||||
|
swap="$(awk '/^SwapTotal:/ { if ($2 > 0) printf "%.1f GiB", $2 / 1048576 }' /proc/meminfo 2>/dev/null)"
|
||||||
|
emit "Swap" "$swap"
|
||||||
|
|
||||||
|
read -r size used avail <<<"$(df -h --output=size,used,avail / 2>/dev/null | tail -1)"
|
||||||
|
[[ -n "${size:-}" ]] && emit "Disk" "$avail free of $size"
|
||||||
|
|
||||||
|
printf '[%s]\n' "$(IFS=,; printf '%s' "${facts[*]}")"
|
||||||
+145
@@ -0,0 +1,145 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
"""Online accounts, through GNOME Online Accounts.
|
||||||
|
|
||||||
|
GOA is a daemon plus a D-Bus API, and it is already running in this session --
|
||||||
|
gvfs activates it, and the four accounts on this machine work without
|
||||||
|
gnome-shell involved anywhere. What GNOME owns is only the *panel*; the accounts
|
||||||
|
themselves are ordinary D-Bus objects that anything may read and modify.
|
||||||
|
|
||||||
|
So everything except the initial sign-in is available to us: listing accounts,
|
||||||
|
turning individual services on and off, and removing an account. That is the
|
||||||
|
whole Online Accounts panel apart from one OAuth handshake.
|
||||||
|
|
||||||
|
Adding an account is the part that splits. The daemon's AddAccount takes
|
||||||
|
credentials as an ARGUMENT -- it stores them, it does not obtain them. For
|
||||||
|
password-based providers (Nextcloud, IMAP, WebDAV) that is a username and a
|
||||||
|
password, which a settings app can reasonably collect. For Google it is an OAuth
|
||||||
|
token, and the code that runs that exchange lives in libgoa-backend, which
|
||||||
|
Fedora ships without a GIR binding -- reachable from C only. Reimplementing it
|
||||||
|
would mean our own Google client credentials. So Google sign-in is handed to
|
||||||
|
GNOME's panel, and only the sign-in.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
panama-accounts list
|
||||||
|
panama-accounts set <object-path> <service> <true|false>
|
||||||
|
panama-accounts remove <object-path>
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# Every service GOA models. The account object carries one interface per service
|
||||||
|
# it supports, so presence of the interface is what "this account can do mail"
|
||||||
|
# means -- there is no capability list to read.
|
||||||
|
SERVICES = [
|
||||||
|
("mail", "Mail", "get_mail", "mail_disabled"),
|
||||||
|
("calendar", "Calendar", "get_calendar", "calendar_disabled"),
|
||||||
|
("contacts", "Contacts", "get_contacts", "contacts_disabled"),
|
||||||
|
("files", "Files", "get_files", "files_disabled"),
|
||||||
|
("photos", "Photos", "get_photos", "photos_disabled"),
|
||||||
|
("music", "Music", "get_music", "music_disabled"),
|
||||||
|
("chat", "Chat", "get_chat", "chat_disabled"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def load_client():
|
||||||
|
import gi
|
||||||
|
|
||||||
|
gi.require_version("Goa", "1.0")
|
||||||
|
from gi.repository import Goa
|
||||||
|
|
||||||
|
return Goa.Client.new_sync(None)
|
||||||
|
|
||||||
|
|
||||||
|
def describe(obj):
|
||||||
|
account = obj.get_account()
|
||||||
|
services = []
|
||||||
|
for key, label, getter, disabled_prop in SERVICES:
|
||||||
|
if getattr(obj, getter)() is None:
|
||||||
|
continue
|
||||||
|
services.append({
|
||||||
|
"key": key,
|
||||||
|
"label": label,
|
||||||
|
"enabled": not getattr(account.props, disabled_prop),
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"path": obj.get_object_path(),
|
||||||
|
"provider": account.props.provider_type,
|
||||||
|
"providerName": account.props.provider_name,
|
||||||
|
# PresentationIdentity is the human one (an email address); Identity is
|
||||||
|
# the internal handle and is not always readable.
|
||||||
|
"identity": account.props.presentation_identity or account.props.identity,
|
||||||
|
# GOA raises this when stored credentials stop working -- an expired
|
||||||
|
# token, a changed password. It is the one piece of state a user must
|
||||||
|
# act on, and nothing else surfaces it.
|
||||||
|
"needsAttention": bool(account.props.attention_needed),
|
||||||
|
"services": services,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def find(client, path):
|
||||||
|
for obj in client.get_accounts():
|
||||||
|
if obj.get_object_path() == path:
|
||||||
|
return obj
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
action = sys.argv[1] if len(sys.argv) > 1 else "list"
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = load_client()
|
||||||
|
except Exception as error: # noqa: BLE001 - any failure here means "no GOA"
|
||||||
|
# A machine without GOA is a legitimate state, not a crash.
|
||||||
|
print(json.dumps({
|
||||||
|
"accounts": [],
|
||||||
|
"error": f"GNOME Online Accounts is not available: {error}",
|
||||||
|
}))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if action == "list":
|
||||||
|
print(json.dumps({
|
||||||
|
"accounts": [describe(obj) for obj in client.get_accounts()],
|
||||||
|
"error": "",
|
||||||
|
}))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if action == "set":
|
||||||
|
if len(sys.argv) != 5:
|
||||||
|
print("usage: panama-accounts set <path> <service> <true|false>", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
path, service, value = sys.argv[2], sys.argv[3], sys.argv[4]
|
||||||
|
obj = find(client, path)
|
||||||
|
if obj is None:
|
||||||
|
print(f"panama-accounts: no account at {path}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
match = next((s for s in SERVICES if s[0] == service), None)
|
||||||
|
if match is None:
|
||||||
|
print(f"panama-accounts: unknown service {service!r}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
if getattr(obj, match[2])() is None:
|
||||||
|
print(f"panama-accounts: this account does not support {service}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
# The property is "disabled", so enabling a service clears it.
|
||||||
|
setattr(obj.get_account().props, match[3], value.lower() not in ("true", "1", "yes"))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if action == "remove":
|
||||||
|
if len(sys.argv) != 3:
|
||||||
|
print("usage: panama-accounts remove <path>", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
obj = find(client, sys.argv[2])
|
||||||
|
if obj is None:
|
||||||
|
print(f"panama-accounts: no account at {sys.argv[2]}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
obj.get_account().call_remove_sync(None)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
print("usage: panama-accounts [list|set|remove]", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -109,6 +109,7 @@ case "$action" in
|
|||||||
clipboard) qs ipc call clipboard open ;;
|
clipboard) qs ipc call clipboard open ;;
|
||||||
overview) qs ipc call overview open ;;
|
overview) qs ipc call overview open ;;
|
||||||
settings) qs ipc call settings open ;;
|
settings) qs ipc call settings open ;;
|
||||||
|
health) qs ipc call health open ;;
|
||||||
|
|
||||||
dnd)
|
dnd)
|
||||||
state="$(qs ipc call notifications dnd)"
|
state="$(qs ipc call notifications dnd)"
|
||||||
@@ -148,7 +149,7 @@ case "$action" in
|
|||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
printf 'Usage: panama-action {%s}\n' \
|
printf 'Usage: panama-action {%s}\n' \
|
||||||
'control-center|notifications|calendar|clipboard|overview|settings|dnd|caffeine|night-light|focus-start|focus-end|capture|intelligence|screenshot|microphone|gallery|restart-shell' >&2
|
'control-center|notifications|calendar|clipboard|overview|settings|health|dnd|caffeine|night-light|focus-start|focus-end|capture|intelligence|screenshot|microphone|gallery|restart-shell' >&2
|
||||||
exit 2
|
exit 2
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|||||||
@@ -57,6 +57,39 @@ has_accessible_bus() {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# The I2C bus that carries DDC/CI for one connector.
|
||||||
|
#
|
||||||
|
# There are two, and picking the wrong one finds no monitor at all:
|
||||||
|
#
|
||||||
|
# DisplayPort carries DDC/CI over the AUX channel. That adapter shows up as a
|
||||||
|
# child directory of the connector -- /sys/class/drm/card1-DP-2/i2c-9 -- and
|
||||||
|
# is the one ddcutil talks to.
|
||||||
|
#
|
||||||
|
# The connector's `ddc` symlink points at the classic I2C line used for EDID
|
||||||
|
# on HDMI and DVI. On a DisplayPort connector it still exists and still
|
||||||
|
# resolves, but nothing answers on it: this machine's DP-2 has ddc -> i2c-5,
|
||||||
|
# where ddcutil reports "No monitor detected", while i2c-9 answers VCP 0x10
|
||||||
|
# immediately.
|
||||||
|
#
|
||||||
|
# So prefer the AUX child and fall back to the symlink. Note the readlink: the
|
||||||
|
# entries under /sys/class/drm are symlinks, and `find` does not follow the path
|
||||||
|
# it is given, so searching the unresolved path silently finds nothing.
|
||||||
|
bus_for_connector() {
|
||||||
|
local path="$1" real aux ddc
|
||||||
|
|
||||||
|
real="$(readlink -f "$path")"
|
||||||
|
aux="$(find "$real" -maxdepth 1 -name 'i2c-*' -printf '%f' -quit 2>/dev/null)"
|
||||||
|
if [[ -n "$aux" ]]; then
|
||||||
|
printf '%s' "${aux#i2c-}"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
ddc="$(readlink -f "$path/ddc" 2>/dev/null)" || return 1
|
||||||
|
[[ -n "$ddc" ]] || return 1
|
||||||
|
ddc="$(basename "$ddc")"
|
||||||
|
printf '%s' "${ddc#i2c-}"
|
||||||
|
}
|
||||||
|
|
||||||
cmd_list() {
|
cmd_list() {
|
||||||
has_accessible_bus || emit_error 'no I2C bus is accessible. ddcutil ships a udev rule that grants this, but only to devices created after it was installed. Run: sudo udevadm control --reload-rules && sudo udevadm trigger --subsystem-match=i2c-dev --subsystem-match=drm'
|
has_accessible_bus || emit_error 'no I2C bus is accessible. ddcutil ships a udev rule that grants this, but only to devices created after it was installed. Run: sudo udevadm control --reload-rules && sudo udevadm trigger --subsystem-match=i2c-dev --subsystem-match=drm'
|
||||||
|
|
||||||
@@ -69,8 +102,7 @@ cmd_list() {
|
|||||||
connector="$(basename "$path")"
|
connector="$(basename "$path")"
|
||||||
connector="${connector#card*-}"
|
connector="${connector#card*-}"
|
||||||
|
|
||||||
bus="$(basename "$(readlink -f "$path/ddc")")"
|
bus="$(bus_for_connector "$path")"
|
||||||
bus="${bus#i2c-}"
|
|
||||||
[[ "$bus" =~ ^[0-9]+$ ]] || continue
|
[[ "$bus" =~ ^[0-9]+$ ]] || continue
|
||||||
|
|
||||||
# A monitor that does not implement 0x10 is not an error; it simply
|
# A monitor that does not implement 0x10 is not an error; it simply
|
||||||
|
|||||||
@@ -37,8 +37,8 @@ def xdg_data_roots() -> list[Path]:
|
|||||||
return [data_home, *(Path(item) for item in data_dirs.split(":") if item)]
|
return [data_home, *(Path(item) for item in data_dirs.split(":") if item)]
|
||||||
|
|
||||||
|
|
||||||
def discovered_desktop_ids() -> set[str]:
|
def discovered_desktop_files() -> dict[str, Path]:
|
||||||
desktop_ids: set[str] = set()
|
desktop_files: dict[str, Path] = {}
|
||||||
for root in xdg_data_roots():
|
for root in xdg_data_roots():
|
||||||
applications = root / "applications"
|
applications = root / "applications"
|
||||||
if not applications.is_dir():
|
if not applications.is_dir():
|
||||||
@@ -47,8 +47,12 @@ def discovered_desktop_ids() -> set[str]:
|
|||||||
if not path.is_file():
|
if not path.is_file():
|
||||||
continue
|
continue
|
||||||
relative = path.relative_to(applications)
|
relative = path.relative_to(applications)
|
||||||
desktop_ids.add("-".join(relative.parts))
|
desktop_files.setdefault("-".join(relative.parts), path)
|
||||||
return desktop_ids
|
return desktop_files
|
||||||
|
|
||||||
|
|
||||||
|
def discovered_desktop_ids() -> set[str]:
|
||||||
|
return set(discovered_desktop_files())
|
||||||
|
|
||||||
|
|
||||||
def require_desktop_id(desktop_id: str, *, discovered: set[str]) -> None:
|
def require_desktop_id(desktop_id: str, *, discovered: set[str]) -> None:
|
||||||
@@ -184,12 +188,7 @@ def set_default(role: str, desktop_id: str) -> None:
|
|||||||
run(command)
|
run(command)
|
||||||
|
|
||||||
|
|
||||||
def update_hidden(path: Path, *, hidden: bool) -> None:
|
def with_hidden(original: str, *, hidden: bool) -> str:
|
||||||
try:
|
|
||||||
original = path.read_text(encoding="utf-8")
|
|
||||||
except (OSError, UnicodeError) as error:
|
|
||||||
raise BoundaryError("That autostart entry could not be read.") from error
|
|
||||||
|
|
||||||
lines = original.splitlines()
|
lines = original.splitlines()
|
||||||
output: list[str] = []
|
output: list[str] = []
|
||||||
section = ""
|
section = ""
|
||||||
@@ -216,24 +215,63 @@ def update_hidden(path: Path, *, hidden: bool) -> None:
|
|||||||
raise BoundaryError("That autostart entry is not a desktop file.")
|
raise BoundaryError("That autostart entry is not a desktop file.")
|
||||||
if not wrote_hidden:
|
if not wrote_hidden:
|
||||||
output.append(f"Hidden={'true' if hidden else 'false'}")
|
output.append(f"Hidden={'true' if hidden else 'false'}")
|
||||||
|
return "\n".join(output) + "\n"
|
||||||
|
|
||||||
mode = path.stat().st_mode
|
|
||||||
|
def write_atomic(path: Path, text: str, *, mode: int) -> None:
|
||||||
|
temporary_path: Path | None = None
|
||||||
try:
|
try:
|
||||||
with tempfile.NamedTemporaryFile(
|
with tempfile.NamedTemporaryFile(
|
||||||
"w", encoding="utf-8", dir=path.parent, prefix=f".{path.name}.", delete=False
|
"w", encoding="utf-8", dir=path.parent, prefix=f".{path.name}.", delete=False
|
||||||
) as temporary:
|
) as temporary:
|
||||||
temporary.write("\n".join(output) + "\n")
|
temporary.write(text)
|
||||||
temporary.flush()
|
temporary.flush()
|
||||||
os.fsync(temporary.fileno())
|
os.fsync(temporary.fileno())
|
||||||
temporary_path = Path(temporary.name)
|
temporary_path = Path(temporary.name)
|
||||||
temporary_path.chmod(mode)
|
temporary_path.chmod(mode)
|
||||||
os.replace(temporary_path, path)
|
os.replace(temporary_path, path)
|
||||||
except OSError as error:
|
except OSError as error:
|
||||||
if "temporary_path" in locals():
|
if temporary_path is not None:
|
||||||
temporary_path.unlink(missing_ok=True)
|
temporary_path.unlink(missing_ok=True)
|
||||||
raise BoundaryError("That autostart entry could not be updated.") from error
|
raise BoundaryError("That autostart entry could not be updated.") from error
|
||||||
|
|
||||||
|
|
||||||
|
def update_hidden(path: Path, *, hidden: bool) -> None:
|
||||||
|
try:
|
||||||
|
original = path.read_text(encoding="utf-8")
|
||||||
|
except (OSError, UnicodeError) as error:
|
||||||
|
raise BoundaryError("That autostart entry could not be read.") from error
|
||||||
|
|
||||||
|
mode = path.stat().st_mode
|
||||||
|
write_atomic(path, with_hidden(original, hidden=hidden), mode=mode)
|
||||||
|
|
||||||
|
|
||||||
|
def add_autostart(desktop_id: str) -> None:
|
||||||
|
desktop_files = discovered_desktop_files()
|
||||||
|
require_desktop_id(desktop_id, discovered=set(desktop_files))
|
||||||
|
source = desktop_files[desktop_id]
|
||||||
|
directory = autostart_directory()
|
||||||
|
try:
|
||||||
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
|
except OSError as error:
|
||||||
|
raise BoundaryError("The user autostart directory could not be created.") from error
|
||||||
|
|
||||||
|
target = directory / desktop_id
|
||||||
|
if target.is_symlink():
|
||||||
|
raise BoundaryError("That autostart entry is not available.")
|
||||||
|
if target.exists():
|
||||||
|
if not target.is_file():
|
||||||
|
raise BoundaryError("That autostart entry is not available.")
|
||||||
|
update_hidden(target, hidden=False)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
original = source.read_text(encoding="utf-8")
|
||||||
|
except (OSError, UnicodeError) as error:
|
||||||
|
raise BoundaryError("That application could not be read.") from error
|
||||||
|
write_atomic(target, with_hidden(original, hidden=False), mode=0o644)
|
||||||
|
|
||||||
|
|
||||||
def set_autostart(desktop_id: str, enabled_text: str) -> None:
|
def set_autostart(desktop_id: str, enabled_text: str) -> None:
|
||||||
if enabled_text not in {"true", "false"}:
|
if enabled_text not in {"true", "false"}:
|
||||||
raise BoundaryError("Autostart state must be true or false.")
|
raise BoundaryError("Autostart state must be true or false.")
|
||||||
@@ -260,10 +298,12 @@ def main(arguments: list[str]) -> int:
|
|||||||
set_default(arguments[1], arguments[2])
|
set_default(arguments[1], arguments[2])
|
||||||
elif len(arguments) == 3 and arguments[0] == "set-autostart":
|
elif len(arguments) == 3 and arguments[0] == "set-autostart":
|
||||||
set_autostart(arguments[1], arguments[2])
|
set_autostart(arguments[1], arguments[2])
|
||||||
|
elif len(arguments) == 2 and arguments[0] == "add-autostart":
|
||||||
|
add_autostart(arguments[1])
|
||||||
else:
|
else:
|
||||||
raise BoundaryError(
|
raise BoundaryError(
|
||||||
"Usage: panama-default-apps snapshot | set-default ROLE DESKTOP_ID | "
|
"Usage: panama-default-apps snapshot | set-default ROLE DESKTOP_ID | "
|
||||||
"set-autostart DESKTOP_ID true|false"
|
"set-autostart DESKTOP_ID true|false | add-autostart DESKTOP_ID"
|
||||||
)
|
)
|
||||||
except BoundaryError as error:
|
except BoundaryError as error:
|
||||||
print(str(error), file=sys.stderr)
|
print(str(error), file=sys.stderr)
|
||||||
|
|||||||
+98
@@ -0,0 +1,98 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
"""Report installed cursor and icon themes from the standard XDG roots.
|
||||||
|
|
||||||
|
This helper is intentionally read-only and argument-free. Theme paths come
|
||||||
|
only from XDG_DATA_HOME and XDG_DATA_DIRS; directory symlinks are not followed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import stat
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def icon_roots() -> list[Path]:
|
||||||
|
home = Path(os.environ.get("HOME") or "/nonexistent")
|
||||||
|
data_home = Path(os.environ.get("XDG_DATA_HOME") or home / ".local/share")
|
||||||
|
data_dirs = os.environ.get("XDG_DATA_DIRS") or "/usr/local/share:/usr/share"
|
||||||
|
|
||||||
|
roots = [data_home / "icons"]
|
||||||
|
roots.extend(Path(directory) / "icons" for directory in data_dirs.split(":") if directory)
|
||||||
|
# XDG paths are required to be absolute. Ignoring malformed relative
|
||||||
|
# entries also prevents the helper's working directory becoming an
|
||||||
|
# accidental caller-controlled search root.
|
||||||
|
return [root for root in roots if root.is_absolute()]
|
||||||
|
|
||||||
|
|
||||||
|
def is_real_directory(path: Path) -> bool:
|
||||||
|
try:
|
||||||
|
return stat.S_ISDIR(path.lstat().st_mode)
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def is_real_file(path: Path) -> bool:
|
||||||
|
try:
|
||||||
|
return stat.S_ISREG(path.lstat().st_mode)
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def has_icon_directories(index_path: Path) -> bool:
|
||||||
|
try:
|
||||||
|
with index_path.open(encoding="utf-8", errors="replace") as handle:
|
||||||
|
for raw_line in handle:
|
||||||
|
line = raw_line.strip()
|
||||||
|
if line.startswith(("#", ";")) or "=" not in line:
|
||||||
|
continue
|
||||||
|
key, value = line.split("=", 1)
|
||||||
|
if key.strip() == "Directories":
|
||||||
|
return bool(value.strip())
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def catalog() -> dict[str, list[str]]:
|
||||||
|
cursor_themes: set[str] = set()
|
||||||
|
icon_themes: set[str] = set()
|
||||||
|
|
||||||
|
for root in icon_roots():
|
||||||
|
if not is_real_directory(root):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
entries = list(os.scandir(root))
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for entry in entries:
|
||||||
|
if not entry.is_dir(follow_symlinks=False):
|
||||||
|
continue
|
||||||
|
theme = Path(entry.path)
|
||||||
|
if is_real_directory(theme / "cursors"):
|
||||||
|
cursor_themes.add(entry.name)
|
||||||
|
|
||||||
|
index_path = theme / "index.theme"
|
||||||
|
if is_real_file(index_path) and has_icon_directories(index_path):
|
||||||
|
icon_themes.add(entry.name)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"cursorThemes": sorted(cursor_themes, key=str.casefold),
|
||||||
|
"iconThemes": sorted(icon_themes, key=str.casefold),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
if len(sys.argv) != 1:
|
||||||
|
print("panama-desktop-style takes no arguments", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
print(json.dumps(catalog(), ensure_ascii=False, separators=(",", ":")))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Executable
+873
@@ -0,0 +1,873 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
"""Redacted diagnostics and bounded repairs for Panama-owned functionality."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import ctypes
|
||||||
|
import errno
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import secrets
|
||||||
|
import signal
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from types import MappingProxyType
|
||||||
|
from typing import Callable, Literal
|
||||||
|
|
||||||
|
Status = Literal["ok", "warning", "error", "unconfigured"]
|
||||||
|
Group = Literal["desktop-foundation", "input-media", "integrations", "panama-tools"]
|
||||||
|
ActionKind = Literal["repair", "open", "instructions"]
|
||||||
|
InhibitorRow = tuple[str, str, str, str, str, str, str, str]
|
||||||
|
|
||||||
|
AT_FDCWD = -100
|
||||||
|
RENAME_NOREPLACE = 1
|
||||||
|
RENAME_EXCHANGE = 2
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Action:
|
||||||
|
kind: ActionKind
|
||||||
|
label: str
|
||||||
|
confirm: bool = False
|
||||||
|
# Only authored Settings page IDs and instruction IDs are allowed here.
|
||||||
|
# Repair commands never receive a caller-controlled target.
|
||||||
|
target: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Check:
|
||||||
|
id: str
|
||||||
|
group: Group
|
||||||
|
title: str
|
||||||
|
status: Status
|
||||||
|
detail: str
|
||||||
|
action: Action | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DoctorConfig:
|
||||||
|
root: Path
|
||||||
|
home: Path
|
||||||
|
config_home: Path
|
||||||
|
state_home: Path
|
||||||
|
runtime_dir: Path
|
||||||
|
path: str
|
||||||
|
timeout: float
|
||||||
|
|
||||||
|
@property
|
||||||
|
def command_env(self) -> dict[str, str]:
|
||||||
|
environment = {
|
||||||
|
"PATH": self.path,
|
||||||
|
"HOME": str(self.home),
|
||||||
|
"XDG_CONFIG_HOME": str(self.config_home),
|
||||||
|
"XDG_STATE_HOME": str(self.state_home),
|
||||||
|
"XDG_RUNTIME_DIR": str(self.runtime_dir),
|
||||||
|
}
|
||||||
|
for name in PROBE_ENVIRONMENT_KEYS:
|
||||||
|
if value := os.environ.get(name):
|
||||||
|
environment[name] = value
|
||||||
|
return environment
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CommandResult:
|
||||||
|
state: Literal["ok", "missing", "timeout", "failed", "unavailable"]
|
||||||
|
stdout: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RepairResult:
|
||||||
|
check_id: str
|
||||||
|
accepted: bool
|
||||||
|
exit_code: int
|
||||||
|
message: str
|
||||||
|
|
||||||
|
def as_json(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"checkId": self.check_id,
|
||||||
|
"accepted": self.accepted,
|
||||||
|
"exitCode": self.exit_code,
|
||||||
|
"message": self.message,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
CHECK_ORDER = (
|
||||||
|
"desktop.hyprland", "desktop.quickshell", "desktop.notifications", "desktop.portals",
|
||||||
|
"desktop.hyprpaper", "desktop.hypridle", "desktop.vicinae", "input.pipewire",
|
||||||
|
"input.clipboard", "input.wallpaper", "input.capture", "input.ocr", "input.brightness",
|
||||||
|
"integration.nextcloud", "integration.rustdesk", "integration.kdeconnect", "integration.bluebubbles",
|
||||||
|
"integration.home-assistant", "integration.calendar", "panama.runtime-links", "panama.vicinae-commands",
|
||||||
|
"panama.selected-terminal", "panama.selected-launcher", "panama.processes", "panama.caffeine",
|
||||||
|
)
|
||||||
|
|
||||||
|
SYSTEMCTL_COMMANDS = {
|
||||||
|
"hyprpaper": ("systemctl", "--user", "is-active", "--quiet", "hyprpaper.service"),
|
||||||
|
"hypridle": ("systemctl", "--user", "is-active", "--quiet", "hypridle.service"),
|
||||||
|
"vicinae": ("systemctl", "--user", "is-active", "--quiet", "vicinae.service"),
|
||||||
|
"pipewire": ("systemctl", "--user", "is-active", "--quiet", "pipewire.service"),
|
||||||
|
"nextcloud": ("systemctl", "--user", "is-active", "--quiet", "nextcloud.service"),
|
||||||
|
"rustdesk": ("systemctl", "--user", "is-active", "--quiet", "rustdesk.service"),
|
||||||
|
}
|
||||||
|
REPAIR_COMMANDS = MappingProxyType({
|
||||||
|
"desktop.hyprpaper": ("systemctl", "--user", "restart", "hyprpaper.service"),
|
||||||
|
"desktop.hypridle": ("systemctl", "--user", "restart", "hypridle.service"),
|
||||||
|
"desktop.vicinae": ("systemctl", "--user", "restart", "vicinae.service"),
|
||||||
|
"desktop.quickshell": ("panama-action", "restart-shell"),
|
||||||
|
})
|
||||||
|
RUNTIME_LINK_TARGETS = (
|
||||||
|
("hypr", Path("config/dot/hypr")),
|
||||||
|
("quickshell", Path("config/dot/quickshell")),
|
||||||
|
("uwsm", Path("config/dot/uwsm")),
|
||||||
|
("vicinae", Path("config/dot/vicinae")),
|
||||||
|
)
|
||||||
|
REPAIR_IDS = frozenset((*REPAIR_COMMANDS.keys(), "panama.runtime-links", "panama.vicinae-commands", "panama.caffeine"))
|
||||||
|
PROCESS_NAMES = ("vicinae", "hyprpaper", "hypridle")
|
||||||
|
VERSION_PATTERN = re.compile(r"\b\d+(?:\.\d+){0,3}(?:[-+._][A-Za-z0-9._-]+)?\b")
|
||||||
|
REVISION_PATTERN = re.compile(r"\b[0-9a-f]{7,40}\b", re.IGNORECASE)
|
||||||
|
PROBE_ENVIRONMENT_KEYS = (
|
||||||
|
"LANG",
|
||||||
|
"LC_ALL",
|
||||||
|
"LC_CTYPE",
|
||||||
|
"TZ",
|
||||||
|
"DBUS_SESSION_BUS_ADDRESS",
|
||||||
|
"WAYLAND_DISPLAY",
|
||||||
|
"DISPLAY",
|
||||||
|
"XAUTHORITY",
|
||||||
|
"PANAMA_DOCTOR_FIXTURE_STOPPED",
|
||||||
|
"PANAMA_DOCTOR_FIXTURE_PROCESSES",
|
||||||
|
"PANAMA_DOCTOR_FIXTURE_BUS",
|
||||||
|
"PANAMA_DOCTOR_FIXTURE_QS",
|
||||||
|
"PANAMA_DOCTOR_FIXTURE_QS_VERSION",
|
||||||
|
"PANAMA_DOCTOR_FIXTURE_BLUEBUBBLES",
|
||||||
|
"PANAMA_DOCTOR_FIXTURE_CALENDAR",
|
||||||
|
"PANAMA_DOCTOR_FIXTURE_BRIGHTNESS",
|
||||||
|
"PANAMA_DOCTOR_FIXTURE_CAFFEINE",
|
||||||
|
)
|
||||||
|
CHECK_TITLES = {
|
||||||
|
"desktop.hyprland": "Hyprland",
|
||||||
|
"desktop.quickshell": "Quickshell",
|
||||||
|
"desktop.notifications": "Notifications",
|
||||||
|
"desktop.portals": "Desktop portals",
|
||||||
|
"desktop.hyprpaper": "Hyprpaper",
|
||||||
|
"desktop.hypridle": "Hypridle",
|
||||||
|
"desktop.vicinae": "Vicinae",
|
||||||
|
"input.pipewire": "PipeWire",
|
||||||
|
"input.clipboard": "Clipboard",
|
||||||
|
"input.wallpaper": "Wallpaper",
|
||||||
|
"input.capture": "Capture",
|
||||||
|
"input.ocr": "OCR",
|
||||||
|
"input.brightness": "External monitor brightness",
|
||||||
|
"integration.nextcloud": "Nextcloud",
|
||||||
|
"integration.rustdesk": "RustDesk",
|
||||||
|
"integration.kdeconnect": "KDE Connect",
|
||||||
|
"integration.bluebubbles": "BlueBubbles",
|
||||||
|
"integration.home-assistant": "Home Assistant",
|
||||||
|
"integration.calendar": "Calendar",
|
||||||
|
"panama.runtime-links": "Panama runtime links",
|
||||||
|
"panama.vicinae-commands": "Panama commands",
|
||||||
|
"panama.selected-terminal": "Selected terminal",
|
||||||
|
"panama.selected-launcher": "Selected launcher",
|
||||||
|
"panama.processes": "Panama processes",
|
||||||
|
"panama.caffeine": "Caffeine inhibitor",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def environment_path(name: str, default: Path) -> Path:
|
||||||
|
value = os.environ.get(name)
|
||||||
|
return Path(value).expanduser() if value else default
|
||||||
|
|
||||||
|
|
||||||
|
def config_from_environment() -> DoctorConfig:
|
||||||
|
home = environment_path("PANAMA_DOCTOR_HOME", Path.home())
|
||||||
|
config_home = environment_path("PANAMA_DOCTOR_CONFIG_HOME", Path(os.environ.get("XDG_CONFIG_HOME", home / ".config")))
|
||||||
|
state_home = environment_path("PANAMA_DOCTOR_STATE_HOME", Path(os.environ.get("XDG_STATE_HOME", home / ".local/state")))
|
||||||
|
runtime_dir = environment_path("PANAMA_DOCTOR_RUNTIME_DIR", Path(os.environ.get("XDG_RUNTIME_DIR", "/run/user/0")))
|
||||||
|
root = environment_path("PANAMA_DOCTOR_ROOT", Path(__file__).resolve().parents[4])
|
||||||
|
try:
|
||||||
|
timeout = float(os.environ.get("PANAMA_DOCTOR_TIMEOUT", "3"))
|
||||||
|
except ValueError:
|
||||||
|
timeout = 3.0
|
||||||
|
return DoctorConfig(root, home, config_home, state_home, runtime_dir, os.environ.get("PANAMA_DOCTOR_PATH", os.environ.get("PATH", "")), max(0.05, min(timeout, 15.0)))
|
||||||
|
|
||||||
|
|
||||||
|
def run_command(command: tuple[str, ...], config: DoctorConfig, cwd: Path | None = None) -> CommandResult:
|
||||||
|
"""Run an authored read-only command without reporting its unparsed output."""
|
||||||
|
try:
|
||||||
|
completed = subprocess.run(command, capture_output=True, text=True, timeout=config.timeout, check=False, env=config.command_env, cwd=cwd)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return CommandResult("missing")
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return CommandResult("timeout")
|
||||||
|
except OSError:
|
||||||
|
return CommandResult("unavailable")
|
||||||
|
if completed.returncode != 0:
|
||||||
|
return CommandResult("failed")
|
||||||
|
return CommandResult("ok", completed.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
def run_repair_command(command: tuple[str, ...], config: DoctorConfig, cwd: Path | None = None) -> tuple[int, str]:
|
||||||
|
"""Execute one authored repair argv and retain output only for strict parsing."""
|
||||||
|
try:
|
||||||
|
completed = subprocess.run(
|
||||||
|
command,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=config.timeout,
|
||||||
|
check=False,
|
||||||
|
env=config.command_env,
|
||||||
|
cwd=cwd,
|
||||||
|
)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return 127, ""
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return 124, ""
|
||||||
|
except OSError:
|
||||||
|
return 126, ""
|
||||||
|
exit_code = completed.returncode if 0 <= completed.returncode <= 255 else 1
|
||||||
|
return exit_code, completed.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def executable_exists(name: str, config: DoctorConfig) -> bool:
|
||||||
|
return shutil.which(name, path=config.path) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def action_json(action: Action) -> dict[str, object]:
|
||||||
|
result: dict[str, object] = {"kind": action.kind, "label": action.label, "confirm": action.confirm}
|
||||||
|
if action.target is not None:
|
||||||
|
result["target"] = action.target
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def check_json(check: Check) -> dict[str, object]:
|
||||||
|
result: dict[str, object] = {"id": check.id, "group": check.group, "title": check.title, "status": check.status, "detail": check.detail}
|
||||||
|
if check.action is not None:
|
||||||
|
result["action"] = action_json(check.action)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def group_for(check_id: str) -> Group:
|
||||||
|
if check_id.startswith("desktop."):
|
||||||
|
return "desktop-foundation"
|
||||||
|
if check_id.startswith("input."):
|
||||||
|
return "input-media"
|
||||||
|
if check_id.startswith("integration."):
|
||||||
|
return "integrations"
|
||||||
|
return "panama-tools"
|
||||||
|
|
||||||
|
|
||||||
|
def service_check(check_id: str, title: str, service: str, config: DoctorConfig, action: Action | None = None) -> Check:
|
||||||
|
result = run_command(SYSTEMCTL_COMMANDS[service], config)
|
||||||
|
if result.state == "ok":
|
||||||
|
return Check(check_id, group_for(check_id), title, "ok", "Service is active.")
|
||||||
|
if result.state in {"missing", "unavailable"}:
|
||||||
|
return Check(check_id, group_for(check_id), title, "error", "Required system service probe is unavailable.")
|
||||||
|
return Check(check_id, group_for(check_id), title, "warning", "Service is not active.", action)
|
||||||
|
|
||||||
|
|
||||||
|
def ipc_target(config: DoctorConfig, target: str) -> CommandResult:
|
||||||
|
result = run_command(("qs", "ipc", "show"), config)
|
||||||
|
if result.state != "ok":
|
||||||
|
return result
|
||||||
|
return CommandResult("ok") if f"target {target}" in result.stdout.splitlines() else CommandResult("failed")
|
||||||
|
|
||||||
|
|
||||||
|
def simple_ipc_check(check_id: str, title: str, target: str, config: DoctorConfig) -> Check:
|
||||||
|
result = ipc_target(config, target)
|
||||||
|
if result.state == "ok":
|
||||||
|
return Check(check_id, "input-media", title, "ok", "Panama IPC target is available.")
|
||||||
|
if result.state == "missing":
|
||||||
|
return Check(check_id, "input-media", title, "error", "Required Quickshell executable is unavailable.")
|
||||||
|
if result.state == "timeout":
|
||||||
|
return Check(check_id, "input-media", title, "warning", "Panama IPC probe timed out.")
|
||||||
|
return Check(check_id, "input-media", title, "warning", "Panama IPC target is unavailable.")
|
||||||
|
|
||||||
|
|
||||||
|
def check_hyprland(config: DoctorConfig) -> Check:
|
||||||
|
if "hyprland" in os.environ.get("XDG_CURRENT_DESKTOP", "").casefold():
|
||||||
|
return Check("desktop.hyprland", "desktop-foundation", "Hyprland", "ok", "Hyprland session detected.")
|
||||||
|
return Check("desktop.hyprland", "desktop-foundation", "Hyprland", "error", "Hyprland session is not active.")
|
||||||
|
|
||||||
|
|
||||||
|
def check_quickshell(config: DoctorConfig) -> Check:
|
||||||
|
result = run_command(("qs", "--version"), config)
|
||||||
|
repair = Action("repair", "Restart Panama", True)
|
||||||
|
if result.state == "ok" and VERSION_PATTERN.search(result.stdout):
|
||||||
|
return Check("desktop.quickshell", "desktop-foundation", "Quickshell", "ok", "Quickshell executable is available.")
|
||||||
|
if result.state == "missing":
|
||||||
|
return Check("desktop.quickshell", "desktop-foundation", "Quickshell", "error", "Required Quickshell executable is unavailable.", repair)
|
||||||
|
return Check("desktop.quickshell", "desktop-foundation", "Quickshell", "warning", "Quickshell probe returned an invalid result.", repair)
|
||||||
|
|
||||||
|
|
||||||
|
def check_notifications(config: DoctorConfig) -> Check:
|
||||||
|
result = ipc_target(config, "notifications")
|
||||||
|
return Check("desktop.notifications", "desktop-foundation", "Notifications", "ok", "Notification service is available.") if result.state == "ok" else Check("desktop.notifications", "desktop-foundation", "Notifications", "warning", "Notification service is unavailable.")
|
||||||
|
|
||||||
|
|
||||||
|
def check_portals(config: DoctorConfig) -> Check:
|
||||||
|
result = run_command(("busctl", "--user", "--no-pager", "list"), config)
|
||||||
|
if result.state == "ok" and any(line.startswith("org.freedesktop.portal.Desktop ") for line in result.stdout.splitlines()):
|
||||||
|
return Check("desktop.portals", "desktop-foundation", "Desktop portals", "ok", "Desktop portal service is available.")
|
||||||
|
detail = "Desktop portal probe timed out." if result.state == "timeout" else "Desktop portal probe is unavailable." if result.state == "missing" else "Desktop portal service is unavailable."
|
||||||
|
return Check("desktop.portals", "desktop-foundation", "Desktop portals", "warning", detail)
|
||||||
|
|
||||||
|
|
||||||
|
def check_brightness(config: DoctorConfig) -> Check:
|
||||||
|
result = run_command(("panama-brightness", "list"), config)
|
||||||
|
instructions = Action("instructions", "View setup instructions", target="ddc-permissions")
|
||||||
|
if result.state == "timeout":
|
||||||
|
return Check("input.brightness", "input-media", "External monitor brightness", "warning", "DDC/CI probe timed out.", instructions)
|
||||||
|
if result.state != "ok":
|
||||||
|
return Check("input.brightness", "input-media", "External monitor brightness", "warning", "DDC/CI support is unavailable.", instructions)
|
||||||
|
try:
|
||||||
|
listing = json.loads(result.stdout)
|
||||||
|
displays, error = listing["displays"], listing["error"]
|
||||||
|
if not isinstance(displays, list) or not isinstance(error, str):
|
||||||
|
raise ValueError
|
||||||
|
except (json.JSONDecodeError, KeyError, TypeError, ValueError):
|
||||||
|
return Check("input.brightness", "input-media", "External monitor brightness", "warning", "DDC/CI probe returned an invalid result.", instructions)
|
||||||
|
if error:
|
||||||
|
return Check("input.brightness", "input-media", "External monitor brightness", "warning", "No accessible DDC/CI bus.", instructions)
|
||||||
|
if not displays:
|
||||||
|
return Check("input.brightness", "input-media", "External monitor brightness", "unconfigured", "No DDC/CI display is configured.")
|
||||||
|
return Check("input.brightness", "input-media", "External monitor brightness", "ok", f"{len(displays)} DDC/CI display{'s' if len(displays) != 1 else ''} available.")
|
||||||
|
|
||||||
|
|
||||||
|
def check_nextcloud(config: DoctorConfig) -> Check:
|
||||||
|
if not (config.config_home / "autostart" / "nextcloud.desktop").is_file():
|
||||||
|
return Check("integration.nextcloud", "integrations", "Nextcloud", "unconfigured", "Nextcloud autostart is not configured.")
|
||||||
|
return service_check("integration.nextcloud", "Nextcloud", "nextcloud", config, Action("open", "Open Nextcloud"))
|
||||||
|
|
||||||
|
|
||||||
|
def check_rustdesk(config: DoctorConfig) -> Check:
|
||||||
|
if not executable_exists("rustdesk", config):
|
||||||
|
return Check("integration.rustdesk", "integrations", "RustDesk", "unconfigured", "RustDesk is not installed.")
|
||||||
|
return service_check("integration.rustdesk", "RustDesk", "rustdesk", config, Action("open", "Open RustDesk"))
|
||||||
|
|
||||||
|
|
||||||
|
def check_kdeconnect(config: DoctorConfig) -> Check:
|
||||||
|
if not executable_exists("kdeconnect-cli", config):
|
||||||
|
return Check("integration.kdeconnect", "integrations", "KDE Connect", "unconfigured", "KDE Connect is not installed.")
|
||||||
|
result = run_command(("busctl", "--user", "--no-pager", "list"), config)
|
||||||
|
if result.state == "ok" and any(line.startswith("org.kde.kdeconnect ") for line in result.stdout.splitlines()):
|
||||||
|
return Check("integration.kdeconnect", "integrations", "KDE Connect", "ok", "KDE Connect service is available.")
|
||||||
|
return Check("integration.kdeconnect", "integrations", "KDE Connect", "warning", "KDE Connect service is unavailable.", Action("open", "Open KDE Connect"))
|
||||||
|
|
||||||
|
|
||||||
|
def check_bluebubbles(config: DoctorConfig) -> Check:
|
||||||
|
result = run_command(("flatpak", "info", "app.bluebubbles.BlueBubbles"), config)
|
||||||
|
if result.state == "ok":
|
||||||
|
return Check("integration.bluebubbles", "integrations", "BlueBubbles", "ok", "BlueBubbles is installed.")
|
||||||
|
if result.state in {"missing", "failed"}:
|
||||||
|
return Check("integration.bluebubbles", "integrations", "BlueBubbles", "unconfigured", "BlueBubbles is not installed.")
|
||||||
|
return Check("integration.bluebubbles", "integrations", "BlueBubbles", "warning", "BlueBubbles installation probe timed out.", Action("open", "Open BlueBubbles"))
|
||||||
|
|
||||||
|
|
||||||
|
def check_home_assistant(config: DoctorConfig) -> Check:
|
||||||
|
configured = all(name in os.environ for name in ("PANAMA_HOME_ASSISTANT_URL", "PANAMA_HOME_ASSISTANT_TOKEN"))
|
||||||
|
helper = config.config_home / "quickshell" / "scripts" / "panama-home-assistant"
|
||||||
|
if not configured:
|
||||||
|
return Check("integration.home-assistant", "integrations", "Home Assistant", "unconfigured", "Home Assistant is not configured.")
|
||||||
|
if not helper.is_file():
|
||||||
|
return Check("integration.home-assistant", "integrations", "Home Assistant", "warning", "Home Assistant bridge is unavailable.", Action("open", "Open Home settings", target="home-phone"))
|
||||||
|
return Check("integration.home-assistant", "integrations", "Home Assistant", "ok", "Home Assistant credentials are configured.")
|
||||||
|
|
||||||
|
|
||||||
|
def check_calendar(config: DoctorConfig) -> Check:
|
||||||
|
result = run_command(("calendar-agenda", "probe"), config)
|
||||||
|
action = Action("open", "Open Date & Time", target="datetime")
|
||||||
|
if result.state == "missing":
|
||||||
|
return Check("integration.calendar", "integrations", "Calendar", "unconfigured", "Calendar integration is not installed.")
|
||||||
|
if result.state == "timeout":
|
||||||
|
return Check("integration.calendar", "integrations", "Calendar", "warning", "Calendar probe timed out.", action)
|
||||||
|
if result.state != "ok":
|
||||||
|
return Check("integration.calendar", "integrations", "Calendar", "warning", "Calendar probe failed.", action)
|
||||||
|
try:
|
||||||
|
enabled_sources = json.loads(result.stdout)["enabledSources"]
|
||||||
|
if not isinstance(enabled_sources, int) or isinstance(enabled_sources, bool):
|
||||||
|
raise ValueError
|
||||||
|
except (json.JSONDecodeError, KeyError, TypeError, ValueError):
|
||||||
|
return Check("integration.calendar", "integrations", "Calendar", "warning", "Calendar probe returned an invalid result.", action)
|
||||||
|
if enabled_sources <= 0:
|
||||||
|
return Check("integration.calendar", "integrations", "Calendar", "unconfigured", "No enabled calendar source is configured.")
|
||||||
|
return Check("integration.calendar", "integrations", "Calendar", "ok", f"{enabled_sources} enabled calendar source{'s' if enabled_sources != 1 else ''} configured.")
|
||||||
|
|
||||||
|
|
||||||
|
def check_runtime_links(config: DoctorConfig) -> Check:
|
||||||
|
def valid_link(name: str, relative_source: Path) -> bool:
|
||||||
|
destination = config.config_home / name
|
||||||
|
source = config.root / relative_source
|
||||||
|
try:
|
||||||
|
return source.is_dir() and destination.is_symlink() \
|
||||||
|
and destination.resolve(strict=False) == source.resolve(strict=True)
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if any(not valid_link(name, relative_source) for name, relative_source in RUNTIME_LINK_TARGETS):
|
||||||
|
return Check("panama.runtime-links", "panama-tools", "Panama runtime links", "warning", "One or more Panama runtime links are unavailable.", Action("repair", "Repair runtime links"))
|
||||||
|
return Check("panama.runtime-links", "panama-tools", "Panama runtime links", "ok", "Panama runtime links are available.")
|
||||||
|
|
||||||
|
|
||||||
|
def check_vicinae_commands(config: DoctorConfig) -> Check:
|
||||||
|
source = config.root / "config/local/share/vicinae/scripts"
|
||||||
|
installed = config.home / ".local/share/vicinae/scripts/panama"
|
||||||
|
try:
|
||||||
|
linked = source.is_dir() and installed.is_symlink() \
|
||||||
|
and installed.resolve(strict=False) == source.resolve(strict=True)
|
||||||
|
except OSError:
|
||||||
|
linked = False
|
||||||
|
if linked:
|
||||||
|
return Check("panama.vicinae-commands", "panama-tools", "Panama commands", "ok", "Panama Vicinae commands are linked.")
|
||||||
|
return Check("panama.vicinae-commands", "panama-tools", "Panama commands", "warning", "Panama Vicinae commands are not linked.", Action("repair", "Repair command link"))
|
||||||
|
|
||||||
|
|
||||||
|
def executable_check(check_id: str, title: str, executable: str, config: DoctorConfig) -> Check:
|
||||||
|
if executable_exists(executable, config):
|
||||||
|
return Check(check_id, group_for(check_id), title, "ok", f"{title} executable is available.")
|
||||||
|
return Check(check_id, group_for(check_id), title, "warning", f"{title} executable is unavailable.")
|
||||||
|
|
||||||
|
|
||||||
|
def check_processes(config: DoctorConfig) -> Check:
|
||||||
|
# `qs` is both the long-running shell and every short-lived IPC client.
|
||||||
|
# Counting it with pgrep races the other parallel health probes and reports
|
||||||
|
# duplicates whenever one of them happens to call `qs ipc`. The instance
|
||||||
|
# list is the authoritative view and contains only actual shells.
|
||||||
|
quickshell = run_command(("qs", "list"), config)
|
||||||
|
if quickshell.state == "ok":
|
||||||
|
quickshell_count = sum(
|
||||||
|
line.startswith("Instance ") for line in quickshell.stdout.splitlines()
|
||||||
|
)
|
||||||
|
elif quickshell.state == "failed":
|
||||||
|
quickshell_count = 0
|
||||||
|
else:
|
||||||
|
return Check("panama.processes", "panama-tools", "Panama processes", "warning", "Process probe is unavailable.")
|
||||||
|
|
||||||
|
counts: list[int] = [quickshell_count]
|
||||||
|
for name in PROCESS_NAMES:
|
||||||
|
result = run_command(("pgrep", "-u", str(os.getuid()), "-x", name), config)
|
||||||
|
if result.state == "ok":
|
||||||
|
pids = result.stdout.splitlines()
|
||||||
|
if not pids or any(not pid.isdecimal() for pid in pids):
|
||||||
|
return Check("panama.processes", "panama-tools", "Panama processes", "warning", "Process probe returned an invalid result.")
|
||||||
|
counts.append(len(pids))
|
||||||
|
elif result.state == "failed":
|
||||||
|
counts.append(0)
|
||||||
|
else:
|
||||||
|
return Check("panama.processes", "panama-tools", "Panama processes", "warning", "Process probe is unavailable.")
|
||||||
|
if any(count > 1 for count in counts):
|
||||||
|
return Check("panama.processes", "panama-tools", "Panama processes", "warning", "Duplicate Panama desktop processes detected.")
|
||||||
|
if counts[0] == 0:
|
||||||
|
return Check("panama.processes", "panama-tools", "Panama processes", "error", "Quickshell process is not running.")
|
||||||
|
return Check("panama.processes", "panama-tools", "Panama processes", "ok", "Panama desktop process counts are normal.")
|
||||||
|
|
||||||
|
|
||||||
|
def check_caffeine(config: DoctorConfig) -> Check:
|
||||||
|
result = run_command(("systemd-inhibit", "--list", "--no-pager", "--no-legend"), config)
|
||||||
|
if result.state != "ok":
|
||||||
|
return Check("panama.caffeine", "panama-tools", "Caffeine inhibitor", "warning", "Caffeine inhibitor probe is unavailable.")
|
||||||
|
inhibitor_rows = parse_caffeine_rows(result.stdout, str(os.getuid()))
|
||||||
|
if inhibitor_rows is None:
|
||||||
|
return Check("panama.caffeine", "panama-tools", "Caffeine inhibitor", "warning", "Caffeine inhibitor probe returned an invalid result.")
|
||||||
|
inhibitors = len(dict.fromkeys(int(row[3]) for row in inhibitor_rows))
|
||||||
|
if inhibitors > 1:
|
||||||
|
return Check("panama.caffeine", "panama-tools", "Caffeine inhibitor", "warning", "Duplicate Panama Caffeine inhibitors detected.", Action("repair", "Release duplicate inhibitors"))
|
||||||
|
if inhibitors == 1:
|
||||||
|
return Check("panama.caffeine", "panama-tools", "Caffeine inhibitor", "ok", "One Panama Caffeine inhibitor is active.")
|
||||||
|
return Check("panama.caffeine", "panama-tools", "Caffeine inhibitor", "ok", "No Panama Caffeine inhibitor is active.")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_version(result: CommandResult, pattern: re.Pattern[str] = VERSION_PATTERN) -> str:
|
||||||
|
match = pattern.search(result.stdout) if result.state == "ok" else None
|
||||||
|
return match.group(0) if match else "unavailable"
|
||||||
|
|
||||||
|
|
||||||
|
def context_versions(config: DoctorConfig) -> list[dict[str, str]]:
|
||||||
|
hyprland = run_command(("hyprctl", "version"), config)
|
||||||
|
quickshell = run_command(("qs", "--version"), config)
|
||||||
|
revision = run_command(("git", "rev-parse", "--short", "HEAD"), config, config.root)
|
||||||
|
fedora = "unavailable"
|
||||||
|
try:
|
||||||
|
match = re.search(r"^VERSION_ID=\"?([^\n\"]+)", Path("/etc/os-release").read_text(encoding="utf-8"), re.MULTILINE)
|
||||||
|
if match and re.fullmatch(r"[0-9.]+", match.group(1)):
|
||||||
|
fedora = match.group(1)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return [{"id": "hyprland", "version": parse_version(hyprland)}, {"id": "quickshell", "version": parse_version(quickshell)}, {"id": "fedora", "version": fedora}, {"id": "panama", "version": parse_version(revision, REVISION_PATTERN)}]
|
||||||
|
|
||||||
|
|
||||||
|
def unavailable_check(check_id: str) -> Check:
|
||||||
|
return Check(check_id, group_for(check_id), CHECK_TITLES[check_id], "warning", "Diagnostic probe could not be completed.")
|
||||||
|
|
||||||
|
|
||||||
|
def unavailable_versions() -> list[dict[str, str]]:
|
||||||
|
return [{"id": name, "version": "unavailable"} for name in ("hyprland", "quickshell", "fedora", "panama")]
|
||||||
|
|
||||||
|
|
||||||
|
def collect_checks(config: DoctorConfig) -> list[Check]:
|
||||||
|
probes: dict[str, Callable[[], Check]] = {
|
||||||
|
"desktop.hyprland": lambda: check_hyprland(config), "desktop.quickshell": lambda: check_quickshell(config), "desktop.notifications": lambda: check_notifications(config), "desktop.portals": lambda: check_portals(config),
|
||||||
|
"desktop.hyprpaper": lambda: service_check("desktop.hyprpaper", "Hyprpaper", "hyprpaper", config, Action("repair", "Restart Hyprpaper")), "desktop.hypridle": lambda: service_check("desktop.hypridle", "Hypridle", "hypridle", config, Action("repair", "Restart Hypridle")), "desktop.vicinae": lambda: service_check("desktop.vicinae", "Vicinae", "vicinae", config, Action("repair", "Restart Vicinae")), "input.pipewire": lambda: service_check("input.pipewire", "PipeWire", "pipewire", config),
|
||||||
|
"input.clipboard": lambda: simple_ipc_check("input.clipboard", "Clipboard", "clipboard", config), "input.wallpaper": lambda: simple_ipc_check("input.wallpaper", "Wallpaper", "wallpaper", config), "input.capture": lambda: simple_ipc_check("input.capture", "Capture", "capture", config), "input.ocr": lambda: executable_check("input.ocr", "OCR", "tesseract", config), "input.brightness": lambda: check_brightness(config),
|
||||||
|
"integration.nextcloud": lambda: check_nextcloud(config), "integration.rustdesk": lambda: check_rustdesk(config), "integration.kdeconnect": lambda: check_kdeconnect(config), "integration.bluebubbles": lambda: check_bluebubbles(config), "integration.home-assistant": lambda: check_home_assistant(config), "integration.calendar": lambda: check_calendar(config),
|
||||||
|
"panama.runtime-links": lambda: check_runtime_links(config), "panama.vicinae-commands": lambda: check_vicinae_commands(config), "panama.selected-terminal": lambda: executable_check("panama.selected-terminal", "Selected terminal", "kitty", config), "panama.selected-launcher": lambda: executable_check("panama.selected-launcher", "Selected launcher", "vicinae", config), "panama.processes": lambda: check_processes(config), "panama.caffeine": lambda: check_caffeine(config),
|
||||||
|
}
|
||||||
|
with ThreadPoolExecutor(max_workers=8) as executor:
|
||||||
|
futures = {check_id: executor.submit(probes[check_id]) for check_id in CHECK_ORDER}
|
||||||
|
checks: list[Check] = []
|
||||||
|
for check_id in CHECK_ORDER:
|
||||||
|
try:
|
||||||
|
checks.append(futures[check_id].result())
|
||||||
|
except Exception:
|
||||||
|
checks.append(unavailable_check(check_id))
|
||||||
|
return checks
|
||||||
|
|
||||||
|
|
||||||
|
def snapshot(config: DoctorConfig) -> dict[str, object]:
|
||||||
|
try:
|
||||||
|
checks = collect_checks(config)
|
||||||
|
except Exception:
|
||||||
|
checks = [unavailable_check(check_id) for check_id in CHECK_ORDER]
|
||||||
|
counts = {status: sum(check.status == status for check in checks) for status in ("ok", "warning", "error", "unconfigured")}
|
||||||
|
overall: Literal["healthy", "warning", "error"] = "error" if counts["error"] else "warning" if counts["warning"] else "healthy"
|
||||||
|
session = "hyprland" if "hyprland" in os.environ.get("XDG_CURRENT_DESKTOP", "").casefold() else "other"
|
||||||
|
try:
|
||||||
|
versions = context_versions(config)
|
||||||
|
except Exception:
|
||||||
|
versions = unavailable_versions()
|
||||||
|
return {"schemaVersion": 1, "generatedAt": datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z"), "summary": {"status": overall, "healthy": counts["ok"], "warnings": counts["warning"], "errors": counts["error"], "unconfigured": counts["unconfigured"]}, "context": {"session": session, "versions": versions}, "checks": [check_json(check) for check in checks]}
|
||||||
|
|
||||||
|
|
||||||
|
def repair_authored_command(check_id: str, config: DoctorConfig) -> RepairResult:
|
||||||
|
command = REPAIR_COMMANDS[check_id]
|
||||||
|
exit_code, _ = run_repair_command(command, config)
|
||||||
|
message = "Repair completed. A fresh health check will verify recovery." if exit_code == 0 \
|
||||||
|
else "The authored repair command could not be completed."
|
||||||
|
return RepairResult(check_id, True, exit_code, message)
|
||||||
|
|
||||||
|
|
||||||
|
def lexical_path(path: Path) -> Path:
|
||||||
|
"""Normalize dot segments without following any filesystem symlink."""
|
||||||
|
return Path(os.path.abspath(os.fspath(path)))
|
||||||
|
|
||||||
|
|
||||||
|
def lexical_link_target(destination: Path) -> Path:
|
||||||
|
target = Path(os.readlink(destination))
|
||||||
|
return lexical_path(target if target.is_absolute() else destination.parent / target)
|
||||||
|
|
||||||
|
|
||||||
|
def renameat2(source: Path, destination: Path, flags: int) -> None:
|
||||||
|
"""Call Linux renameat2 with fixed flags selected by authored code."""
|
||||||
|
libc = ctypes.CDLL(None, use_errno=True)
|
||||||
|
function = getattr(libc, "renameat2", None)
|
||||||
|
if function is None:
|
||||||
|
raise OSError(errno.ENOSYS, "renameat2 is unavailable")
|
||||||
|
function.argtypes = [ctypes.c_int, ctypes.c_char_p, ctypes.c_int, ctypes.c_char_p, ctypes.c_uint]
|
||||||
|
function.restype = ctypes.c_int
|
||||||
|
result = function(
|
||||||
|
AT_FDCWD,
|
||||||
|
os.fsencode(source),
|
||||||
|
AT_FDCWD,
|
||||||
|
os.fsencode(destination),
|
||||||
|
flags,
|
||||||
|
)
|
||||||
|
if result != 0:
|
||||||
|
error = ctypes.get_errno()
|
||||||
|
raise OSError(error, os.strerror(error), destination)
|
||||||
|
|
||||||
|
|
||||||
|
def rename_exchange(source: Path, destination: Path) -> None:
|
||||||
|
renameat2(source, destination, RENAME_EXCHANGE)
|
||||||
|
|
||||||
|
|
||||||
|
def rename_noreplace(source: Path, destination: Path) -> None:
|
||||||
|
renameat2(source, destination, RENAME_NOREPLACE)
|
||||||
|
|
||||||
|
|
||||||
|
def create_symlink_candidate(destination: Path, source: Path) -> Path:
|
||||||
|
"""Create one unpredictable authored sibling candidate symlink."""
|
||||||
|
for _ in range(32):
|
||||||
|
candidate = destination.with_name(
|
||||||
|
f".panama-link-{destination.name}-{os.getpid()}-{secrets.token_hex(8)}"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
os.symlink(source, candidate, target_is_directory=True)
|
||||||
|
return candidate
|
||||||
|
except FileExistsError:
|
||||||
|
continue
|
||||||
|
raise OSError("Could not allocate an authored temporary link")
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_candidate(candidate: Path) -> None:
|
||||||
|
try:
|
||||||
|
candidate.unlink()
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def install_absent_symlink(destination: Path, source: Path) -> Literal["repaired", "blocked", "failed"]:
|
||||||
|
candidate = create_symlink_candidate(destination, source)
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
rename_noreplace(candidate, destination)
|
||||||
|
except FileExistsError:
|
||||||
|
return "blocked"
|
||||||
|
except OSError:
|
||||||
|
return "failed"
|
||||||
|
return "repaired"
|
||||||
|
finally:
|
||||||
|
cleanup_candidate(candidate)
|
||||||
|
|
||||||
|
|
||||||
|
def exchange_owned_symlink(
|
||||||
|
destination: Path,
|
||||||
|
source: Path,
|
||||||
|
authored_sources: frozenset[Path],
|
||||||
|
) -> Literal["repaired", "blocked", "failed"]:
|
||||||
|
"""Exchange first, then validate the exact object removed from destination."""
|
||||||
|
candidate = create_symlink_candidate(destination, source)
|
||||||
|
exchanged = False
|
||||||
|
rolled_back = False
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
rename_exchange(candidate, destination)
|
||||||
|
exchanged = True
|
||||||
|
except OSError:
|
||||||
|
return "failed"
|
||||||
|
|
||||||
|
try:
|
||||||
|
old_is_authored = candidate.is_symlink() \
|
||||||
|
and lexical_link_target(candidate) in authored_sources
|
||||||
|
except OSError:
|
||||||
|
old_is_authored = False
|
||||||
|
if old_is_authored:
|
||||||
|
cleanup_candidate(candidate)
|
||||||
|
return "repaired"
|
||||||
|
|
||||||
|
try:
|
||||||
|
rename_exchange(candidate, destination)
|
||||||
|
rolled_back = True
|
||||||
|
except OSError:
|
||||||
|
# The displaced object remains at the unpredictable candidate path;
|
||||||
|
# never unlink it when rollback could not restore ownership.
|
||||||
|
return "failed"
|
||||||
|
|
||||||
|
try:
|
||||||
|
restored_candidate_is_ours = candidate.is_symlink() \
|
||||||
|
and lexical_link_target(candidate) == source
|
||||||
|
except OSError:
|
||||||
|
restored_candidate_is_ours = False
|
||||||
|
if not restored_candidate_is_ours:
|
||||||
|
return "failed"
|
||||||
|
cleanup_candidate(candidate)
|
||||||
|
return "blocked"
|
||||||
|
finally:
|
||||||
|
if not exchanged or rolled_back:
|
||||||
|
try:
|
||||||
|
if candidate.is_symlink() and lexical_link_target(candidate) == source:
|
||||||
|
cleanup_candidate(candidate)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def repair_runtime_links(config: DoctorConfig) -> RepairResult:
|
||||||
|
root = lexical_path(config.root)
|
||||||
|
sources = [(name, lexical_path(config.root / relative_source)) for name, relative_source in RUNTIME_LINK_TARGETS]
|
||||||
|
if any(not source.is_dir() for _, source in sources):
|
||||||
|
return RepairResult("panama.runtime-links", True, 1, "Tracked Panama link destinations are unavailable.")
|
||||||
|
if any(not source.is_relative_to(root) for _, source in sources):
|
||||||
|
return RepairResult("panama.runtime-links", True, 1, "Tracked Panama link destinations are invalid.")
|
||||||
|
authored_sources = frozenset(source for _, source in sources)
|
||||||
|
|
||||||
|
try:
|
||||||
|
config.config_home.mkdir(parents=True, exist_ok=True)
|
||||||
|
except OSError:
|
||||||
|
return RepairResult("panama.runtime-links", True, 1, "Panama runtime links could not be accessed.")
|
||||||
|
|
||||||
|
blocked = False
|
||||||
|
failed = False
|
||||||
|
for name, source in sources:
|
||||||
|
destination = config.config_home / name
|
||||||
|
try:
|
||||||
|
if destination.is_symlink():
|
||||||
|
current_target = lexical_link_target(destination)
|
||||||
|
if current_target == source:
|
||||||
|
continue
|
||||||
|
if current_target not in authored_sources:
|
||||||
|
blocked = True
|
||||||
|
continue
|
||||||
|
outcome = exchange_owned_symlink(destination, source, authored_sources)
|
||||||
|
blocked = blocked or outcome == "blocked"
|
||||||
|
failed = failed or outcome == "failed"
|
||||||
|
elif destination.exists():
|
||||||
|
# A regular file or directory is user-owned unless proven
|
||||||
|
# otherwise. Report it, but never replace it.
|
||||||
|
blocked = True
|
||||||
|
else:
|
||||||
|
outcome = install_absent_symlink(destination, source)
|
||||||
|
blocked = blocked or outcome == "blocked"
|
||||||
|
failed = failed or outcome == "failed"
|
||||||
|
except OSError:
|
||||||
|
failed = True
|
||||||
|
|
||||||
|
if failed:
|
||||||
|
return RepairResult("panama.runtime-links", True, 1, "One or more Panama runtime links could not be recreated.")
|
||||||
|
if blocked:
|
||||||
|
return RepairResult("panama.runtime-links", True, 1, "A user-owned runtime path is blocking a Panama link.")
|
||||||
|
return RepairResult("panama.runtime-links", True, 0, "Panama runtime links were recreated. A fresh health check will verify them.")
|
||||||
|
|
||||||
|
|
||||||
|
def repair_vicinae_commands(config: DoctorConfig) -> RepairResult:
|
||||||
|
helper = config.root / "setup/scripts/link-vicinae-scripts"
|
||||||
|
if not helper.is_file():
|
||||||
|
return RepairResult("panama.vicinae-commands", True, 127, "The authored Vicinae link helper is unavailable.")
|
||||||
|
exit_code, _ = run_repair_command((str(helper),), config, config.root)
|
||||||
|
message = "Panama commands were relinked. A fresh health check will verify them." if exit_code == 0 \
|
||||||
|
else "Panama commands could not be relinked."
|
||||||
|
return RepairResult("panama.vicinae-commands", True, exit_code, message)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_caffeine_rows(output: str, uid: str) -> list[InhibitorRow] | None:
|
||||||
|
inhibitor_rows: list[InhibitorRow] = []
|
||||||
|
for line in output.splitlines():
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) < 2 or parts[0] != "Panama" or parts[1] != uid:
|
||||||
|
continue
|
||||||
|
if len(parts) != 8:
|
||||||
|
if "Caffeine" in parts:
|
||||||
|
return None
|
||||||
|
continue
|
||||||
|
if parts[6] != "Caffeine" or parts[7] != "block":
|
||||||
|
continue
|
||||||
|
if not parts[3].isdecimal():
|
||||||
|
return None
|
||||||
|
inhibitor_rows.append(tuple(parts))
|
||||||
|
return inhibitor_rows
|
||||||
|
|
||||||
|
|
||||||
|
def close_pidfds(pidfds: list[int]) -> None:
|
||||||
|
for pidfd in pidfds:
|
||||||
|
try:
|
||||||
|
os.close(pidfd)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def signal_caffeine_pidfds(
|
||||||
|
pidfds: list[int],
|
||||||
|
sender: Callable[..., None] | None = None,
|
||||||
|
) -> Literal["released", "preflight-failed", "incomplete"]:
|
||||||
|
send = sender or signal.pidfd_send_signal
|
||||||
|
for pidfd in pidfds:
|
||||||
|
try:
|
||||||
|
send(pidfd, 0, None, 0)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return "preflight-failed"
|
||||||
|
|
||||||
|
incomplete = False
|
||||||
|
for pidfd in pidfds:
|
||||||
|
try:
|
||||||
|
send(pidfd, signal.SIGTERM, None, 0)
|
||||||
|
except ProcessLookupError:
|
||||||
|
continue
|
||||||
|
except OSError as error:
|
||||||
|
if error.errno != errno.ESRCH:
|
||||||
|
incomplete = True
|
||||||
|
except ValueError:
|
||||||
|
incomplete = True
|
||||||
|
return "incomplete" if incomplete else "released"
|
||||||
|
|
||||||
|
|
||||||
|
def repair_caffeine(config: DoctorConfig) -> RepairResult:
|
||||||
|
list_command = ("systemd-inhibit", "--list", "--no-pager", "--no-legend")
|
||||||
|
list_exit, output = run_repair_command(list_command, config)
|
||||||
|
if list_exit != 0:
|
||||||
|
return RepairResult("panama.caffeine", True, list_exit, "Caffeine inhibitors could not be inspected.")
|
||||||
|
|
||||||
|
uid = str(os.getuid())
|
||||||
|
inhibitor_rows = parse_caffeine_rows(output, uid)
|
||||||
|
if inhibitor_rows is None:
|
||||||
|
return RepairResult("panama.caffeine", True, 1, "Caffeine inhibitor metadata was invalid; nothing was released.")
|
||||||
|
inhibitor_pids = list(dict.fromkeys(int(row[3]) for row in inhibitor_rows))
|
||||||
|
|
||||||
|
if len(inhibitor_pids) <= 1:
|
||||||
|
return RepairResult("panama.caffeine", True, 0, "No duplicate Panama Caffeine inhibitors needed release.")
|
||||||
|
|
||||||
|
duplicates = inhibitor_pids[1:]
|
||||||
|
if not hasattr(os, "pidfd_open") or not hasattr(signal, "pidfd_send_signal"):
|
||||||
|
return RepairResult("panama.caffeine", True, 1, "Safe Caffeine inhibitor release is unavailable on this system.")
|
||||||
|
|
||||||
|
pidfds: list[int] = []
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
pidfds = [os.pidfd_open(pid, 0) for pid in duplicates]
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return RepairResult("panama.caffeine", True, 1, "A duplicate inhibitor changed before it could be safely released.")
|
||||||
|
|
||||||
|
second_exit, second_output = run_repair_command(list_command, config)
|
||||||
|
if second_exit != 0:
|
||||||
|
return RepairResult("panama.caffeine", True, second_exit, "Caffeine inhibitors could not be revalidated; nothing was released.")
|
||||||
|
second_rows = parse_caffeine_rows(second_output, uid)
|
||||||
|
if second_rows is None or second_rows != inhibitor_rows:
|
||||||
|
return RepairResult("panama.caffeine", True, 1, "Caffeine inhibitor metadata changed; nothing was released.")
|
||||||
|
|
||||||
|
signal_outcome = signal_caffeine_pidfds(pidfds)
|
||||||
|
if signal_outcome == "preflight-failed":
|
||||||
|
return RepairResult("panama.caffeine", True, 1, "A duplicate inhibitor changed before it could be safely released.")
|
||||||
|
if signal_outcome == "incomplete":
|
||||||
|
return RepairResult("panama.caffeine", True, 1, "One or more duplicate inhibitors could not be released.")
|
||||||
|
finally:
|
||||||
|
close_pidfds(pidfds)
|
||||||
|
return RepairResult("panama.caffeine", True, 0, "Duplicate Panama Caffeine inhibitors were released. A fresh health check will verify recovery.")
|
||||||
|
|
||||||
|
|
||||||
|
def repair(check_id: str, config: DoctorConfig) -> RepairResult:
|
||||||
|
if check_id in REPAIR_COMMANDS:
|
||||||
|
return repair_authored_command(check_id, config)
|
||||||
|
if check_id == "panama.runtime-links":
|
||||||
|
return repair_runtime_links(config)
|
||||||
|
if check_id == "panama.vicinae-commands":
|
||||||
|
return repair_vicinae_commands(config)
|
||||||
|
if check_id == "panama.caffeine":
|
||||||
|
return repair_caffeine(config)
|
||||||
|
return RepairResult(check_id, False, 2, "This health check has no authored repair.")
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str]) -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="Panama system diagnostics and bounded repairs")
|
||||||
|
output = parser.add_mutually_exclusive_group()
|
||||||
|
output.add_argument("--json", action="store_true")
|
||||||
|
output.add_argument("--summary", action="store_true")
|
||||||
|
parser.add_argument("--repair", metavar="CHECK_ID")
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
if args.repair is not None:
|
||||||
|
if args.repair not in REPAIR_IDS or args.summary:
|
||||||
|
result = RepairResult(args.repair, False, 2, "This health check has no authored repair.")
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
result = repair(args.repair, config_from_environment())
|
||||||
|
except Exception:
|
||||||
|
result = RepairResult(args.repair, True, 1, "The authored repair could not be completed.")
|
||||||
|
print(json.dumps(result.as_json(), separators=(",", ":"), sort_keys=False))
|
||||||
|
return result.exit_code
|
||||||
|
|
||||||
|
result = snapshot(config_from_environment())
|
||||||
|
if args.summary:
|
||||||
|
summary = result["summary"]
|
||||||
|
assert isinstance(summary, dict)
|
||||||
|
print(f"Panama system health: {summary['status']} ({summary['healthy']} ok, {summary['warnings']} warnings, {summary['errors']} errors, {summary['unconfigured']} unconfigured)")
|
||||||
|
else:
|
||||||
|
print(json.dumps(result, separators=(",", ":"), sort_keys=False))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main(sys.argv[1:]))
|
||||||
@@ -25,6 +25,9 @@ PLUGIN_ACTIONS = {
|
|||||||
"kdeconnect_share": "share",
|
"kdeconnect_share": "share",
|
||||||
}
|
}
|
||||||
DEVICE_OBJECT_PREFIX = "/modules/kdeconnect/devices"
|
DEVICE_OBJECT_PREFIX = "/modules/kdeconnect/devices"
|
||||||
|
DEVICE_OBJECT_LINE = re.compile(
|
||||||
|
rf"(?P<path>{re.escape(DEVICE_OBJECT_PREFIX)}/(?P<id>[A-Fa-f0-9]{{32,64}}))$"
|
||||||
|
)
|
||||||
Runner = Callable[..., subprocess.CompletedProcess[str]]
|
Runner = Callable[..., subprocess.CompletedProcess[str]]
|
||||||
|
|
||||||
|
|
||||||
@@ -107,6 +110,13 @@ def parse_string_property(output: str) -> str:
|
|||||||
return parts[1] if len(parts) == 2 and parts[0] == "s" else ""
|
return parts[1] if len(parts) == 2 and parts[0] == "s" else ""
|
||||||
|
|
||||||
|
|
||||||
|
def parse_bool_property(output: str) -> bool | None:
|
||||||
|
parts = output.split()
|
||||||
|
if len(parts) != 2 or parts[0] != "b" or parts[1] not in {"true", "false"}:
|
||||||
|
return None
|
||||||
|
return parts[1] == "true"
|
||||||
|
|
||||||
|
|
||||||
def run_command(
|
def run_command(
|
||||||
command: list[str],
|
command: list[str],
|
||||||
*,
|
*,
|
||||||
@@ -179,6 +189,82 @@ def reported_type(device_id: str, runner: Runner = subprocess.run) -> str:
|
|||||||
return parse_string_property(result.stdout) if result.returncode == 0 else ""
|
return parse_string_property(result.stdout) if result.returncode == 0 else ""
|
||||||
|
|
||||||
|
|
||||||
|
def device_property(
|
||||||
|
device_id: str,
|
||||||
|
member: str,
|
||||||
|
runner: Runner = subprocess.run,
|
||||||
|
) -> subprocess.CompletedProcess[str]:
|
||||||
|
return run_command(
|
||||||
|
[
|
||||||
|
"busctl",
|
||||||
|
"--user",
|
||||||
|
"get-property",
|
||||||
|
"org.kde.kdeconnect",
|
||||||
|
device_object(device_id),
|
||||||
|
"org.kde.kdeconnect.device",
|
||||||
|
member,
|
||||||
|
],
|
||||||
|
runner=runner,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def dbus_device_ids(runner: Runner = subprocess.run) -> list[str]:
|
||||||
|
try:
|
||||||
|
result = run_command(
|
||||||
|
["busctl", "--user", "tree", "org.kde.kdeconnect"],
|
||||||
|
runner=runner,
|
||||||
|
)
|
||||||
|
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||||
|
return []
|
||||||
|
if result.returncode != 0:
|
||||||
|
return []
|
||||||
|
return [
|
||||||
|
match.group("id")
|
||||||
|
for line in result.stdout.splitlines()
|
||||||
|
if (match := DEVICE_OBJECT_LINE.search(line.strip())) is not None
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def dbus_devices(runner: Runner = subprocess.run) -> list[dict[str, object]]:
|
||||||
|
devices: list[dict[str, object]] = []
|
||||||
|
for device_id in dbus_device_ids(runner):
|
||||||
|
try:
|
||||||
|
name_result = device_property(device_id, "name", runner)
|
||||||
|
type_result = device_property(device_id, "type", runner)
|
||||||
|
paired_result = device_property(device_id, "isPaired", runner)
|
||||||
|
reachable_result = device_property(device_id, "isReachable", runner)
|
||||||
|
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||||
|
continue
|
||||||
|
name = parse_string_property(name_result.stdout) if name_result.returncode == 0 else ""
|
||||||
|
device_type = parse_string_property(type_result.stdout) if type_result.returncode == 0 else ""
|
||||||
|
paired = parse_bool_property(paired_result.stdout) if paired_result.returncode == 0 else None
|
||||||
|
reachable = parse_bool_property(reachable_result.stdout) if reachable_result.returncode == 0 else None
|
||||||
|
if not name or paired is not True or reachable is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
plugins = device_plugins(device_id, runner)
|
||||||
|
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||||
|
plugins = []
|
||||||
|
actions = sorted(
|
||||||
|
{
|
||||||
|
action
|
||||||
|
for plugin, action in PLUGIN_ACTIONS.items()
|
||||||
|
if plugin in plugins
|
||||||
|
}
|
||||||
|
)
|
||||||
|
devices.append(
|
||||||
|
{
|
||||||
|
"id": device_id,
|
||||||
|
"name": name,
|
||||||
|
"type": device_type or inferred_type(name),
|
||||||
|
"paired": paired,
|
||||||
|
"reachable": reachable,
|
||||||
|
"actions": actions,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return devices
|
||||||
|
|
||||||
|
|
||||||
def collect_status(runner: Runner = subprocess.run) -> dict[str, object]:
|
def collect_status(runner: Runner = subprocess.run) -> dict[str, object]:
|
||||||
try:
|
try:
|
||||||
listing = run_command(
|
listing = run_command(
|
||||||
@@ -200,15 +286,24 @@ def collect_status(runner: Runner = subprocess.run) -> dict[str, object]:
|
|||||||
continue
|
continue
|
||||||
device_id = match.group("id")
|
device_id = match.group("id")
|
||||||
try:
|
try:
|
||||||
device = normalize_device_line(
|
plugins = device_plugins(device_id, runner)
|
||||||
line,
|
device_type = reported_type(device_id, runner)
|
||||||
device_plugins(device_id, runner),
|
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||||
reported_type(device_id, runner),
|
plugins = []
|
||||||
)
|
device_type = ""
|
||||||
|
try:
|
||||||
|
device = normalize_device_line(line, plugins, device_type)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
continue
|
continue
|
||||||
devices.append(device)
|
devices.append(device)
|
||||||
|
|
||||||
|
known_ids = {str(device["id"]) for device in devices}
|
||||||
|
devices.extend(
|
||||||
|
device
|
||||||
|
for device in dbus_devices(runner)
|
||||||
|
if str(device["id"]) not in known_ids
|
||||||
|
)
|
||||||
|
|
||||||
devices.sort(
|
devices.sort(
|
||||||
key=lambda device: (
|
key=lambda device: (
|
||||||
not bool(device["reachable"]),
|
not bool(device["reachable"]),
|
||||||
|
|||||||
Executable
+138
@@ -0,0 +1,138 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
"""The login keyring's lock state, and a way to unlock it.
|
||||||
|
|
||||||
|
Why this exists
|
||||||
|
---------------
|
||||||
|
GNOME unlocks the login keyring at sign-in through pam_gnome_keyring, and so
|
||||||
|
does this desktop -- the PAM stack is GDM's and it works. What GNOME also has,
|
||||||
|
and a bare Hyprland session does not, is anywhere to SEE that it failed.
|
||||||
|
|
||||||
|
It does fail, rarely. gnome-keyring-daemon can crash (an upstream abort in
|
||||||
|
service_method_open_session, seen once here), and when it does, D-Bus activates
|
||||||
|
a replacement. That replacement never received the login password, so the login
|
||||||
|
keyring comes back LOCKED in the middle of a session that unlocked it correctly
|
||||||
|
at login. Everything that stores a secret then starts failing in ways that do
|
||||||
|
not mention keyrings at all: a mail client that will not authenticate, a git
|
||||||
|
push that cannot find its key, an integration that reports "not configured".
|
||||||
|
|
||||||
|
So this reports the state plainly and offers the one action that fixes it.
|
||||||
|
|
||||||
|
Unlocking prompts
|
||||||
|
-----------------
|
||||||
|
`unlock` asks the Secret Service to unlock, which raises the gcr password
|
||||||
|
dialog. That is deliberate: the password is not ours to store or handle, and it
|
||||||
|
never passes through this script. The dialog is the same one GNOME shows.
|
||||||
|
|
||||||
|
Note that a locked keyring makes a NON-INTERACTIVE caller appear to hang -- it
|
||||||
|
is not hung, it is waiting for a dialog nobody is looking at. That is worth
|
||||||
|
knowing before debugging one for an hour.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
panama-keyring status -> {"available", "locked", "collections", "daemon"}
|
||||||
|
panama-keyring unlock -> raises the password prompt; prints the new state
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def daemon_origin():
|
||||||
|
"""Whether the running secrets daemon came from PAM or from D-Bus activation.
|
||||||
|
|
||||||
|
A D-Bus-activated daemon is the signature of the crash-and-replace case
|
||||||
|
above: it is the one that cannot have the login password. PAM's daemon lives
|
||||||
|
outside the app slice, so the cgroup tells the two apart.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
for pid in os.listdir("/proc"):
|
||||||
|
if not pid.isdigit():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
with open(f"/proc/{pid}/cmdline", "rb") as handle:
|
||||||
|
cmdline = handle.read().decode("utf-8", "replace")
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
if "gnome-keyring-daemon" not in cmdline:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
with open(f"/proc/{pid}/cgroup", "r") as handle:
|
||||||
|
cgroup = handle.read()
|
||||||
|
except OSError:
|
||||||
|
return "unknown"
|
||||||
|
if re.search(r"dbus-.*org\.freedesktop\.secrets", cgroup):
|
||||||
|
return "dbus"
|
||||||
|
return "pam"
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return "none"
|
||||||
|
|
||||||
|
|
||||||
|
def load_service():
|
||||||
|
import gi
|
||||||
|
|
||||||
|
gi.require_version("Secret", "1")
|
||||||
|
from gi.repository import Secret
|
||||||
|
|
||||||
|
return Secret, Secret.Service.get_sync(Secret.ServiceFlags.LOAD_COLLECTIONS, None)
|
||||||
|
|
||||||
|
|
||||||
|
def report(service, Secret):
|
||||||
|
collections = [
|
||||||
|
{"label": c.get_label(), "locked": c.get_locked()}
|
||||||
|
for c in service.get_collections()
|
||||||
|
]
|
||||||
|
# The login keyring is the one that matters; the others are per-application
|
||||||
|
# stores that manage their own unlocking.
|
||||||
|
login = next((c for c in collections if c["label"] == "Login"), None)
|
||||||
|
return {
|
||||||
|
"available": True,
|
||||||
|
"locked": bool(login["locked"]) if login else False,
|
||||||
|
"hasLogin": login is not None,
|
||||||
|
"collections": collections,
|
||||||
|
"daemon": daemon_origin(),
|
||||||
|
"error": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
action = sys.argv[1] if len(sys.argv) > 1 else "status"
|
||||||
|
if action not in ("status", "unlock"):
|
||||||
|
print("usage: panama-keyring [status|unlock]", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
try:
|
||||||
|
Secret, service = load_service()
|
||||||
|
except Exception as error: # noqa: BLE001 - any failure here is "no keyring"
|
||||||
|
# No Secret Service at all is a legitimate state, not a crash: report it
|
||||||
|
# so the UI can say so instead of showing an empty card.
|
||||||
|
print(json.dumps({
|
||||||
|
"available": False, "locked": False, "hasLogin": False,
|
||||||
|
"collections": [], "daemon": daemon_origin(),
|
||||||
|
"error": f"The secret service is not answering: {error}",
|
||||||
|
}))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if action == "unlock":
|
||||||
|
login = next(
|
||||||
|
(c for c in service.get_collections() if c.get_label() == "Login"), None)
|
||||||
|
if login is not None and login.get_locked():
|
||||||
|
try:
|
||||||
|
# Blocks until the dialog is answered or dismissed.
|
||||||
|
service.unlock_sync([login], None)
|
||||||
|
except Exception as error: # noqa: BLE001
|
||||||
|
state = report(service, Secret)
|
||||||
|
state["error"] = f"The keyring was not unlocked: {error}"
|
||||||
|
print(json.dumps(state))
|
||||||
|
return 0
|
||||||
|
# The collection object caches its state; re-read it.
|
||||||
|
Secret, service = load_service()
|
||||||
|
|
||||||
|
print(json.dumps(report(service, Secret)))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Executable
+98
@@ -0,0 +1,98 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# System locale, via localectl.
|
||||||
|
#
|
||||||
|
# panama-locale list -> [{value, label, detail}]
|
||||||
|
# panama-locale get -> the current LANG, e.g. en_US.UTF-8
|
||||||
|
# panama-locale set <locale>
|
||||||
|
#
|
||||||
|
# Locale codes are not names. "pt_BR.UTF-8" tells you what it means only if you
|
||||||
|
# already know, which defeats the point of a picker, so codes are resolved
|
||||||
|
# against the iso-codes database into "Portuguese (Brazil)" the way GNOME does.
|
||||||
|
# The code stays visible as the row's detail, because it is what actually gets
|
||||||
|
# written and someone choosing between two Spanish variants needs to see it.
|
||||||
|
#
|
||||||
|
# The join happens in a single jq pass. Doing it per locale meant 327 jq
|
||||||
|
# invocations, which took long enough to be visible when opening the page.
|
||||||
|
#
|
||||||
|
# Setting the locale is a privileged operation: localectl goes through polkit,
|
||||||
|
# which prompts. It also only takes effect for programs started afterwards, so
|
||||||
|
# the caller is responsible for saying a sign-out is needed -- this script does
|
||||||
|
# not pretend the running session changed.
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
readonly ISO_LANG=/usr/share/iso-codes/json/iso_639-2.json
|
||||||
|
readonly ISO_COUNTRY=/usr/share/iso-codes/json/iso_3166-1.json
|
||||||
|
|
||||||
|
cmd_get() {
|
||||||
|
localectl status 2>/dev/null \
|
||||||
|
| awk -F'LANG=' '/System Locale:/ { print $2; exit }' \
|
||||||
|
| tr -d '[:space:]'
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_list() {
|
||||||
|
local locales
|
||||||
|
locales="$(localectl list-locales 2>/dev/null)" || locales=""
|
||||||
|
if [[ -z "$locales" ]]; then
|
||||||
|
printf '[]\n'
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Without iso-codes installed the codes are still perfectly usable; they
|
||||||
|
# just do not get friendly names. That is a degraded list, not a failure.
|
||||||
|
if [[ ! -r "$ISO_LANG" || ! -r "$ISO_COUNTRY" ]]; then
|
||||||
|
jq -Rn --rawfile raw /dev/stdin \
|
||||||
|
'[$raw | split("\n")[] | select(length > 0) | {value: ., label: ., detail: ""}]' \
|
||||||
|
<<<"$locales"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
jq -Rn \
|
||||||
|
--slurpfile languages "$ISO_LANG" \
|
||||||
|
--slurpfile countries "$ISO_COUNTRY" \
|
||||||
|
--rawfile raw /dev/stdin '
|
||||||
|
# alpha_2 -> name, for both databases. Languages without a two-letter
|
||||||
|
# code cannot appear in a locale name, so they are simply absent.
|
||||||
|
($languages[0]["639-2"] | map(select(.alpha_2)) | INDEX(.alpha_2) | map_values(.name)) as $lang
|
||||||
|
| ($countries[0]["3166-1"] | INDEX(.alpha_2) | map_values(.name)) as $country
|
||||||
|
| [ $raw
|
||||||
|
| split("\n")[]
|
||||||
|
| select(length > 0)
|
||||||
|
| . as $value
|
||||||
|
# en_US.UTF-8 -> ["en", "US"]; the codeset and any @modifier are
|
||||||
|
# not part of the human name.
|
||||||
|
| ($value | split(".")[0] | split("@")[0] | split("_")) as $parts
|
||||||
|
| ($lang[$parts[0]] // $parts[0]) as $language
|
||||||
|
| (if ($parts | length) > 1 then $country[$parts[1]] else null end) as $region
|
||||||
|
| {
|
||||||
|
value: $value,
|
||||||
|
label: (if $region then "\($language) (\($region))" else $language end),
|
||||||
|
detail: $value
|
||||||
|
}
|
||||||
|
]
|
||||||
|
| sort_by(.label)
|
||||||
|
' <<<"$locales"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_set() {
|
||||||
|
local locale="${1:-}"
|
||||||
|
# Constrained rather than passed through: this reaches a privileged
|
||||||
|
# command, and the set of legal locale names is narrow and well known.
|
||||||
|
[[ "$locale" =~ ^[[email protected]]+$ ]] || {
|
||||||
|
printf 'panama-locale: refusing a locale name with unexpected characters\n' >&2
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
localectl list-locales 2>/dev/null | grep -qxF "$locale" || {
|
||||||
|
printf 'panama-locale: %s is not an installed locale\n' "$locale" >&2
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
localectl set-locale "LANG=$locale"
|
||||||
|
}
|
||||||
|
|
||||||
|
case "${1:-list}" in
|
||||||
|
list) cmd_list ;;
|
||||||
|
get) cmd_get ;;
|
||||||
|
set) shift; cmd_set "${1:-}" ;;
|
||||||
|
*) printf 'usage: panama-locale [list|get|set <locale>]\n' >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
+85
@@ -0,0 +1,85 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# System power profile, via the PowerProfiles D-Bus API.
|
||||||
|
#
|
||||||
|
# GNOME's Power panel offers Balanced / Performance / Power Saver; this is the
|
||||||
|
# same daemon behind it. On Fedora 44 the implementation is tuned-ppd rather
|
||||||
|
# than power-profiles-daemon, but it serves the same net.hadess.PowerProfiles
|
||||||
|
# interface, which is why this talks to the interface rather than to either
|
||||||
|
# binary -- powerprofilesctl is not even installed here.
|
||||||
|
#
|
||||||
|
# Setting a profile needs no privileges: the daemon accepts a property write
|
||||||
|
# from the active session user.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# panama-power-profile list -> {"profiles":[...],"active":"...","degraded":"..."}
|
||||||
|
# panama-power-profile set <name>
|
||||||
|
#
|
||||||
|
# PerformanceDegraded is reported because it is the one thing that makes the
|
||||||
|
# choice a lie: a thermally throttled laptop reports "performance" while
|
||||||
|
# behaving otherwise, and GNOME surfaces exactly this. It is an empty string
|
||||||
|
# when nothing is wrong.
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
readonly BUS_NAME=net.hadess.PowerProfiles
|
||||||
|
readonly OBJECT=/net/hadess/PowerProfiles
|
||||||
|
|
||||||
|
emit_error() {
|
||||||
|
printf '{"profiles":[],"active":"","degraded":"","error":%s}\n' "$(jq -Rn --arg e "$1" '$e')"
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
command -v busctl >/dev/null 2>&1 || emit_error 'busctl is not available'
|
||||||
|
|
||||||
|
property() {
|
||||||
|
busctl get-property "$BUS_NAME" "$OBJECT" "$BUS_NAME" "$1" 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_list() {
|
||||||
|
# A machine with no power-profiles daemon is a normal state -- plenty of
|
||||||
|
# desktops have none -- so it is reported rather than treated as a failure.
|
||||||
|
busctl status "$BUS_NAME" >/dev/null 2>&1 \
|
||||||
|
|| emit_error 'No power profile service is running. GNOME uses power-profiles-daemon; Fedora ships tuned-ppd.'
|
||||||
|
|
||||||
|
local active degraded profiles
|
||||||
|
active="$(property ActiveProfile | sed 's/^s //; s/"//g')"
|
||||||
|
degraded="$(property PerformanceDegraded | sed 's/^s //; s/"//g')"
|
||||||
|
|
||||||
|
# Profiles is an array of dicts, which busctl renders flat:
|
||||||
|
# v aa{sv} 3 2 "Profile" s "power-saver" "Driver" s "tuned" 2 "Profile" ...
|
||||||
|
# so each profile is the string following its own "Profile" marker. Matching
|
||||||
|
# the marker matters: "Driver" values sit in the same stream, and on this
|
||||||
|
# machine the driver is called "tuned", which a looser pattern happily
|
||||||
|
# reports as a fourth profile that does not exist.
|
||||||
|
profiles="$(property Profiles \
|
||||||
|
| grep -oE '"Profile" s "[a-z-]+"' \
|
||||||
|
| sed 's/.*s "//; s/"$//' \
|
||||||
|
| awk '!seen[$0]++')"
|
||||||
|
|
||||||
|
[[ -n "$profiles" ]] || emit_error 'The power profile service reported no profiles.'
|
||||||
|
|
||||||
|
jq -cn \
|
||||||
|
--arg active "$active" \
|
||||||
|
--arg degraded "$degraded" \
|
||||||
|
--argjson profiles "$(printf '%s\n' "$profiles" | jq -Rn '[inputs | select(length > 0)]')" \
|
||||||
|
'{profiles: $profiles, active: $active, degraded: $degraded, error: ""}'
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_set() {
|
||||||
|
local profile="${1:-}"
|
||||||
|
# Constrained rather than passed through: this reaches a system service.
|
||||||
|
[[ "$profile" =~ ^[a-z-]+$ ]] || {
|
||||||
|
printf 'panama-power-profile: refusing a profile name with unexpected characters\n' >&2
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
busctl set-property "$BUS_NAME" "$OBJECT" "$BUS_NAME" ActiveProfile s "$profile" 2>&1 >/dev/null \
|
||||||
|
| head -2 >&2
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
case "${1:-list}" in
|
||||||
|
list) cmd_list ;;
|
||||||
|
set) shift; cmd_set "${1:-}" ;;
|
||||||
|
*) printf 'usage: panama-power-profile [list|set <profile>]\n' >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
+83
@@ -0,0 +1,83 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# Device security facts, as JSON.
|
||||||
|
#
|
||||||
|
# Everything here is READ-ONLY and deliberately so. Secure Boot, TPM presence,
|
||||||
|
# disk encryption, SELinux mode and the firewall are set in firmware, at install
|
||||||
|
# time, or by system policy -- none of them is a desktop preference, and a
|
||||||
|
# settings app that offered to toggle them would either fail or do something
|
||||||
|
# far-reaching from a switch that looks like any other.
|
||||||
|
#
|
||||||
|
# What it is for is answering "is this machine set up the way I think it is",
|
||||||
|
# which is the question GNOME's Device Security panel exists to answer and which
|
||||||
|
# otherwise needs five commands and root.
|
||||||
|
#
|
||||||
|
# Each fact is reported as {value, ok} where `ok` marks the reassuring state, so
|
||||||
|
# the UI can highlight what deserves attention without hard-coding the meaning
|
||||||
|
# of each string. Anything that cannot be determined reports "Unknown" with
|
||||||
|
# ok:false rather than guessing, because a security readout that quietly reports
|
||||||
|
# "fine" when it failed to look is worse than no readout.
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
fact() {
|
||||||
|
jq -cn --arg label "$1" --arg value "$2" --argjson ok "$3" --arg detail "${4:-}" \
|
||||||
|
'{label: $label, value: $value, ok: $ok, detail: $detail}'
|
||||||
|
}
|
||||||
|
|
||||||
|
facts=()
|
||||||
|
|
||||||
|
# ── Secure Boot ──────────────────────────────────────────────────────────────
|
||||||
|
if command -v mokutil >/dev/null 2>&1; then
|
||||||
|
case "$(mokutil --sb-state 2>/dev/null)" in
|
||||||
|
*"SecureBoot enabled"*) facts+=("$(fact "Secure Boot" "Enabled" true "Firmware verifies the bootloader and kernel signatures")" ) ;;
|
||||||
|
*"SecureBoot disabled"*) facts+=("$(fact "Secure Boot" "Disabled" false "Firmware does not verify what it boots")") ;;
|
||||||
|
*) facts+=("$(fact "Secure Boot" "Unknown" false "The firmware did not report a Secure Boot state")") ;;
|
||||||
|
esac
|
||||||
|
elif [[ -d /sys/firmware/efi ]]; then
|
||||||
|
facts+=("$(fact "Secure Boot" "Unknown" false "Install mokutil to report this")")
|
||||||
|
else
|
||||||
|
facts+=("$(fact "Secure Boot" "Not applicable" false "This machine booted in legacy BIOS mode")")
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── TPM ──────────────────────────────────────────────────────────────────────
|
||||||
|
tpm_major="$(cat /sys/class/tpm/tpm0/tpm_version_major 2>/dev/null || true)"
|
||||||
|
if [[ -n "$tpm_major" ]]; then
|
||||||
|
facts+=("$(fact "TPM" "Version $tpm_major" true "A trusted platform module is present and usable")")
|
||||||
|
elif [[ -e /sys/class/tpm/tpm0 ]]; then
|
||||||
|
facts+=("$(fact "TPM" "Present" true "A trusted platform module is present")")
|
||||||
|
else
|
||||||
|
facts+=("$(fact "TPM" "None" false "No trusted platform module, so keys cannot be sealed to this machine")")
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Disk encryption ──────────────────────────────────────────────────────────
|
||||||
|
# Counts LUKS mappings rather than naming them: which volume is encrypted is
|
||||||
|
# more detail than this readout needs, and device names are not meaningful here.
|
||||||
|
crypt_count="$(lsblk -o TYPE 2>/dev/null | grep -c '^crypt$' || true)"
|
||||||
|
[[ "$crypt_count" =~ ^[0-9]+$ ]] || crypt_count=0
|
||||||
|
if (( crypt_count > 0 )); then
|
||||||
|
facts+=("$(fact "Disk encryption" "$crypt_count encrypted volume$( (( crypt_count == 1 )) || printf 's')" true "Data at rest is protected by LUKS")")
|
||||||
|
else
|
||||||
|
facts+=("$(fact "Disk encryption" "None" false "No LUKS volume is unlocked on this machine")")
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── SELinux ──────────────────────────────────────────────────────────────────
|
||||||
|
if command -v getenforce >/dev/null 2>&1; then
|
||||||
|
case "$(getenforce 2>/dev/null)" in
|
||||||
|
Enforcing) facts+=("$(fact "SELinux" "Enforcing" true "Policy violations are blocked")") ;;
|
||||||
|
Permissive) facts+=("$(fact "SELinux" "Permissive" false "Violations are logged but allowed")") ;;
|
||||||
|
Disabled) facts+=("$(fact "SELinux" "Disabled" false "Mandatory access control is off")") ;;
|
||||||
|
*) facts+=("$(fact "SELinux" "Unknown" false "")") ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Firewall ─────────────────────────────────────────────────────────────────
|
||||||
|
if systemctl list-unit-files firewalld.service >/dev/null 2>&1; then
|
||||||
|
if [[ "$(systemctl is-active firewalld 2>/dev/null)" == "active" ]]; then
|
||||||
|
facts+=("$(fact "Firewall" "Active" true "firewalld is filtering incoming connections")")
|
||||||
|
else
|
||||||
|
facts+=("$(fact "Firewall" "Inactive" false "firewalld is installed but not running")")
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '[%s]\n' "$(IFS=,; printf '%s' "${facts[*]}")"
|
||||||
@@ -11,6 +11,11 @@
|
|||||||
# Terminals are the notable exception -- they predate the standard and carry
|
# Terminals are the notable exception -- they predate the standard and carry
|
||||||
# their own palettes. kitty is handled here.
|
# their own palettes. kitty is handled here.
|
||||||
#
|
#
|
||||||
|
# GTK3 is the other one. Under GNOME, gnome-settings-daemon publishes the theme
|
||||||
|
# over XSETTINGS; under Hyprland nothing does, so ~/.config/gtk-3.0/settings.ini
|
||||||
|
# is authoritative for GTK3 applications. Pinned to dark, it contradicted the
|
||||||
|
# scheme in light mode, so it is generated from a template here instead.
|
||||||
|
#
|
||||||
# panama-theme-apps dark|light
|
# panama-theme-apps dark|light
|
||||||
#
|
#
|
||||||
# kitty gets it twice: the generated include file so terminals opened later
|
# kitty gets it twice: the generated include file so terminals opened later
|
||||||
@@ -26,6 +31,84 @@ case "$scheme" in
|
|||||||
*) printf 'usage: panama-theme-apps [dark|light]\n' >&2; exit 2 ;;
|
*) printf 'usage: panama-theme-apps [dark|light]\n' >&2; exit 2 ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
|
# ── tmux ─────────────────────────────────────────────────────────────────────
|
||||||
|
# Generated like kitty's: tmux.conf sources current-theme.conf, and that file is
|
||||||
|
# machine state rather than configuration. Running servers are re-sourced so an
|
||||||
|
# open session changes now instead of at next launch -- tmux applies a
|
||||||
|
# source-file to every attached client immediately.
|
||||||
|
tmux_dir="${XDG_CONFIG_HOME:-$HOME/.config}/tmux"
|
||||||
|
tmux_theme="$tmux_dir/themes/tokyonight-moon.conf"
|
||||||
|
[[ "$scheme" == "light" ]] && tmux_theme="$tmux_dir/themes/tokyonight-day.conf"
|
||||||
|
|
||||||
|
status_tmux="skipped"
|
||||||
|
if [[ -r "$tmux_theme" ]]; then
|
||||||
|
if cp "$tmux_theme" "$tmux_dir/current-theme.conf.tmp" 2>/dev/null \
|
||||||
|
&& mv "$tmux_dir/current-theme.conf.tmp" "$tmux_dir/current-theme.conf" 2>/dev/null; then
|
||||||
|
status_tmux="written"
|
||||||
|
# Only if a server is actually running; `tmux source-file` would
|
||||||
|
# otherwise start one just to theme it.
|
||||||
|
if command -v tmux >/dev/null 2>&1 && tmux has-session 2>/dev/null; then
|
||||||
|
tmux source-file "$tmux_dir/current-theme.conf" 2>/dev/null \
|
||||||
|
&& status_tmux="applied to running sessions"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
rm -f "$tmux_dir/current-theme.conf.tmp"
|
||||||
|
status_tmux="failed"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── btop ─────────────────────────────────────────────────────────────────────
|
||||||
|
# Only the color_theme line is rewritten, in place. btop OWNS btop.conf -- it
|
||||||
|
# rewrites the whole file on exit -- so the config is not symlinked into Panama
|
||||||
|
# and not replaced wholesale here; just this one value is edited, and btop keeps
|
||||||
|
# it on the next write.
|
||||||
|
#
|
||||||
|
# btop reads its theme once at startup, so a running instance keeps the old
|
||||||
|
# colours until it is restarted. That is acceptable for a monitor you open when
|
||||||
|
# you want it, and forcing a restart would kill a process the user is watching.
|
||||||
|
btop_conf="${XDG_CONFIG_HOME:-$HOME/.config}/btop/btop.conf"
|
||||||
|
btop_theme="tokyonight-moon"
|
||||||
|
[[ "$scheme" == "light" ]] && btop_theme="tokyonight-day"
|
||||||
|
|
||||||
|
status_btop="skipped"
|
||||||
|
if [[ -w "$btop_conf" ]]; then
|
||||||
|
if sed -i "s|^color_theme *=.*|color_theme = \"$btop_theme\"|" "$btop_conf" 2>/dev/null; then
|
||||||
|
status_btop="written"
|
||||||
|
else
|
||||||
|
status_btop="failed"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── GTK ──────────────────────────────────────────────────────────────────────
|
||||||
|
# adw-gtk3, not Adwaita: no Adwaita GTK theme is installed on Fedora 44, and
|
||||||
|
# naming a theme that does not exist makes GTK fall back to its light default --
|
||||||
|
# which made dark mode silently produce light windows.
|
||||||
|
if [[ "$scheme" == "light" ]]; then
|
||||||
|
gtk_theme="adw-gtk3"
|
||||||
|
prefer_dark=0
|
||||||
|
else
|
||||||
|
gtk_theme="adw-gtk3-dark"
|
||||||
|
prefer_dark=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
status_gtk="skipped"
|
||||||
|
for gtk_version in 3.0 4.0; do
|
||||||
|
gtk_dir="${XDG_CONFIG_HOME:-$HOME/.config}/gtk-$gtk_version"
|
||||||
|
template="$gtk_dir/settings.ini.template"
|
||||||
|
[[ -r "$template" ]] || continue
|
||||||
|
|
||||||
|
# Written atomically: a GTK application starting mid-write would otherwise
|
||||||
|
# read a truncated file and fall back to defaults.
|
||||||
|
if sed -e "s/@GTK_THEME@/$gtk_theme/" -e "s/@PREFER_DARK@/$prefer_dark/" "$template" \
|
||||||
|
>"$gtk_dir/settings.ini.tmp" 2>/dev/null \
|
||||||
|
&& mv "$gtk_dir/settings.ini.tmp" "$gtk_dir/settings.ini" 2>/dev/null; then
|
||||||
|
status_gtk="written"
|
||||||
|
else
|
||||||
|
rm -f "$gtk_dir/settings.ini.tmp"
|
||||||
|
status_gtk="failed"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
kitty_dir="${XDG_CONFIG_HOME:-$HOME/.config}/kitty"
|
kitty_dir="${XDG_CONFIG_HOME:-$HOME/.config}/kitty"
|
||||||
theme_file="$kitty_dir/themes/tokyonight-moon.conf"
|
theme_file="$kitty_dir/themes/tokyonight-moon.conf"
|
||||||
[[ "$scheme" == "light" ]] && theme_file="$kitty_dir/themes/tokyonight-day.conf"
|
[[ "$scheme" == "light" ]] && theme_file="$kitty_dir/themes/tokyonight-day.conf"
|
||||||
@@ -54,4 +137,13 @@ if [[ -r "$theme_file" ]]; then
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
printf '{"scheme":"%s","kitty":"%s"}\n' "$scheme" "$status_kitty"
|
# Every target reports what actually happened. A helper that says only
|
||||||
|
# "kitty: applied" while silently skipping three other applications is how a
|
||||||
|
# half-applied theme goes unnoticed.
|
||||||
|
jq -cn \
|
||||||
|
--arg scheme "$scheme" \
|
||||||
|
--arg kitty "$status_kitty" \
|
||||||
|
--arg gtk "$status_gtk" \
|
||||||
|
--arg btop "$status_btop" \
|
||||||
|
--arg tmux "$status_tmux" \
|
||||||
|
'{scheme: $scheme, kitty: $kitty, gtk: $gtk, btop: $btop, tmux: $tmux}'
|
||||||
|
|||||||
Executable
+121
@@ -0,0 +1,121 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# A QR code for a saved Wi-Fi network, so a guest can join by pointing a camera.
|
||||||
|
#
|
||||||
|
# GNOME's Wi-Fi panel has this and it is the single most-used thing in it.
|
||||||
|
# The payload is the de-facto WIFI: URI that Android and iOS both scan:
|
||||||
|
#
|
||||||
|
# WIFI:T:WPA;S:<ssid>;P:<passphrase>;H:<hidden>;;
|
||||||
|
#
|
||||||
|
# HANDLING THE PASSPHRASE
|
||||||
|
#
|
||||||
|
# This image contains the network password in machine-readable form. Anyone who
|
||||||
|
# can read the file can read the password, so:
|
||||||
|
#
|
||||||
|
# * it is written under XDG_RUNTIME_DIR, which is 0700 and on tmpfs, so it
|
||||||
|
# never reaches disk and disappears at logout -- not /tmp, which is shared;
|
||||||
|
# * it is created with umask 077;
|
||||||
|
# * the passphrase is never printed, never passed as an argument (argv is
|
||||||
|
# world-readable via /proc), and never appears in an error message.
|
||||||
|
#
|
||||||
|
# It is piped to qrencode on stdin for that last reason.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# panama-wifi-qr list -> {"networks":[{"name","ssid","shareable"}]}
|
||||||
|
# panama-wifi-qr qr <name> -> {"path":"/run/user/…/….png"}
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
emit_error() {
|
||||||
|
printf '{"networks":[],"path":"","error":%s}\n' "$(jq -Rn --arg e "$1" '$e')"
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
command -v nmcli >/dev/null 2>&1 || emit_error 'NetworkManager is not available'
|
||||||
|
command -v qrencode >/dev/null 2>&1 || emit_error 'qrencode is not installed, so a Wi-Fi QR code cannot be drawn'
|
||||||
|
|
||||||
|
cmd_list() {
|
||||||
|
local rows=() name ssid psk
|
||||||
|
while IFS= read -r name; do
|
||||||
|
[[ -n "$name" ]] || continue
|
||||||
|
ssid="$(nmcli -g 802-11-wireless.ssid connection show "$name" 2>/dev/null)"
|
||||||
|
[[ -n "$ssid" ]] || ssid="$name"
|
||||||
|
|
||||||
|
# Only networks whose passphrase this user can actually read are
|
||||||
|
# shareable. An enterprise network has no passphrase to share at all,
|
||||||
|
# and a QR code for one would simply not work.
|
||||||
|
psk="$(nmcli -s -g 802-11-wireless-security.psk connection show "$name" 2>/dev/null)"
|
||||||
|
|
||||||
|
rows+=("$(jq -cn --arg name "$name" --arg ssid "$ssid" \
|
||||||
|
--argjson shareable "$([[ -n "$psk" ]] && echo true || echo false)" \
|
||||||
|
'{name: $name, ssid: $ssid, shareable: $shareable}')")
|
||||||
|
done < <(nmcli -t -f NAME,TYPE connection show 2>/dev/null \
|
||||||
|
| awk -F: '$2 == "802-11-wireless" { print $1 }')
|
||||||
|
|
||||||
|
if [[ ${#rows[@]} -eq 0 ]]; then
|
||||||
|
printf '{"networks":[],"path":"","error":"No saved Wi-Fi networks."}\n'
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
printf '{"networks":[%s],"path":"","error":""}\n' "$(IFS=,; printf '%s' "${rows[*]}")"
|
||||||
|
}
|
||||||
|
|
||||||
|
# The WIFI: URI reserves \ ; , : and ", each escaped with a backslash. An SSID
|
||||||
|
# containing a semicolon would otherwise terminate the field early and produce a
|
||||||
|
# QR code for a different network entirely.
|
||||||
|
#
|
||||||
|
# Trailing newlines are stripped as well. nmcli terminates every value with one,
|
||||||
|
# and left in place it lands INSIDE the payload -- the code still decodes here,
|
||||||
|
# but a newline in the middle of a WIFI: URI is not something every phone's
|
||||||
|
# scanner tolerates, and the failure would look like "the QR code just does not
|
||||||
|
# work on my phone".
|
||||||
|
escape_field() {
|
||||||
|
sed -e 's/\\/\\\\/g' -e 's/;/\\;/g' -e 's/,/\\,/g' -e 's/:/\\:/g' -e 's/"/\\"/g' \
|
||||||
|
| tr -d '\n'
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_qr() {
|
||||||
|
local name="${1:-}"
|
||||||
|
[[ -n "$name" ]] || emit_error 'no network named'
|
||||||
|
|
||||||
|
local ssid hidden psk_file payload_file out_dir out_file
|
||||||
|
ssid="$(nmcli -g 802-11-wireless.ssid connection show "$name" 2>/dev/null)"
|
||||||
|
[[ -n "$ssid" ]] || emit_error "There is no saved network called \"$name\"."
|
||||||
|
|
||||||
|
hidden="$(nmcli -g 802-11-wireless.hidden connection show "$name" 2>/dev/null)"
|
||||||
|
[[ "$hidden" == "yes" ]] && hidden=true || hidden=false
|
||||||
|
|
||||||
|
out_dir="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/panama"
|
||||||
|
umask 077
|
||||||
|
mkdir -p "$out_dir" 2>/dev/null || emit_error 'could not create the runtime directory'
|
||||||
|
chmod 700 "$out_dir" 2>/dev/null || true
|
||||||
|
|
||||||
|
# Named after the connection, hashed, so repeated shares reuse one file
|
||||||
|
# instead of accumulating images of the password.
|
||||||
|
out_file="$out_dir/wifi-$(printf '%s' "$name" | sha256sum | cut -c1-16).png"
|
||||||
|
|
||||||
|
# Built in a file rather than a variable that could be echoed, and piped to
|
||||||
|
# qrencode on stdin so the passphrase never appears in argv.
|
||||||
|
payload_file="$(mktemp "$out_dir/payload.XXXXXX")" || emit_error 'could not create a temporary file'
|
||||||
|
trap 'rm -f "$payload_file"' RETURN
|
||||||
|
|
||||||
|
{
|
||||||
|
printf 'WIFI:T:WPA;S:'
|
||||||
|
printf '%s' "$ssid" | escape_field
|
||||||
|
printf ';P:'
|
||||||
|
nmcli -s -g 802-11-wireless-security.psk connection show "$name" 2>/dev/null | escape_field
|
||||||
|
printf ';H:%s;;' "$hidden"
|
||||||
|
} >"$payload_file"
|
||||||
|
|
||||||
|
if ! qrencode -o "$out_file" -s 8 -m 2 -l M <"$payload_file" 2>/dev/null; then
|
||||||
|
emit_error "Could not generate a QR code for \"$name\"."
|
||||||
|
fi
|
||||||
|
chmod 600 "$out_file" 2>/dev/null || true
|
||||||
|
|
||||||
|
jq -cn --arg path "$out_file" '{networks: [], path: $path, error: ""}'
|
||||||
|
}
|
||||||
|
|
||||||
|
case "${1:-list}" in
|
||||||
|
list) cmd_list ;;
|
||||||
|
qr) shift; cmd_qr "${1:-}" ;;
|
||||||
|
*) printf 'usage: panama-wifi-qr [list|qr <name>]\n' >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
@@ -25,25 +25,14 @@ import qs.config
|
|||||||
Singleton {
|
Singleton {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
property string cursorTheme: ""
|
|
||||||
property string lastError: ""
|
property string lastError: ""
|
||||||
|
|
||||||
readonly property bool busy: themeQuery.running || runner.running || root.pending.length > 0
|
readonly property bool busy: runner.running || root.pending.length > 0
|
||||||
|
|
||||||
|
readonly property string cursorTheme: DesktopPreferences.get("cursorTheme")
|
||||||
readonly property int cursorSize: DesktopPreferences.get("cursorSize")
|
readonly property int cursorSize: DesktopPreferences.get("cursorSize")
|
||||||
readonly property real textScale: DesktopPreferences.get("textScale")
|
readonly property real textScale: DesktopPreferences.get("textScale")
|
||||||
|
|
||||||
Process {
|
|
||||||
id: themeQuery
|
|
||||||
command: ["gsettings", "get", "org.gnome.desktop.interface", "cursor-theme"]
|
|
||||||
stdout: StdioCollector {
|
|
||||||
onStreamFinished: {
|
|
||||||
// gsettings quotes strings: 'oreo_blue_cursors'
|
|
||||||
root.cursorTheme = this.text.trim().replace(/^'|'$/g, "");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A short queue, because applying one setting takes several commands and
|
// A short queue, because applying one setting takes several commands and
|
||||||
// Process runs one at a time.
|
// Process runs one at a time.
|
||||||
property var pending: []
|
property var pending: []
|
||||||
@@ -71,7 +60,6 @@ Singleton {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Component.onCompleted: {
|
Component.onCompleted: {
|
||||||
themeQuery.running = true;
|
|
||||||
settle.restart();
|
settle.restart();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,9 +90,6 @@ Singleton {
|
|||||||
["gsettings", "set", "org.gnome.desktop.interface", "cursor-size", size],
|
["gsettings", "set", "org.gnome.desktop.interface", "cursor-size", size],
|
||||||
["gsettings", "set", "org.gnome.desktop.interface", "text-scaling-factor", String(root.textScale)]
|
["gsettings", "set", "org.gnome.desktop.interface", "text-scaling-factor", String(root.textScale)]
|
||||||
];
|
];
|
||||||
// setcursor needs a theme name; skip it rather than guess if gsettings
|
|
||||||
// has not answered yet. The next change will catch up.
|
|
||||||
if (root.cursorTheme !== "")
|
|
||||||
commands.push(["hyprctl", "setcursor", root.cursorTheme, size]);
|
commands.push(["hyprctl", "setcursor", root.cursorTheme, size]);
|
||||||
root.enqueue(commands);
|
root.enqueue(commands);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ Singleton {
|
|||||||
readonly property string appThemePath: Quickshell.shellDir + "/scripts/panama-theme-apps"
|
readonly property string appThemePath: Quickshell.shellDir + "/scripts/panama-theme-apps"
|
||||||
|
|
||||||
readonly property bool dark: DesktopPreferences.get("colorScheme") !== "light"
|
readonly property bool dark: DesktopPreferences.get("colorScheme") !== "light"
|
||||||
|
// A neutral contrast role, not an accent. The focused Prism border belongs
|
||||||
|
// to the visual theme and must remain untouched when this role changes.
|
||||||
|
readonly property string inactiveBorderDark: "rgba(3b426199)"
|
||||||
|
readonly property string inactiveBorderLight: "rgba(a8aecb99)"
|
||||||
|
readonly property string inactiveBorder: root.dark ? root.inactiveBorderDark : root.inactiveBorderLight
|
||||||
property string lastError: ""
|
property string lastError: ""
|
||||||
|
|
||||||
// Applied one command at a time: Process runs a single command, and several
|
// Applied one command at a time: Process runs a single command, and several
|
||||||
@@ -78,21 +83,31 @@ Singleton {
|
|||||||
root.lastError = "";
|
root.lastError = "";
|
||||||
|
|
||||||
const scheme = root.dark ? "prefer-dark" : "prefer-light";
|
const scheme = root.dark ? "prefer-dark" : "prefer-light";
|
||||||
// Adwaita's light and dark are the same theme; only the preference and
|
|
||||||
// the -dark suffix differ, so applications that honour either agree.
|
// adw-gtk3, not Adwaita. This is the bug that made dark mode look
|
||||||
const gtkTheme = root.dark ? "Adwaita-dark" : "Adwaita";
|
// broken while light mode looked fine:
|
||||||
|
//
|
||||||
|
// Neither "Adwaita" nor "Adwaita-dark" is an installed theme on Fedora
|
||||||
|
// 44 -- only adw-gtk3 and adw-gtk3-dark are. Naming a theme that does
|
||||||
|
// not exist makes GTK fall back to its built-in default, which is
|
||||||
|
// LIGHT. So asking for light accidentally worked, asking for dark
|
||||||
|
// silently produced light, and applications that take their cue from
|
||||||
|
// the GTK theme rather than the portal -- Chromium and Electron, when
|
||||||
|
// built against GTK -- stayed light no matter what the portal said.
|
||||||
|
//
|
||||||
|
// gtk-theme-contract asserts these names are actually installed,
|
||||||
|
// because the failure mode is silent in exactly this way.
|
||||||
|
const gtkTheme = root.dark ? "adw-gtk3-dark" : "adw-gtk3";
|
||||||
|
|
||||||
const commands = [
|
const commands = [
|
||||||
["gsettings", "set", "org.gnome.desktop.interface", "color-scheme", scheme],
|
["gsettings", "set", "org.gnome.desktop.interface", "color-scheme", scheme],
|
||||||
["gsettings", "set", "org.gnome.desktop.interface", "gtk-theme", gtkTheme]
|
["gsettings", "set", "org.gnome.desktop.interface", "gtk-theme", gtkTheme]
|
||||||
];
|
];
|
||||||
|
|
||||||
// Unfocused window borders. The focused border is the prism gradient and
|
// Unfocused window borders need scheme-relative contrast. The focused
|
||||||
// is already scheme-independent; the inactive one is a flat neutral that
|
// Prism border is deliberately owned by the accent/theme layer.
|
||||||
// would be invisible against the opposite background.
|
|
||||||
const inactive = root.dark ? "rgba(3b426199)" : "rgba(a8aecb99)";
|
|
||||||
commands.push(["hyprctl", "eval",
|
commands.push(["hyprctl", "eval",
|
||||||
`hl.config({ general = { col = { inactive_border = "${inactive}" } } })`]);
|
`hl.config({ general = { col = { inactive_border = "${root.inactiveBorder}" } } })`]);
|
||||||
|
|
||||||
// Applications that predate org.freedesktop.appearance and carry their
|
// Applications that predate org.freedesktop.appearance and carry their
|
||||||
// own palettes -- terminals, chiefly. Everything that reads the portal
|
// own palettes -- terminals, chiefly. Everything that reads the portal
|
||||||
|
|||||||
@@ -33,11 +33,16 @@ Singleton {
|
|||||||
}
|
}
|
||||||
|
|
||||||
readonly property var wiredDevice: {
|
readonly property var wiredDevice: {
|
||||||
|
let fallback = null;
|
||||||
for (const device of Networking.devices.values) {
|
for (const device of Networking.devices.values) {
|
||||||
if (device.type === DeviceType.Wired)
|
if (device.type !== DeviceType.Wired)
|
||||||
|
continue;
|
||||||
|
if (device.connected)
|
||||||
return device;
|
return device;
|
||||||
|
if (!fallback)
|
||||||
|
fallback = device;
|
||||||
}
|
}
|
||||||
return null;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
readonly property var adapter: Bluetooth.defaultAdapter
|
readonly property var adapter: Bluetooth.defaultAdapter
|
||||||
|
|||||||
@@ -98,5 +98,16 @@ Singleton {
|
|||||||
mutationProcess.exec([root.helper, "set-autostart", desktopId, String(enabled)]);
|
mutationProcess.exec([root.helper, "set-autostart", desktopId, String(enabled)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function addAutostart(desktopId: string): void {
|
||||||
|
if (root.busy)
|
||||||
|
return;
|
||||||
|
if (!root.knownDesktopId(desktopId)) {
|
||||||
|
root.lastError = "Choose an installed application.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
root.lastError = "";
|
||||||
|
mutationProcess.exec([root.helper, "add-autostart", desktopId]);
|
||||||
|
}
|
||||||
|
|
||||||
Component.onCompleted: root.refresh()
|
Component.onCompleted: root.refresh()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,260 @@
|
|||||||
|
pragma Singleton
|
||||||
|
|
||||||
|
// Application-facing desktop style.
|
||||||
|
//
|
||||||
|
// Panama owns the durable choices; gsettings is an output boundary for GTK
|
||||||
|
// and applications that follow GNOME's desktop schemas. Commands are arrays,
|
||||||
|
// values are validated before storage, and no user text is ever sent through a
|
||||||
|
// shell. Hyprland's pointer setting stays live through Accessibility.
|
||||||
|
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import QtQuick
|
||||||
|
import qs.config
|
||||||
|
|
||||||
|
Singleton {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-desktop-style"
|
||||||
|
|
||||||
|
// SearchPicker consumes [{ value, label, detail }]. Keep the raw names as
|
||||||
|
// a separate allow-list so a caller cannot smuggle a display label into a
|
||||||
|
// stored theme name.
|
||||||
|
property var cursorThemes: []
|
||||||
|
property var iconThemes: []
|
||||||
|
property var cursorThemeNames: []
|
||||||
|
property var iconThemeNames: []
|
||||||
|
property bool catalogLoaded: false
|
||||||
|
property bool scanning: false
|
||||||
|
property bool startupApplied: false
|
||||||
|
property string lastError: ""
|
||||||
|
|
||||||
|
property var pending: []
|
||||||
|
readonly property bool busy: root.scanning || catalogProcess.running
|
||||||
|
|| runner.running || root.pending.length > 0
|
||||||
|
readonly property int preferenceRevision: DesktopPreferences.revision
|
||||||
|
|
||||||
|
readonly property string cursorTheme: DesktopPreferences.get("cursorTheme")
|
||||||
|
readonly property string iconTheme: DesktopPreferences.get("iconTheme")
|
||||||
|
readonly property string applicationFont: DesktopPreferences.get("applicationFont")
|
||||||
|
readonly property string documentFont: DesktopPreferences.get("documentFont")
|
||||||
|
readonly property string monospaceFont: DesktopPreferences.get("monospaceFont")
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: catalogProcess
|
||||||
|
|
||||||
|
stdout: StdioCollector {
|
||||||
|
onStreamFinished: root.acceptCatalog(this.text)
|
||||||
|
}
|
||||||
|
|
||||||
|
onExited: (exitCode, exitStatus) => {
|
||||||
|
root.scanning = false;
|
||||||
|
if (exitCode !== 0) {
|
||||||
|
root.catalogLoaded = false;
|
||||||
|
root.lastError = "Installed icon and pointer themes could not be read.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: runner
|
||||||
|
|
||||||
|
onExited: (exitCode, exitStatus) => {
|
||||||
|
if (exitCode !== 0)
|
||||||
|
root.lastError = "One desktop style setting could not be applied.";
|
||||||
|
root.drain();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Component.onCompleted: {
|
||||||
|
root.ensureStarted();
|
||||||
|
// Accessing the singleton here keeps its existing hyprctl setcursor
|
||||||
|
// path alive for cursor-theme changes as well as cursor-size changes.
|
||||||
|
Accessibility.applyAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: startupApply
|
||||||
|
interval: 1200
|
||||||
|
onTriggered: {
|
||||||
|
root.startupApplied = true;
|
||||||
|
root.applyAll();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: DesktopPreferences
|
||||||
|
function onRevisionChanged(): void { applyCoalesce.restart(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: applyCoalesce
|
||||||
|
interval: 180
|
||||||
|
onTriggered: root.applyAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureStarted(): void {
|
||||||
|
if (!root.catalogLoaded && !root.scanning)
|
||||||
|
root.refreshCatalog();
|
||||||
|
if (!root.startupApplied && !startupApply.running)
|
||||||
|
startupApply.restart();
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshCatalog(): void {
|
||||||
|
if (catalogProcess.running)
|
||||||
|
return;
|
||||||
|
root.scanning = true;
|
||||||
|
catalogProcess.exec([root.helperPath]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function acceptCatalog(text: string): void {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(text);
|
||||||
|
if (!parsed || !Array.isArray(parsed.cursorThemes) || !Array.isArray(parsed.iconThemes))
|
||||||
|
throw new Error("invalid catalog shape");
|
||||||
|
|
||||||
|
const cursors = parsed.cursorThemes.filter(name =>
|
||||||
|
typeof name === "string" && PreferenceSchema.coerce("cursorTheme", name) !== undefined);
|
||||||
|
const icons = parsed.iconThemes.filter(name =>
|
||||||
|
typeof name === "string" && PreferenceSchema.coerce("iconTheme", name) !== undefined);
|
||||||
|
root.cursorThemeNames = cursors;
|
||||||
|
root.iconThemeNames = icons;
|
||||||
|
root.cursorThemes = cursors.map(name => ({
|
||||||
|
value: name,
|
||||||
|
label: name,
|
||||||
|
detail: "Pointer theme"
|
||||||
|
}));
|
||||||
|
root.iconThemes = icons.map(name => ({
|
||||||
|
value: name,
|
||||||
|
label: name,
|
||||||
|
detail: "Application icon theme"
|
||||||
|
}));
|
||||||
|
root.catalogLoaded = true;
|
||||||
|
root.lastError = "";
|
||||||
|
} catch (error) {
|
||||||
|
root.cursorThemes = [];
|
||||||
|
root.iconThemes = [];
|
||||||
|
root.cursorThemeNames = [];
|
||||||
|
root.iconThemeNames = [];
|
||||||
|
root.catalogLoaded = false;
|
||||||
|
root.lastError = "Installed icon and pointer themes could not be read.";
|
||||||
|
}
|
||||||
|
root.scanning = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function drain(): void {
|
||||||
|
if (runner.running || root.pending.length === 0)
|
||||||
|
return;
|
||||||
|
const next = root.pending[0];
|
||||||
|
root.pending = root.pending.slice(1);
|
||||||
|
runner.exec(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
function enqueue(commands: var): void {
|
||||||
|
// A fresh revision supersedes commands that have not started yet. The
|
||||||
|
// currently running command is allowed to finish, then the newest full
|
||||||
|
// state is replayed in a deterministic order.
|
||||||
|
root.pending = commands;
|
||||||
|
root.drain();
|
||||||
|
}
|
||||||
|
|
||||||
|
// GVariant accepts JSON-style quoted strings. JSON.stringify escapes every
|
||||||
|
// quote, backslash, and control character, and the schema patterns further
|
||||||
|
// constrain stored font/theme names. Arguments still travel directly to
|
||||||
|
// gsettings rather than through a shell.
|
||||||
|
function gvariant(value: var): string {
|
||||||
|
if (typeof value === "boolean")
|
||||||
|
return value ? "true" : "false";
|
||||||
|
if (typeof value === "number")
|
||||||
|
return String(value);
|
||||||
|
return JSON.stringify(String(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function fontName(familyKey: string, sizeKey: string): string {
|
||||||
|
return `${DesktopPreferences.get(familyKey)} ${DesktopPreferences.get(sizeKey)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buttonLayout(): string {
|
||||||
|
const side = DesktopPreferences.get("titlebarButtonSide");
|
||||||
|
const maximize = DesktopPreferences.get("titlebarMaximizeButton") === true;
|
||||||
|
|
||||||
|
// Tokens are fixed. Only their side and whether maximize is present
|
||||||
|
// vary, so preference data can never become command syntax.
|
||||||
|
if (side === "left")
|
||||||
|
return (maximize ? "close,maximize" : "close") + ":appmenu";
|
||||||
|
return "appmenu:" + (maximize ? "maximize,close" : "close");
|
||||||
|
}
|
||||||
|
|
||||||
|
function setting(schema: string, key: string, value: var): var {
|
||||||
|
return ["gsettings", "set", schema, key, root.gvariant(value)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyAll(): void {
|
||||||
|
root.lastError = "";
|
||||||
|
root.enqueue([
|
||||||
|
root.setting("org.gnome.desktop.interface", "icon-theme", root.iconTheme),
|
||||||
|
root.setting("org.gnome.desktop.interface", "cursor-theme", root.cursorTheme),
|
||||||
|
root.setting("org.gnome.desktop.interface", "font-name",
|
||||||
|
root.fontName("applicationFont", "applicationFontSize")),
|
||||||
|
root.setting("org.gnome.desktop.interface", "document-font-name",
|
||||||
|
root.fontName("documentFont", "documentFontSize")),
|
||||||
|
root.setting("org.gnome.desktop.interface", "monospace-font-name",
|
||||||
|
root.fontName("monospaceFont", "monospaceFontSize")),
|
||||||
|
root.setting("org.gnome.desktop.interface", "font-hinting",
|
||||||
|
DesktopPreferences.get("fontHinting")),
|
||||||
|
root.setting("org.gnome.desktop.interface", "font-antialiasing",
|
||||||
|
DesktopPreferences.get("fontAntialiasing")),
|
||||||
|
root.setting("org.gnome.desktop.interface", "gtk-enable-primary-paste",
|
||||||
|
DesktopPreferences.get("middleClickPaste")),
|
||||||
|
root.setting("org.gnome.desktop.wm.preferences", "button-layout", root.buttonLayout()),
|
||||||
|
root.setting("org.gnome.desktop.wm.preferences", "action-double-click-titlebar",
|
||||||
|
DesktopPreferences.get("titlebarDoubleClick"))
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function storeCatalogChoice(key: string, value: string, allowed: var, kind: string): bool {
|
||||||
|
if (!root.catalogLoaded || allowed.indexOf(value) < 0) {
|
||||||
|
root.lastError = `That ${kind} theme is not installed.`;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!DesktopPreferences.set(key, value)) {
|
||||||
|
root.lastError = `That ${kind} theme name could not be saved.`;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
root.lastError = "";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCursorTheme(value: string): bool {
|
||||||
|
return root.storeCatalogChoice("cursorTheme", value, root.cursorThemeNames, "pointer");
|
||||||
|
}
|
||||||
|
|
||||||
|
function setIconTheme(value: string): bool {
|
||||||
|
return root.storeCatalogChoice("iconTheme", value, root.iconThemeNames, "icon");
|
||||||
|
}
|
||||||
|
|
||||||
|
function storeFont(key: string, family: string, allowed: var, kind: string): bool {
|
||||||
|
if (allowed.indexOf(family) < 0) {
|
||||||
|
root.lastError = `That ${kind} font is not installed.`;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!DesktopPreferences.set(key, family)) {
|
||||||
|
root.lastError = `That ${kind} font name could not be saved.`;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
root.lastError = "";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setApplicationFont(family: string): bool {
|
||||||
|
return root.storeFont("applicationFont", family, Fonts.interfaceFonts, "application");
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDocumentFont(family: string): bool {
|
||||||
|
return root.storeFont("documentFont", family, Fonts.interfaceFonts, "document");
|
||||||
|
}
|
||||||
|
|
||||||
|
function setMonospaceFont(family: string): bool {
|
||||||
|
return root.storeFont("monospaceFont", family, Fonts.monospaceFonts, "monospace");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
pragma Singleton
|
||||||
|
|
||||||
|
// Read-only device security facts: Secure Boot, TPM, disk encryption, SELinux,
|
||||||
|
// firewall.
|
||||||
|
//
|
||||||
|
// Nothing here is a preference. These are set in firmware, at install time, or
|
||||||
|
// by system policy, and a settings app that offered to change them from a
|
||||||
|
// switch would either fail or do something far-reaching from a control that
|
||||||
|
// looks like every other control. What this answers is "is this machine set up
|
||||||
|
// the way I think it is", which otherwise takes five commands and root.
|
||||||
|
//
|
||||||
|
// Read on demand. None of these can change while the desktop is running,
|
||||||
|
// short of a reboot.
|
||||||
|
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import QtQuick
|
||||||
|
|
||||||
|
Singleton {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-security"
|
||||||
|
|
||||||
|
// [{ label, value, ok, detail }]
|
||||||
|
property var facts: []
|
||||||
|
property bool scanned: false
|
||||||
|
|
||||||
|
// The facts that are not in their reassuring state. The page leads with the
|
||||||
|
// count so a machine that is entirely fine says so in one line instead of
|
||||||
|
// making the user read five rows to find out.
|
||||||
|
readonly property int attentionCount: root.facts.filter(fact => !fact.ok).length
|
||||||
|
|
||||||
|
function refresh(): void {
|
||||||
|
if (!query.running)
|
||||||
|
query.running = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: query
|
||||||
|
command: [root.helperPath]
|
||||||
|
stdout: StdioCollector {
|
||||||
|
onStreamFinished: {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(this.text);
|
||||||
|
root.facts = Array.isArray(parsed) ? parsed : [];
|
||||||
|
} catch (error) {
|
||||||
|
root.facts = [];
|
||||||
|
console.warn("DeviceSecurity: could not parse helper output:", error);
|
||||||
|
}
|
||||||
|
root.scanned = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,444 @@
|
|||||||
|
pragma Singleton
|
||||||
|
|
||||||
|
// The health helper is deliberately not a state owner. This singleton accepts
|
||||||
|
// complete, typed snapshots and keeps the last valid one available while a
|
||||||
|
// later scan or repair fails.
|
||||||
|
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import QtQuick
|
||||||
|
|
||||||
|
Singleton {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property var snapshot: ({})
|
||||||
|
property var checks: []
|
||||||
|
property var summary: ({ status: "healthy", healthy: 0, warnings: 0, errors: 0, unconfigured: 0 })
|
||||||
|
property string status: "healthy"
|
||||||
|
property bool diagnosticUnavailable: false
|
||||||
|
property bool queuedRefresh: false
|
||||||
|
property int generation: 0
|
||||||
|
property int acceptedGeneration: 0
|
||||||
|
property string lastError: ""
|
||||||
|
property string repairingId: ""
|
||||||
|
property var lastRepair: ({})
|
||||||
|
property string lastCopyResult: ""
|
||||||
|
property bool startupScanEnabled: true
|
||||||
|
property bool postRepairScanPending: false
|
||||||
|
|
||||||
|
readonly property bool actionable: root.status === "warning" || root.status === "error"
|
||||||
|
readonly property bool busy: scanProcess.running || repairProcess.running || root.postRepairScanPending
|
||||||
|
readonly property string helperPath: Quickshell.env("PANAMA_HEALTH_HELPER")
|
||||||
|
|| Quickshell.shellDir + "/scripts/panama-doctor"
|
||||||
|
readonly property var statuses: ["ok", "warning", "error", "unconfigured"]
|
||||||
|
readonly property var groups: ["desktop-foundation", "input-media", "integrations", "panama-tools"]
|
||||||
|
readonly property var overallStatuses: ["healthy", "warning", "error"]
|
||||||
|
readonly property var settingsTargets: ["home-phone", "datetime"]
|
||||||
|
readonly property var instructionTargets: ["ddc-permissions"]
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: scanProcess
|
||||||
|
|
||||||
|
property int scanGeneration: 0
|
||||||
|
property string outputText: ""
|
||||||
|
property int exitCode: -1
|
||||||
|
property bool exited: false
|
||||||
|
property bool streamFinished: false
|
||||||
|
property bool settled: false
|
||||||
|
|
||||||
|
stdout: StdioCollector {
|
||||||
|
id: scanOutput
|
||||||
|
property int generation: 0
|
||||||
|
onStreamFinished: {
|
||||||
|
scanProcess.outputText = this.text;
|
||||||
|
scanProcess.scanGeneration = generation;
|
||||||
|
scanProcess.streamFinished = true;
|
||||||
|
root.settleScan();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onExited: (exitCode, exitStatus) => {
|
||||||
|
scanProcess.exitCode = exitCode;
|
||||||
|
scanProcess.exited = true;
|
||||||
|
root.settleScan();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: repairProcess
|
||||||
|
|
||||||
|
property string checkId: ""
|
||||||
|
property bool external: false
|
||||||
|
property string outputText: ""
|
||||||
|
property int exitCode: -1
|
||||||
|
property bool exited: false
|
||||||
|
property bool streamFinished: false
|
||||||
|
property bool settled: false
|
||||||
|
|
||||||
|
stdout: StdioCollector {
|
||||||
|
onStreamFinished: {
|
||||||
|
repairProcess.outputText = this.text;
|
||||||
|
repairProcess.streamFinished = true;
|
||||||
|
root.settleRepair();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onExited: (exitCode, exitStatus) => {
|
||||||
|
repairProcess.exitCode = exitCode;
|
||||||
|
repairProcess.exited = true;
|
||||||
|
root.settleRepair();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: copyProcess
|
||||||
|
|
||||||
|
property string payload: ""
|
||||||
|
|
||||||
|
onStarted: copyProcess.write(copyProcess.payload)
|
||||||
|
onExited: (exitCode, exitStatus) => {
|
||||||
|
root.lastCopyResult = exitCode === 0
|
||||||
|
? "Report copied."
|
||||||
|
: "Could not copy the health report."
|
||||||
|
copyProcess.payload = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: failureNotification
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: startupScan
|
||||||
|
interval: 2200
|
||||||
|
repeat: false
|
||||||
|
running: root.startupScanEnabled
|
||||||
|
onTriggered: root.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
function refresh(): bool {
|
||||||
|
if (root.postRepairScanPending)
|
||||||
|
return false;
|
||||||
|
if (scanProcess.running || repairProcess.running) {
|
||||||
|
root.queuedRefresh = true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
root.generation += 1;
|
||||||
|
scanProcess.scanGeneration = root.generation;
|
||||||
|
scanProcess.outputText = "";
|
||||||
|
scanProcess.exitCode = -1;
|
||||||
|
scanProcess.exited = false;
|
||||||
|
scanProcess.streamFinished = false;
|
||||||
|
scanProcess.settled = false;
|
||||||
|
scanOutput.generation = root.generation;
|
||||||
|
scanProcess.exec([root.helperPath, "--json"]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function settleScan(): void {
|
||||||
|
if (scanProcess.settled || !scanProcess.exited || !scanProcess.streamFinished)
|
||||||
|
return;
|
||||||
|
scanProcess.settled = true;
|
||||||
|
root.finishScan(scanProcess.exitCode, scanProcess.scanGeneration, scanProcess.outputText);
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishScan(exitCode: int, scanGeneration: int, text: string): void {
|
||||||
|
if (exitCode === 0)
|
||||||
|
root.consumeSnapshot(text, scanGeneration);
|
||||||
|
else
|
||||||
|
root.rejectSnapshot("Panama diagnostics could not be read. Try refreshing.");
|
||||||
|
|
||||||
|
if (!root.queuedRefresh)
|
||||||
|
return;
|
||||||
|
root.queuedRefresh = false;
|
||||||
|
root.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
function consumeSnapshot(text: string, scanGeneration: int): bool {
|
||||||
|
if (scanGeneration < root.acceptedGeneration)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
let candidate;
|
||||||
|
try {
|
||||||
|
candidate = JSON.parse(text.trim());
|
||||||
|
} catch (error) {
|
||||||
|
root.rejectSnapshot("Panama diagnostics returned an unreadable response.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!root.validSnapshot(candidate)) {
|
||||||
|
root.rejectSnapshot("Panama diagnostics returned an invalid response.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const accepted = root.safeSnapshot(candidate);
|
||||||
|
root.snapshot = accepted;
|
||||||
|
root.checks = accepted.checks;
|
||||||
|
root.summary = accepted.summary;
|
||||||
|
root.status = accepted.summary.status;
|
||||||
|
root.acceptedGeneration = scanGeneration;
|
||||||
|
root.diagnosticUnavailable = false;
|
||||||
|
root.lastError = "";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rejectSnapshot(message: string): void {
|
||||||
|
root.diagnosticUnavailable = true;
|
||||||
|
root.lastError = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeSnapshot(candidate: var): var {
|
||||||
|
return {
|
||||||
|
schemaVersion: 1,
|
||||||
|
generatedAt: candidate.generatedAt,
|
||||||
|
summary: {
|
||||||
|
status: candidate.summary.status,
|
||||||
|
healthy: candidate.summary.healthy,
|
||||||
|
warnings: candidate.summary.warnings,
|
||||||
|
errors: candidate.summary.errors,
|
||||||
|
unconfigured: candidate.summary.unconfigured
|
||||||
|
},
|
||||||
|
context: {
|
||||||
|
session: candidate.context.session,
|
||||||
|
versions: candidate.context.versions.map(version => ({
|
||||||
|
id: version.id,
|
||||||
|
version: version.version
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
checks: candidate.checks.map(check => root.safeCheck(check))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeCheck(candidate: var): var {
|
||||||
|
const check = {
|
||||||
|
id: candidate.id,
|
||||||
|
group: candidate.group,
|
||||||
|
title: candidate.title,
|
||||||
|
status: candidate.status,
|
||||||
|
detail: candidate.detail
|
||||||
|
};
|
||||||
|
if (candidate.action !== undefined)
|
||||||
|
check.action = root.safeAction(candidate.action);
|
||||||
|
return check;
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeAction(candidate: var): var {
|
||||||
|
const action = {
|
||||||
|
kind: candidate.kind,
|
||||||
|
label: candidate.label,
|
||||||
|
confirm: candidate.confirm
|
||||||
|
};
|
||||||
|
if (Object.prototype.hasOwnProperty.call(candidate, "target"))
|
||||||
|
action.target = candidate.target;
|
||||||
|
return action;
|
||||||
|
}
|
||||||
|
|
||||||
|
function repair(id: string, external: bool): bool {
|
||||||
|
if (root.busy)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
const check = root.checks.find(candidate => candidate.id === id);
|
||||||
|
if (!check || !check.action || check.action.kind !== "repair")
|
||||||
|
return false;
|
||||||
|
if (external && check.action.confirm)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
root.repairingId = id;
|
||||||
|
root.lastError = "";
|
||||||
|
repairProcess.checkId = id;
|
||||||
|
repairProcess.external = external;
|
||||||
|
repairProcess.outputText = "";
|
||||||
|
repairProcess.exitCode = -1;
|
||||||
|
repairProcess.exited = false;
|
||||||
|
repairProcess.streamFinished = false;
|
||||||
|
repairProcess.settled = false;
|
||||||
|
repairProcess.exec([root.helperPath, "--repair", id, "--json"]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function settleRepair(): void {
|
||||||
|
if (repairProcess.settled || !repairProcess.exited || !repairProcess.streamFinished)
|
||||||
|
return;
|
||||||
|
repairProcess.settled = true;
|
||||||
|
root.finishRepair(
|
||||||
|
repairProcess.exitCode,
|
||||||
|
repairProcess.checkId,
|
||||||
|
repairProcess.external,
|
||||||
|
repairProcess.outputText
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishRepair(exitCode: int, id: string, external: bool, text: string): void {
|
||||||
|
let candidate;
|
||||||
|
try {
|
||||||
|
candidate = JSON.parse(text.trim());
|
||||||
|
} catch (error) {
|
||||||
|
candidate = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = root.validRepairResult(candidate, id, exitCode)
|
||||||
|
? {
|
||||||
|
schemaVersion: 1,
|
||||||
|
checkId: candidate.checkId,
|
||||||
|
accepted: candidate.accepted,
|
||||||
|
exitCode: candidate.exitCode,
|
||||||
|
message: candidate.message
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
schemaVersion: 1,
|
||||||
|
checkId: id,
|
||||||
|
accepted: false,
|
||||||
|
exitCode: exitCode,
|
||||||
|
message: "Panama returned an invalid repair response."
|
||||||
|
};
|
||||||
|
const failed = !result.accepted || result.exitCode !== 0;
|
||||||
|
root.repairingId = "";
|
||||||
|
root.lastRepair = result;
|
||||||
|
root.lastError = failed ? result.message : "";
|
||||||
|
if (failed && external && !failureNotification.running) {
|
||||||
|
failureNotification.exec([
|
||||||
|
"notify-send", "-a", "Panama", "-i", "dialog-error-symbolic",
|
||||||
|
"Panama action failed", "The requested health repair could not be completed."
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Any refresh requested while the repair was running is satisfied by
|
||||||
|
// this one observed post-repair scan. Process exit alone never changes
|
||||||
|
// the accepted check rows.
|
||||||
|
root.queuedRefresh = false;
|
||||||
|
root.postRepairScanPending = true;
|
||||||
|
Qt.callLater(root.startPostRepairScan);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startPostRepairScan(): void {
|
||||||
|
root.postRepairScanPending = false;
|
||||||
|
root.queuedRefresh = false;
|
||||||
|
root.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
function validRepairResult(candidate: var, id: string, processExitCode: int): bool {
|
||||||
|
if (!root.plainObject(candidate))
|
||||||
|
return false;
|
||||||
|
const keys = Object.keys(candidate).sort();
|
||||||
|
const expectedKeys = ["accepted", "checkId", "exitCode", "message", "schemaVersion"];
|
||||||
|
if (keys.length !== expectedKeys.length
|
||||||
|
|| !keys.every((key, index) => key === expectedKeys[index]))
|
||||||
|
return false;
|
||||||
|
return candidate.schemaVersion === 1
|
||||||
|
&& candidate.checkId === id
|
||||||
|
&& typeof candidate.accepted === "boolean"
|
||||||
|
&& Number.isInteger(candidate.exitCode)
|
||||||
|
&& candidate.exitCode === processExitCode
|
||||||
|
&& typeof candidate.message === "string"
|
||||||
|
&& candidate.message.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyReport(): bool {
|
||||||
|
if (copyProcess.running)
|
||||||
|
return false;
|
||||||
|
copyProcess.payload = JSON.stringify(root.snapshot, null, 2);
|
||||||
|
root.lastCopyResult = "";
|
||||||
|
copyProcess.exec(["wl-copy"]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function diagnostics(): var {
|
||||||
|
return {
|
||||||
|
status: root.status,
|
||||||
|
summary: root.summary,
|
||||||
|
busy: root.busy,
|
||||||
|
diagnosticUnavailable: root.diagnosticUnavailable,
|
||||||
|
queuedRefresh: root.queuedRefresh,
|
||||||
|
generation: root.generation,
|
||||||
|
acceptedGeneration: root.acceptedGeneration,
|
||||||
|
repairingId: root.repairingId,
|
||||||
|
lastRepair: root.lastRepair,
|
||||||
|
lastError: root.lastError,
|
||||||
|
checks: root.checks.map(check => check.id),
|
||||||
|
checkStates: root.checks.map(check => ({ id: check.id, status: check.status }))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function validSnapshot(candidate: var): bool {
|
||||||
|
if (!root.plainObject(candidate)
|
||||||
|
|| candidate.schemaVersion !== 1
|
||||||
|
|| typeof candidate.generatedAt !== "string" || candidate.generatedAt.length === 0
|
||||||
|
|| !root.validSummary(candidate.summary)
|
||||||
|
|| !root.validContext(candidate.context)
|
||||||
|
|| !Array.isArray(candidate.checks) || candidate.checks.length === 0)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
const ids = {};
|
||||||
|
const counts = { ok: 0, warning: 0, error: 0, unconfigured: 0 };
|
||||||
|
for (const check of candidate.checks) {
|
||||||
|
if (!root.validCheck(check) || ids[check.id])
|
||||||
|
return false;
|
||||||
|
ids[check.id] = true;
|
||||||
|
counts[check.status] += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const computedStatus = counts.error > 0 ? "error" : counts.warning > 0 ? "warning" : "healthy";
|
||||||
|
return candidate.summary.healthy === counts.ok
|
||||||
|
&& candidate.summary.warnings === counts.warning
|
||||||
|
&& candidate.summary.errors === counts.error
|
||||||
|
&& candidate.summary.unconfigured === counts.unconfigured
|
||||||
|
&& candidate.summary.status === computedStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validSummary(candidate: var): bool {
|
||||||
|
if (!root.plainObject(candidate) || root.overallStatuses.indexOf(candidate.status) < 0)
|
||||||
|
return false;
|
||||||
|
for (const key of ["healthy", "warnings", "errors", "unconfigured"]) {
|
||||||
|
if (!Number.isInteger(candidate[key]) || candidate[key] < 0)
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validContext(candidate: var): bool {
|
||||||
|
if (!root.plainObject(candidate)
|
||||||
|
|| ["hyprland", "other"].indexOf(candidate.session) < 0
|
||||||
|
|| !Array.isArray(candidate.versions))
|
||||||
|
return false;
|
||||||
|
return candidate.versions.every(version => root.plainObject(version)
|
||||||
|
&& typeof version.id === "string" && version.id.length > 0
|
||||||
|
&& typeof version.version === "string" && version.version.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validCheck(candidate: var): bool {
|
||||||
|
if (!root.plainObject(candidate)
|
||||||
|
|| !/^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)+$/.test(candidate.id)
|
||||||
|
|| root.groups.indexOf(candidate.group) < 0
|
||||||
|
|| root.statuses.indexOf(candidate.status) < 0
|
||||||
|
|| typeof candidate.title !== "string" || candidate.title.length === 0
|
||||||
|
|| typeof candidate.detail !== "string" || candidate.detail.length === 0)
|
||||||
|
return false;
|
||||||
|
return candidate.action === undefined || root.validAction(candidate.action);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validAction(candidate: var): bool {
|
||||||
|
if (!root.plainObject(candidate)
|
||||||
|
|| ["repair", "open", "instructions"].indexOf(candidate.kind) < 0
|
||||||
|
|| typeof candidate.label !== "string" || candidate.label.length === 0
|
||||||
|
|| typeof candidate.confirm !== "boolean")
|
||||||
|
return false;
|
||||||
|
|
||||||
|
const allowedKeys = ["kind", "label", "confirm", "target"];
|
||||||
|
if (!Object.keys(candidate).every(key => allowedKeys.indexOf(key) >= 0))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
const hasTarget = Object.prototype.hasOwnProperty.call(candidate, "target");
|
||||||
|
if (!hasTarget)
|
||||||
|
return true;
|
||||||
|
if (typeof candidate.target !== "string" || candidate.target.length === 0 || candidate.kind === "repair")
|
||||||
|
return false;
|
||||||
|
return candidate.kind === "open"
|
||||||
|
? root.settingsTargets.indexOf(candidate.target) >= 0
|
||||||
|
: root.instructionTargets.indexOf(candidate.target) >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function plainObject(value: var): bool {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
pragma Singleton
|
||||||
|
|
||||||
|
// What input hardware this machine actually has.
|
||||||
|
//
|
||||||
|
// Exists so pages can hide controls for hardware that is not present. A
|
||||||
|
// touchpad card on a desktop is not merely useless -- it is misleading, because
|
||||||
|
// every switch on it appears to work: the preference is stored and Hyprland
|
||||||
|
// accepts the option for a device class it has no member of. The user is left
|
||||||
|
// toggling settings that will never affect anything, with nothing to say so.
|
||||||
|
//
|
||||||
|
// Touchpads are identified by name. libinput exposes them through the same
|
||||||
|
// "mice" list as everything else that reports pointer motion, and Hyprland
|
||||||
|
// passes the device name through, so an Elan or Synaptics touchpad arrives as
|
||||||
|
// something like "elan-touchpad". There is no device-class field to consult.
|
||||||
|
//
|
||||||
|
// Read on demand rather than polled. Input devices do come and go -- a mouse is
|
||||||
|
// unplugged, a receiver is moved -- so this also refreshes when Hyprland says
|
||||||
|
// the device list changed, which is the only moment the answer can differ.
|
||||||
|
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import Quickshell.Hyprland
|
||||||
|
import QtQuick
|
||||||
|
|
||||||
|
Singleton {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property var mice: []
|
||||||
|
property var keyboards: []
|
||||||
|
|
||||||
|
// True when anything that looks like a touchpad is attached.
|
||||||
|
readonly property bool hasTouchpad: root.mice.some(name =>
|
||||||
|
name.includes("touchpad") || name.includes("trackpad"))
|
||||||
|
|
||||||
|
readonly property bool hasMouse: root.mice.length > 0
|
||||||
|
|
||||||
|
function refresh(): void {
|
||||||
|
if (!query.running)
|
||||||
|
query.running = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: query
|
||||||
|
running: true
|
||||||
|
command: ["hyprctl", "-j", "devices"]
|
||||||
|
stdout: StdioCollector {
|
||||||
|
onStreamFinished: {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(this.text);
|
||||||
|
root.mice = (parsed.mice ?? []).map(device => String(device.name ?? "").toLowerCase());
|
||||||
|
root.keyboards = (parsed.keyboards ?? []).map(device => String(device.name ?? "").toLowerCase());
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("InputDevices: could not parse hyprctl devices:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: Hyprland
|
||||||
|
function onRawEvent(event: var): void {
|
||||||
|
if (event.name === "device" || event.name === "configreloaded")
|
||||||
|
root.refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
pragma Singleton
|
||||||
|
|
||||||
|
// The login keyring's lock state.
|
||||||
|
//
|
||||||
|
// The keyring is unlocked at sign-in by pam_gnome_keyring, exactly as it is
|
||||||
|
// under GNOME. What a bare Hyprland session lacks is anywhere to see when that
|
||||||
|
// has stopped being true.
|
||||||
|
//
|
||||||
|
// It stops being true rarely but expensively: gnome-keyring-daemon can crash,
|
||||||
|
// D-Bus activates a replacement, and the replacement never received the login
|
||||||
|
// password -- so the keyring is locked in the middle of a session that unlocked
|
||||||
|
// it correctly. Nothing announces this. What the user sees instead is a mail
|
||||||
|
// account that will not authenticate, a git push that cannot find its key, or
|
||||||
|
// an integration reporting "not configured", none of which mention keyrings.
|
||||||
|
//
|
||||||
|
// Checked on demand and after an unlock, not polled: the state changes only
|
||||||
|
// when a daemon dies or a password is entered.
|
||||||
|
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import QtQuick
|
||||||
|
|
||||||
|
Singleton {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-keyring"
|
||||||
|
|
||||||
|
property bool available: false
|
||||||
|
property bool locked: false
|
||||||
|
property bool scanned: false
|
||||||
|
property bool unlocking: false
|
||||||
|
property string lastError: ""
|
||||||
|
|
||||||
|
// "pam" when the daemon that holds the keyring is the one PAM started at
|
||||||
|
// login, "dbus" when it is a D-Bus-activated replacement -- which is the
|
||||||
|
// signature of the crash case, and worth showing, because a dbus daemon
|
||||||
|
// that is currently unlocked was unlocked by hand and will not survive.
|
||||||
|
property string daemon: ""
|
||||||
|
|
||||||
|
readonly property bool replacementDaemon: root.daemon === "dbus"
|
||||||
|
|
||||||
|
function refresh(): void {
|
||||||
|
if (!query.running)
|
||||||
|
query.running = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Raises the standard password dialog. The password never passes through
|
||||||
|
// Panama -- the Secret Service prompts, the same way it does under GNOME.
|
||||||
|
function unlock(): void {
|
||||||
|
if (root.unlocking)
|
||||||
|
return;
|
||||||
|
root.unlocking = true;
|
||||||
|
unlockProcess.running = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function absorb(text: string): void {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(text);
|
||||||
|
root.available = parsed.available === true;
|
||||||
|
root.locked = parsed.locked === true;
|
||||||
|
root.daemon = String(parsed.daemon ?? "");
|
||||||
|
root.lastError = String(parsed.error ?? "");
|
||||||
|
} catch (error) {
|
||||||
|
root.available = false;
|
||||||
|
root.lastError = "Could not read the keyring helper's output.";
|
||||||
|
console.warn("Keyring: could not parse helper output:", error);
|
||||||
|
}
|
||||||
|
root.scanned = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: query
|
||||||
|
command: [root.helperPath, "status"]
|
||||||
|
stdout: StdioCollector { onStreamFinished: root.absorb(this.text) }
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: unlockProcess
|
||||||
|
command: [root.helperPath, "unlock"]
|
||||||
|
stdout: StdioCollector { onStreamFinished: root.absorb(this.text) }
|
||||||
|
onExited: root.unlocking = false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
pragma Singleton
|
||||||
|
|
||||||
|
// What this machine is: model, processor, memory, disk, OS, kernel.
|
||||||
|
//
|
||||||
|
// Read once, on demand. None of it changes while the desktop is running except
|
||||||
|
// free disk space, and About is not a monitor -- the vitals readout on the Home
|
||||||
|
// page is where live figures belong.
|
||||||
|
//
|
||||||
|
// Graphics is not here. GraphicsDevices already enumerates GPUs for the vitals
|
||||||
|
// readout, and naming them again would be a second source of truth that could
|
||||||
|
// disagree with the first; the About page joins the two instead.
|
||||||
|
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import QtQuick
|
||||||
|
|
||||||
|
Singleton {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-about"
|
||||||
|
|
||||||
|
// [{ label, value }] in display order.
|
||||||
|
property var facts: []
|
||||||
|
property bool scanned: false
|
||||||
|
|
||||||
|
function refresh(): void {
|
||||||
|
if (!query.running)
|
||||||
|
query.running = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: query
|
||||||
|
command: [root.helperPath]
|
||||||
|
stdout: StdioCollector {
|
||||||
|
onStreamFinished: {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(this.text);
|
||||||
|
root.facts = Array.isArray(parsed) ? parsed : [];
|
||||||
|
} catch (error) {
|
||||||
|
root.facts = [];
|
||||||
|
console.warn("MachineInfo: could not parse helper output:", error);
|
||||||
|
}
|
||||||
|
root.scanned = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
pragma Singleton
|
||||||
|
|
||||||
|
// Online accounts, via GNOME Online Accounts.
|
||||||
|
//
|
||||||
|
// The daemon already runs in this session -- gvfs activates it, and accounts
|
||||||
|
// work without gnome-shell anywhere. Only the panel was GNOME's; the accounts
|
||||||
|
// are D-Bus objects anything may read and modify. So listing, per-service
|
||||||
|
// toggles, and removal all happen here, natively.
|
||||||
|
//
|
||||||
|
// Signing in is the exception, and only for OAuth providers. The daemon's
|
||||||
|
// AddAccount takes credentials as an argument rather than obtaining them, and
|
||||||
|
// the code that runs Google's OAuth exchange lives in libgoa-backend, which
|
||||||
|
// Fedora ships without a GIR binding. So that one step is handed to GNOME's
|
||||||
|
// panel and the user comes straight back here.
|
||||||
|
//
|
||||||
|
// Read on demand and after every change: accounts are added and removed by
|
||||||
|
// people, not by the system, so there is nothing to poll for.
|
||||||
|
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import QtQuick
|
||||||
|
|
||||||
|
Singleton {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-accounts"
|
||||||
|
|
||||||
|
// [{ path, provider, providerName, identity, needsAttention, services: [{key,label,enabled}] }]
|
||||||
|
property var accounts: []
|
||||||
|
property bool scanned: false
|
||||||
|
property bool busy: false
|
||||||
|
property string lastError: ""
|
||||||
|
|
||||||
|
// Accounts whose stored credentials have stopped working -- an expired
|
||||||
|
// token, a changed password. GOA knows, and nothing outside its own panel
|
||||||
|
// ever says so, which is how an account quietly stops syncing for weeks.
|
||||||
|
readonly property int attentionCount: root.accounts.filter(a => a.needsAttention).length
|
||||||
|
|
||||||
|
readonly property bool available: root.lastError === ""
|
||||||
|
|
||||||
|
function refresh(): void {
|
||||||
|
if (!list.running)
|
||||||
|
list.running = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enabling a service clears GOA's "disabled" flag; the helper owns that
|
||||||
|
// inversion so the UI can speak in terms of what is on.
|
||||||
|
function setService(path: string, service: string, enabled: bool): void {
|
||||||
|
if (root.busy)
|
||||||
|
return;
|
||||||
|
root.busy = true;
|
||||||
|
write.command = [root.helperPath, "set", path, service, enabled ? "true" : "false"];
|
||||||
|
write.running = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(path: string): void {
|
||||||
|
if (root.busy)
|
||||||
|
return;
|
||||||
|
root.busy = true;
|
||||||
|
write.command = [root.helperPath, "remove", path];
|
||||||
|
write.running = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: list
|
||||||
|
command: [root.helperPath, "list"]
|
||||||
|
stdout: StdioCollector {
|
||||||
|
onStreamFinished: {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(this.text);
|
||||||
|
root.accounts = Array.isArray(parsed.accounts) ? parsed.accounts : [];
|
||||||
|
root.lastError = String(parsed.error ?? "");
|
||||||
|
} catch (error) {
|
||||||
|
root.accounts = [];
|
||||||
|
root.lastError = "Could not read the accounts helper's output.";
|
||||||
|
console.warn("OnlineAccounts: could not parse helper output:", error);
|
||||||
|
}
|
||||||
|
root.scanned = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: write
|
||||||
|
stderr: StdioCollector {
|
||||||
|
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
|
||||||
|
}
|
||||||
|
// Re-read rather than assuming the write landed: GOA may refuse, and a
|
||||||
|
// toggle that sprang back is the honest outcome.
|
||||||
|
onExited: {
|
||||||
|
root.busy = false;
|
||||||
|
root.refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
pragma Singleton
|
||||||
|
|
||||||
|
// The system power profile: power-saver, balanced, or performance.
|
||||||
|
//
|
||||||
|
// The same daemon GNOME's Power panel drives. On Fedora 44 the implementation
|
||||||
|
// is tuned-ppd rather than power-profiles-daemon, but it serves the same
|
||||||
|
// net.hadess.PowerProfiles interface -- so this talks to the interface, not to
|
||||||
|
// either binary. powerprofilesctl is not installed here at all.
|
||||||
|
//
|
||||||
|
// Not a stored preference. The profile lives in the daemon, survives Panama
|
||||||
|
// restarts, and can be changed by anything else on the system; keeping a copy
|
||||||
|
// in settings.json would mean restoring a value the daemon had moved past.
|
||||||
|
// Same reasoning as monitor brightness.
|
||||||
|
//
|
||||||
|
// Read on demand and after each change. The daemon does emit PropertiesChanged,
|
||||||
|
// but subscribing to it would mean holding a bus connection open for a value
|
||||||
|
// that changes only when someone chooses it.
|
||||||
|
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import QtQuick
|
||||||
|
|
||||||
|
Singleton {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-power-profile"
|
||||||
|
|
||||||
|
property var profiles: []
|
||||||
|
property string active: ""
|
||||||
|
property bool scanned: false
|
||||||
|
property bool busy: false
|
||||||
|
property string lastError: ""
|
||||||
|
|
||||||
|
// Non-empty when the machine cannot actually deliver the profile it is set
|
||||||
|
// to -- thermal throttling, or a laptop running on battery. Worth showing,
|
||||||
|
// because otherwise "performance" is a claim the hardware is not honouring.
|
||||||
|
property string degraded: ""
|
||||||
|
|
||||||
|
readonly property bool available: root.profiles.length > 0
|
||||||
|
|
||||||
|
// Presentation lives here rather than in the page so the Control Center and
|
||||||
|
// Settings cannot disagree about what a profile is called.
|
||||||
|
function label(profile: string): string {
|
||||||
|
switch (profile) {
|
||||||
|
case "power-saver": return "Power Saver";
|
||||||
|
case "balanced": return "Balanced";
|
||||||
|
case "performance": return "Performance";
|
||||||
|
default: return profile;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function detail(profile: string): string {
|
||||||
|
switch (profile) {
|
||||||
|
case "power-saver": return "Reduces performance to save energy and run quieter";
|
||||||
|
case "balanced": return "Standard behaviour, scaling up only when needed";
|
||||||
|
case "performance": return "Holds higher clocks, using more power and making more noise";
|
||||||
|
default: return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function refresh(): void {
|
||||||
|
if (!query.running)
|
||||||
|
query.running = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function set(profile: string): void {
|
||||||
|
if (root.busy || profile === root.active)
|
||||||
|
return;
|
||||||
|
root.busy = true;
|
||||||
|
apply.command = [root.helperPath, "set", profile];
|
||||||
|
apply.running = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: query
|
||||||
|
command: [root.helperPath, "list"]
|
||||||
|
stdout: StdioCollector {
|
||||||
|
onStreamFinished: {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(this.text);
|
||||||
|
root.profiles = Array.isArray(parsed.profiles) ? parsed.profiles : [];
|
||||||
|
root.active = String(parsed.active ?? "");
|
||||||
|
root.degraded = String(parsed.degraded ?? "");
|
||||||
|
root.lastError = String(parsed.error ?? "");
|
||||||
|
} catch (error) {
|
||||||
|
root.profiles = [];
|
||||||
|
root.lastError = "Could not read the power profile helper's output.";
|
||||||
|
console.warn("PowerProfiles: could not parse helper output:", error);
|
||||||
|
}
|
||||||
|
root.scanned = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: apply
|
||||||
|
stderr: StdioCollector {
|
||||||
|
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
|
||||||
|
}
|
||||||
|
// Re-read rather than assuming: the daemon may refuse, or may land on a
|
||||||
|
// different profile than the one asked for.
|
||||||
|
onExited: {
|
||||||
|
root.busy = false;
|
||||||
|
root.refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,12 +17,20 @@ import qs.config
|
|||||||
Singleton {
|
Singleton {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
|
// SettingsSearch is part of the always-constructed sidebar. Touching the
|
||||||
|
// desktop-style service here gives application preferences their startup
|
||||||
|
// replay even when Appearance is not the page that opens first.
|
||||||
|
Component.onCompleted: DesktopStyle.ensureStarted()
|
||||||
|
|
||||||
// Which page shows the settings in a given schema group. A group with no
|
// Which page shows the settings in a given schema group. A group with no
|
||||||
// entry here still appears in results and routes to Home rather than being
|
// entry here still appears in results and routes to Home rather than being
|
||||||
// dropped, so adding a group can never make a setting unreachable.
|
// dropped, so adding a group can never make a setting unreachable.
|
||||||
readonly property var groupPages: ({
|
readonly property var groupPages: ({
|
||||||
"clock": "appearance",
|
"clock": "appearance",
|
||||||
"vitals": "appearance",
|
"vitals": "appearance",
|
||||||
|
"typography": "appearance",
|
||||||
|
"themes": "appearance",
|
||||||
|
"titlebar": "appearance",
|
||||||
"windows": "appearance",
|
"windows": "appearance",
|
||||||
"effects": "appearance",
|
"effects": "appearance",
|
||||||
"wallpaper": "appearance",
|
"wallpaper": "appearance",
|
||||||
@@ -33,6 +41,12 @@ Singleton {
|
|||||||
"idle": "power",
|
"idle": "power",
|
||||||
"accessibility": "accessibility",
|
"accessibility": "accessibility",
|
||||||
"input": "shortcuts",
|
"input": "shortcuts",
|
||||||
|
"pointer": "mouse",
|
||||||
|
"touchpad": "mouse",
|
||||||
|
"multitasking": "desktop",
|
||||||
|
"edges": "desktop",
|
||||||
|
"master": "desktop",
|
||||||
|
"notices": "desktop",
|
||||||
"weather": "appearance",
|
"weather": "appearance",
|
||||||
"notifications": "notifications",
|
"notifications": "notifications",
|
||||||
"capture": "screen-intelligence"
|
"capture": "screen-intelligence"
|
||||||
@@ -49,7 +63,9 @@ Singleton {
|
|||||||
{ label: "Printers", detail: "Managed by GNOME Settings", page: "connectivity" },
|
{ label: "Printers", detail: "Managed by GNOME Settings", page: "connectivity" },
|
||||||
{ label: "Default applications", detail: "Browser, mail, files", page: "applications" },
|
{ label: "Default applications", detail: "Browser, mail, files", page: "applications" },
|
||||||
{ label: "Restore defaults", detail: "Return every Panama setting to its shipped value", page: "desktop" },
|
{ label: "Restore defaults", detail: "Return every Panama setting to its shipped value", page: "desktop" },
|
||||||
{ label: "Keyboard shortcuts", detail: "Every shortcut the compositor has bound", page: "shortcuts" }
|
{ label: "Keyboard shortcuts", detail: "Every shortcut the compositor has bound", page: "shortcuts" },
|
||||||
|
{ label: "System Health", detail: "Check Panama services, integrations, tools, and recovery actions", page: "services" },
|
||||||
|
{ label: "Copy health report", detail: "Copy a redacted Panama doctor report", page: "services" }
|
||||||
]
|
]
|
||||||
|
|
||||||
function pageFor(group: string): string {
|
function pageFor(group: string): string {
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ Singleton {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openSettings(page: string): void {
|
function openSettings(page: string): void {
|
||||||
const allowed = ["home", "appearance", "displays", "connectivity", "home-phone", "desktop", "sound", "notifications", "screen-intelligence", "shortcuts", "accessibility", "power", "datetime", "applications", "services", "about"];
|
const allowed = ["home", "appearance", "displays", "connectivity", "home-phone", "desktop", "sound", "notifications", "screen-intelligence", "shortcuts", "mouse", "privacy", "region", "accounts", "accessibility", "power", "datetime", "applications", "services", "about"];
|
||||||
root.settingsPage = allowed.indexOf(page) >= 0 ? page : "home";
|
root.settingsPage = allowed.indexOf(page) >= 0 ? page : "home";
|
||||||
DesktopPreferences.set("lastPage", root.settingsPage);
|
DesktopPreferences.set("lastPage", root.settingsPage);
|
||||||
root.settingsOpen = true;
|
root.settingsOpen = true;
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
pragma Singleton
|
||||||
|
|
||||||
|
// The system locale.
|
||||||
|
//
|
||||||
|
// Named SystemLocale, not Locale: QML has a built-in Locale value type, and a
|
||||||
|
// singleton of that name is silently shadowed by it. Every binding then reads
|
||||||
|
// properties off the wrong thing and the page renders empty with only
|
||||||
|
// "Cannot read property of undefined" to show for it.
|
||||||
|
//
|
||||||
|
// Changing it is privileged: localectl goes through polkit, which prompts
|
||||||
|
// (hyprpolkitagent serves that in this session). It also only applies to
|
||||||
|
// programs started afterwards, so `pendingRestart` goes true once a change is
|
||||||
|
// accepted and the page says a sign-out is needed. Reporting the new locale as
|
||||||
|
// simply "in effect" would be wrong -- almost nothing on screen would be using
|
||||||
|
// it yet.
|
||||||
|
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import QtQuick
|
||||||
|
|
||||||
|
Singleton {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-locale"
|
||||||
|
|
||||||
|
// [{ value, label, detail }]
|
||||||
|
property var locales: []
|
||||||
|
property string current: ""
|
||||||
|
property bool scanning: false
|
||||||
|
property string lastError: ""
|
||||||
|
|
||||||
|
// True once a change has been accepted but the session has not restarted,
|
||||||
|
// so the UI can stop claiming the new locale is already in use.
|
||||||
|
property bool pendingRestart: false
|
||||||
|
|
||||||
|
readonly property string currentLabel: {
|
||||||
|
const match = root.locales.find(locale => locale.value === root.current);
|
||||||
|
return match ? match.label : root.current;
|
||||||
|
}
|
||||||
|
|
||||||
|
function refresh(): void {
|
||||||
|
if (root.scanning)
|
||||||
|
return;
|
||||||
|
root.scanning = true;
|
||||||
|
readCurrent.running = true;
|
||||||
|
list.running = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function set(value: string): void {
|
||||||
|
if (value === root.current)
|
||||||
|
return;
|
||||||
|
apply.command = [root.helperPath, "set", value];
|
||||||
|
apply.pendingValue = value;
|
||||||
|
apply.running = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: list
|
||||||
|
command: [root.helperPath, "list"]
|
||||||
|
stdout: StdioCollector {
|
||||||
|
onStreamFinished: {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(this.text);
|
||||||
|
root.locales = Array.isArray(parsed) ? parsed : [];
|
||||||
|
} catch (error) {
|
||||||
|
root.locales = [];
|
||||||
|
console.warn("SystemLocale: could not parse the locale list:", error);
|
||||||
|
}
|
||||||
|
root.scanning = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: readCurrent
|
||||||
|
command: [root.helperPath, "get"]
|
||||||
|
stdout: StdioCollector {
|
||||||
|
onStreamFinished: root.current = this.text.trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: apply
|
||||||
|
|
||||||
|
property string pendingValue: ""
|
||||||
|
|
||||||
|
// A refused change -- polkit dismissed, or an unknown locale -- must not
|
||||||
|
// move the UI. The value is only adopted on a zero exit.
|
||||||
|
onExited: code => {
|
||||||
|
if (code === 0) {
|
||||||
|
root.current = apply.pendingValue;
|
||||||
|
root.pendingRestart = true;
|
||||||
|
root.lastError = "";
|
||||||
|
} else {
|
||||||
|
root.lastError = "The system did not accept that language. It may have needed a password.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -273,6 +273,15 @@ Singleton {
|
|||||||
// decides, and config/dot/hypr/prefs.lua does the same conversion via
|
// decides, and config/dot/hypr/prefs.lua does the same conversion via
|
||||||
// prefs.getInt so both sides agree.
|
// prefs.getInt so both sides agree.
|
||||||
function hyprValue(entry: var, value: var): var {
|
function hyprValue(entry: var, value: var): var {
|
||||||
|
// Some options are phrased as a negative by the compositor -- the four
|
||||||
|
// Hyprland notices are all `disable_x` -- while the setting reads as
|
||||||
|
// "show x", because a switch labelled "Disable splash text" that must
|
||||||
|
// be ON to hide something is a small cruelty. `invert` bridges the two,
|
||||||
|
// in exactly one place, so nothing downstream has to remember which
|
||||||
|
// options are backwards.
|
||||||
|
if (entry.hypr.invert === true && typeof value === "boolean")
|
||||||
|
value = !value;
|
||||||
|
|
||||||
if (typeof value === "boolean" && entry.hypr.readAs !== "bool")
|
if (typeof value === "boolean" && entry.hypr.readAs !== "bool")
|
||||||
return value ? 1 : 0;
|
return value ? 1 : 0;
|
||||||
return value;
|
return value;
|
||||||
@@ -300,6 +309,24 @@ Singleton {
|
|||||||
return value ? "true" : "false";
|
return value ? "true" : "false";
|
||||||
if (typeof value === "number")
|
if (typeof value === "number")
|
||||||
return String(value);
|
return String(value);
|
||||||
|
|
||||||
|
// A gradient is the one setting whose Lua form is not a scalar. The
|
||||||
|
// stubs declare it as `string|{colors:string[], angle?:number}`, and
|
||||||
|
// the string form only ever carries ONE stop -- writing
|
||||||
|
// "rgba(a) rgba(b) 45deg" as a string is accepted and silently keeps
|
||||||
|
// the previous value, which is how a two-stop write looks like it
|
||||||
|
// worked and did nothing. Multi-stop must be the table form.
|
||||||
|
if (value && typeof value === "object" && Array.isArray(value.colors)) {
|
||||||
|
const stops = value.colors
|
||||||
|
.map(stop => `"${String(stop).replace(/["\\]/g, "")}"`)
|
||||||
|
.join(", ");
|
||||||
|
const angle = Number(value.angle);
|
||||||
|
return `{ colors = { ${stops} }` + (isFinite(angle) ? `, angle = ${angle} }` : ` }`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A vec2 reaches Lua as a two-element table.
|
||||||
|
if (Array.isArray(value) && value.length === 2)
|
||||||
|
return `{ ${Number(value[0])}, ${Number(value[1])} }`;
|
||||||
// Strings only reach here after the schema's pattern check; quoting is
|
// Strings only reach here after the schema's pattern check; quoting is
|
||||||
// belt-and-braces rather than the primary defence.
|
// belt-and-braces rather than the primary defence.
|
||||||
return `"${String(value).replace(/["\\]/g, "")}"`;
|
return `"${String(value).replace(/["\\]/g, "")}"`;
|
||||||
@@ -309,7 +336,15 @@ Singleton {
|
|||||||
const parts = [];
|
const parts = [];
|
||||||
for (const name in node) {
|
for (const name in node) {
|
||||||
const child = node[name];
|
const child = node[name];
|
||||||
parts.push(`${name} = ${typeof child === "string" ? child : root.serialiseTable(child)}`);
|
// A leaf arrives pre-serialised as a string; anything else is
|
||||||
|
// either a nested section or a structured value (gradient, vec2)
|
||||||
|
// that serialiseValue knows how to render.
|
||||||
|
const rendered = typeof child === "string"
|
||||||
|
? child
|
||||||
|
: (Array.isArray(child) || (child && child.colors !== undefined)
|
||||||
|
? root.serialiseValue(child)
|
||||||
|
: root.serialiseTable(child));
|
||||||
|
parts.push(`${name} = ${rendered}`);
|
||||||
}
|
}
|
||||||
return `{ ${parts.join(", ")} }`;
|
return `{ ${parts.join(", ")} }`;
|
||||||
}
|
}
|
||||||
@@ -353,6 +388,59 @@ Singleton {
|
|||||||
root.drainQueue();
|
root.drainQueue();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Gradients are written in one notation and read back in another, so they
|
||||||
|
// cannot be compared directly the way every other type can.
|
||||||
|
//
|
||||||
|
// written: { colors = { "rgba(3b426199)" }, angle = 45 }
|
||||||
|
// read: "993b4261 45deg"
|
||||||
|
//
|
||||||
|
// The stops swap to AARRGGBB order, lose their wrapper, and the angle is
|
||||||
|
// always appended even when it was never given. Comparing the raw strings
|
||||||
|
// reports every gradient write as rejected, which is what would have
|
||||||
|
// happened had this been added with readAs: "str".
|
||||||
|
function gradientMatches(expected: var, observed: string): bool {
|
||||||
|
if (typeof observed !== "string")
|
||||||
|
return false;
|
||||||
|
return root.normaliseGradient(expected) === root.normaliseGradient(observed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both notations reduced to "aarrggbb aarrggbb Ndeg".
|
||||||
|
function normaliseGradient(value: var): string {
|
||||||
|
const stops = [];
|
||||||
|
let angle = 0;
|
||||||
|
|
||||||
|
const readStop = function (text: string): void {
|
||||||
|
const rgba = String(text).match(/rgba?\(\s*([0-9a-fA-F]{6,8})\s*\)/);
|
||||||
|
if (rgba) {
|
||||||
|
let hex = rgba[1].toLowerCase();
|
||||||
|
// rgb() has no alpha; the compositor reports it as fully opaque.
|
||||||
|
if (hex.length === 6)
|
||||||
|
hex = hex + "ff";
|
||||||
|
// RRGGBBAA in, AARRGGBB out.
|
||||||
|
stops.push(hex.slice(6, 8) + hex.slice(0, 6));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const bare = String(text).match(/^([0-9a-fA-F]{8})$/);
|
||||||
|
if (bare) {
|
||||||
|
stops.push(bare[1].toLowerCase());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const deg = String(text).match(/^(-?[0-9.]+)deg$/);
|
||||||
|
if (deg)
|
||||||
|
angle = Number(deg[1]);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (value && typeof value === "object" && Array.isArray(value.colors)) {
|
||||||
|
value.colors.forEach(readStop);
|
||||||
|
if (value.angle !== undefined && isFinite(Number(value.angle)))
|
||||||
|
angle = Number(value.angle);
|
||||||
|
} else {
|
||||||
|
String(value).trim().split(/\s+/).forEach(readStop);
|
||||||
|
}
|
||||||
|
|
||||||
|
return stops.join(" ") + " " + angle + "deg";
|
||||||
|
}
|
||||||
|
|
||||||
function matchesObserved(entry: var, value: var, answer: var): bool {
|
function matchesObserved(entry: var, value: var, answer: var): bool {
|
||||||
if (!answer)
|
if (!answer)
|
||||||
return false;
|
return false;
|
||||||
@@ -370,6 +458,12 @@ Singleton {
|
|||||||
case "css":
|
case "css":
|
||||||
// Gaps read back as a box, e.g. "10 10 10 10".
|
// Gaps read back as a box, e.g. "10 10 10 10".
|
||||||
return Number(String(answer.css).trim().split(/\s+/)[0]) === expected;
|
return Number(String(answer.css).trim().split(/\s+/)[0]) === expected;
|
||||||
|
case "gradient":
|
||||||
|
return root.gradientMatches(expected, answer.gradient);
|
||||||
|
case "vec2":
|
||||||
|
return Array.isArray(answer.vec2) && Array.isArray(expected)
|
||||||
|
&& Number(answer.vec2[0]) === Number(expected[0])
|
||||||
|
&& Number(answer.vec2[1]) === Number(expected[1]);
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -497,18 +591,35 @@ Singleton {
|
|||||||
].indexOf(panel) >= 0;
|
].indexOf(panel) >= 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function openGnomePanel(panel: string): bool {
|
// `subpage` reaches the panels GNOME 50 nests under System -- users,
|
||||||
|
// about, datetime, region -- which its own desktop entries open as
|
||||||
|
// `gnome-control-center system users`. Without it, a row labelled "Users"
|
||||||
|
// lands on System's front page and leaves the user to navigate, which is
|
||||||
|
// most of the way to a broken button.
|
||||||
|
function openGnomePanel(panel: string, subpage: string): bool {
|
||||||
if (!root.isGnomePanelAllowed(panel)) {
|
if (!root.isGnomePanelAllowed(panel)) {
|
||||||
root.lastError = "That GNOME Settings panel is not available.";
|
root.lastError = "That GNOME Settings panel is not available.";
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
const command = ["gnome-control-center", panel];
|
||||||
|
if (subpage !== undefined && subpage !== "" && root.isGnomeSubpageAllowed(panel, subpage))
|
||||||
|
command.push(subpage);
|
||||||
Quickshell.execDetached({
|
Quickshell.execDetached({
|
||||||
command: ["gnome-control-center", panel],
|
command: command,
|
||||||
environment: { "XDG_CURRENT_DESKTOP": "GNOME" }
|
environment: { "XDG_CURRENT_DESKTOP": "GNOME" }
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only System nests panels, and only these. Read off the Exec lines of the
|
||||||
|
// gnome-*-panel desktop entries rather than guessed, for the same reason
|
||||||
|
// the panel list above was.
|
||||||
|
function isGnomeSubpageAllowed(panel: string, subpage: string): bool {
|
||||||
|
if (panel !== "system")
|
||||||
|
return false;
|
||||||
|
return ["users", "about", "datetime", "region", "remote-desktop"].indexOf(subpage) >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
function openApplication(id: string): bool {
|
function openApplication(id: string): bool {
|
||||||
const commands = {
|
const commands = {
|
||||||
"nextcloud": ["nextcloud"],
|
"nextcloud": ["nextcloud"],
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
pragma Singleton
|
||||||
|
|
||||||
|
// A QR code for a saved Wi-Fi network, so a guest can join by pointing a phone
|
||||||
|
// at the screen. GNOME's Wi-Fi panel has this and it is the most-used thing in
|
||||||
|
// it; reading a passphrase aloud is the alternative.
|
||||||
|
//
|
||||||
|
// The generated image contains the network password in machine-readable form,
|
||||||
|
// so the helper writes it under XDG_RUNTIME_DIR -- 0700, on tmpfs, gone at
|
||||||
|
// logout -- rather than anywhere persistent. Nothing here ever holds the
|
||||||
|
// passphrase itself; this service only ever sees a file path.
|
||||||
|
//
|
||||||
|
// Generated on demand. Producing a QR for every saved network up front would
|
||||||
|
// mean writing images of passwords nobody asked to see.
|
||||||
|
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import QtQuick
|
||||||
|
|
||||||
|
Singleton {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-wifi-qr"
|
||||||
|
|
||||||
|
// [{ name, ssid, shareable }]
|
||||||
|
property var networks: []
|
||||||
|
property bool scanned: false
|
||||||
|
property string lastError: ""
|
||||||
|
|
||||||
|
// The network whose code is on screen, and where its image is. Empty when
|
||||||
|
// nothing is being shared.
|
||||||
|
property string sharing: ""
|
||||||
|
property string imagePath: ""
|
||||||
|
|
||||||
|
readonly property var shareable: root.networks.filter(n => n.shareable)
|
||||||
|
|
||||||
|
function refresh(): void {
|
||||||
|
if (!list.running)
|
||||||
|
list.running = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function share(name: string): void {
|
||||||
|
if (generate.running)
|
||||||
|
return;
|
||||||
|
// Cache-bust: the helper reuses one file per network, so a QML Image
|
||||||
|
// pointed at the same path would keep showing the previous render.
|
||||||
|
root.imagePath = "";
|
||||||
|
root.sharing = name;
|
||||||
|
generate.command = [root.helperPath, "qr", name];
|
||||||
|
generate.running = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopSharing(): void {
|
||||||
|
root.sharing = "";
|
||||||
|
root.imagePath = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: list
|
||||||
|
command: [root.helperPath, "list"]
|
||||||
|
stdout: StdioCollector {
|
||||||
|
onStreamFinished: {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(this.text);
|
||||||
|
root.networks = Array.isArray(parsed.networks) ? parsed.networks : [];
|
||||||
|
root.lastError = String(parsed.error ?? "");
|
||||||
|
} catch (error) {
|
||||||
|
root.networks = [];
|
||||||
|
root.lastError = "Could not read the Wi-Fi helper's output.";
|
||||||
|
console.warn("WifiShare: could not parse helper output:", error);
|
||||||
|
}
|
||||||
|
root.scanned = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: generate
|
||||||
|
stdout: StdioCollector {
|
||||||
|
onStreamFinished: {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(this.text);
|
||||||
|
const path = String(parsed.path ?? "");
|
||||||
|
const error = String(parsed.error ?? "");
|
||||||
|
if (error !== "" || path === "") {
|
||||||
|
root.lastError = error !== "" ? error : "No QR code was produced.";
|
||||||
|
root.sharing = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
root.lastError = "";
|
||||||
|
root.imagePath = path;
|
||||||
|
} catch (error) {
|
||||||
|
root.lastError = "Could not read the generated QR code's path.";
|
||||||
|
root.sharing = "";
|
||||||
|
console.warn("WifiShare: could not parse helper output:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -145,6 +145,43 @@ ShellRoot {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
IpcHandler {
|
||||||
|
target: "health"
|
||||||
|
|
||||||
|
function refresh(): bool { return Health.refresh(); }
|
||||||
|
function status(): string {
|
||||||
|
return JSON.stringify({
|
||||||
|
summary: Health.summary,
|
||||||
|
busy: Health.busy,
|
||||||
|
generation: Health.generation,
|
||||||
|
acceptedGeneration: Health.acceptedGeneration,
|
||||||
|
checks: Health.checks.map(check => ({ id: check.id, status: check.status }))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function open(): void {
|
||||||
|
ShellState.openSettings("services");
|
||||||
|
Health.refresh();
|
||||||
|
}
|
||||||
|
function repair(id: string): bool { return Health.repair(id, true); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Health checks the wallpaper service through the same typed IPC boundary
|
||||||
|
// as capture and clipboard. This is deliberately read-only: choosing an
|
||||||
|
// image remains an explicit Settings action.
|
||||||
|
IpcHandler {
|
||||||
|
target: "wallpaper"
|
||||||
|
|
||||||
|
function refresh(): void { Wallpaper.refreshActive(); }
|
||||||
|
function status(): string {
|
||||||
|
return JSON.stringify({
|
||||||
|
active: Wallpaper.active,
|
||||||
|
configured: Wallpaper.configured,
|
||||||
|
availableCount: Wallpaper.available.length,
|
||||||
|
lastError: Wallpaper.lastError
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// A small diagnostics surface doubles as a deterministic contract harness.
|
// A small diagnostics surface doubles as a deterministic contract harness.
|
||||||
// Real producers call StatusEvents.publish() directly; fixtures never run
|
// Real producers call StatusEvents.publish() directly; fixtures never run
|
||||||
// unless explicitly requested over IPC by the test suite.
|
// unless explicitly requested over IPC by the test suite.
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Tokyo Night Day for tmux -- the light counterpart to tokyonight-moon.conf.
|
||||||
|
#
|
||||||
|
# Same structure, same roles, Day's palette. Mapped role for role rather than by
|
||||||
|
# hue: #1b1d2b was text drawn ON an accent block, so it becomes the LIGHT ground
|
||||||
|
# rather than a dark colour, or the status bar would be dark text on a dark
|
||||||
|
# accent in light mode.
|
||||||
|
#
|
||||||
|
# The accents are Day's DARKER variants, not its standard ones. Measured against
|
||||||
|
# the panel, the standard #2e7de9 and #9854f1 give 2.74:1 and 2.94:1 -- under
|
||||||
|
# the 3:1 floor for text. These give 4.42:1 and 4.17:1, and 5.01:1 / 4.73:1 for
|
||||||
|
# the light text drawn on top of them in the inverted blocks.
|
||||||
|
|
||||||
|
set -g mode-style "fg=#1c5bb8,bg=#d0d5e3"
|
||||||
|
|
||||||
|
set -g message-style "fg=#1c5bb8,bg=#d0d5e3"
|
||||||
|
set -g message-command-style "fg=#1c5bb8,bg=#d0d5e3"
|
||||||
|
|
||||||
|
set -g pane-border-style "fg=#1c5bb8"
|
||||||
|
set -g pane-active-border-style "fg=#7847bd"
|
||||||
|
|
||||||
|
set -g status-style "fg=#7847bd,bg=#d0d5e3"
|
||||||
|
set -g status-bg "#e1e2e7"
|
||||||
|
|
||||||
|
set -g status-left "#[fg=#e1e2e7,bg=#7847bd,bold] #S #[fg=#e1e2e7,bg=#7847bd,nobold,nounderscore,noitalics]"
|
||||||
|
set -g status-right "#[fg=#e1e2e7,bg=#1c5bb8,nobold,nounderscore,noitalics]#[fg=#7847bd,bg=#d0d5e3] #{prefix_highlight} #[fg=#d0d5e3,bg=#d0d5e3]#[fg=#7847bd,bg=#d0d5e3] %Y/%m/%d %I:%M %p #[fg=#1c5bb8,bg=#d0d5e3,nobold,nounderscore,noitalics]#[fg=#e1e2e7,bg=#7847bd,bold,italics] #h "
|
||||||
|
|
||||||
|
setw -g window-status-format "#[fg=#e1e2e7,bg=#d0d5e3,nobold,nounderscore,noitalics]#[fg=#1c5bb8,bg=#d0d5e3] #I #W #F #[fg=#e1e2e7,bg=#d0d5e3,nobold,nounderscore,noitalics]"
|
||||||
|
setw -g window-status-current-format "#[fg=#e1e2e7,bg=#d0d5e3,nobold,nounderscore,noitalics]#[fg=#7847bd,bg=#d0d5e3,bold] #I #W #F #[fg=#d0d5e3,bg=#d0d5e3,nobold,nounderscore,noitalics]"
|
||||||
|
|
||||||
|
setw -g window-status-separator ""
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Tokyo Night Moon for tmux -- the palette Panama ships.
|
||||||
|
#
|
||||||
|
# Extracted from tmux.conf so the two schemes can be swapped. tmux.conf sources
|
||||||
|
# current-theme.conf, which panama-theme-apps generates from one of these.
|
||||||
|
#
|
||||||
|
# Every colour here was already in tmux.conf; this file is that block verbatim.
|
||||||
|
|
||||||
|
set -g mode-style "fg=#82aaff,bg=#3b4261"
|
||||||
|
|
||||||
|
set -g message-style "fg=#82aaff,bg=#3b4261"
|
||||||
|
set -g message-command-style "fg=#82aaff,bg=#3b4261"
|
||||||
|
|
||||||
|
set -g pane-border-style "fg=#82aaff"
|
||||||
|
set -g pane-active-border-style "fg=#b172b0"
|
||||||
|
|
||||||
|
set -g status-style "fg=#b172b0,bg=#3b4261"
|
||||||
|
set -g status-bg "#222436"
|
||||||
|
|
||||||
|
set -g status-left "#[fg=#1b1d2b,bg=#b172b0,bold] #S #[fg=#1b1d2b,bg=#b172b0,nobold,nounderscore,noitalics]"
|
||||||
|
set -g status-right "#[fg=#1b1d2b,bg=#82aaff,nobold,nounderscore,noitalics]#[fg=#b172b0,bg=#3b4261] #{prefix_highlight} #[fg=#3b4261,bg=#3b4261]#[fg=#b172b0,bg=#3b4261] %Y/%m/%d %I:%M %p #[fg=#82aaff,bg=#3b4261,nobold,nounderscore,noitalics]#[fg=#1b1d2b,bg=#b172b0,bold,italics] #h "
|
||||||
|
|
||||||
|
setw -g window-status-format "#[fg=#1b1d2b,bg=#3b4261,nobold,nounderscore,noitalics]#[fg=#82aaff,bg=#3b4261] #I #W #F #[fg=#1b1d2b,bg=#3b4261,nobold,nounderscore,noitalics]"
|
||||||
|
setw -g window-status-current-format "#[fg=#1b1d2b,bg=#3b4261,nobold,nounderscore,noitalics]#[fg=#b172b0,bg=#3b4261,bold] #I #W #F #[fg=#3b4261,bg=#3b4261,nobold,nounderscore,noitalics]"
|
||||||
|
|
||||||
|
setw -g window-status-separator ""
|
||||||
@@ -19,24 +19,10 @@ bind C-Space send-prefix
|
|||||||
# Set status bar
|
# Set status bar
|
||||||
#set -g status-bg pink
|
#set -g status-bg pink
|
||||||
# Tokyo Night Moon color palette
|
# Tokyo Night Moon color palette
|
||||||
set -g mode-style "fg=#82aaff,bg=#3b4261"
|
# Colours live in themes/ and are generated into current-theme.conf by
|
||||||
|
# panama-theme-apps, which regenerates it whenever the desktop colour scheme
|
||||||
set -g message-style "fg=#82aaff,bg=#3b4261"
|
# changes. -q so a fresh checkout without the generated file still starts.
|
||||||
set -g message-command-style "fg=#82aaff,bg=#3b4261"
|
source-file -q "~/.config/tmux/current-theme.conf"
|
||||||
|
|
||||||
set -g pane-border-style "fg=#82aaff"
|
|
||||||
set -g pane-active-border-style "fg=#b172b0"
|
|
||||||
|
|
||||||
set -g status-style "fg=#b172b0,bg=#3b4261"
|
|
||||||
set -g status-bg "#222436"
|
|
||||||
|
|
||||||
set -g status-left "#[fg=#1b1d2b,bg=#b172b0,bold] #S #[fg=#1b1d2b,bg=#b172b0,nobold,nounderscore,noitalics]"
|
|
||||||
set -g status-right "#[fg=#1b1d2b,bg=#82aaff,nobold,nounderscore,noitalics]#[fg=#b172b0,bg=#3b4261] #{prefix_highlight} #[fg=#3b4261,bg=#3b4261]#[fg=#b172b0,bg=#3b4261] %Y/%m/%d %I:%M %p #[fg=#82aaff,bg=#3b4261,nobold,nounderscore,noitalics]#[fg=#1b1d2b,bg=#b172b0,bold,italics] #h "
|
|
||||||
|
|
||||||
setw -g window-status-format "#[fg=#1b1d2b,bg=#3b4261,nobold,nounderscore,noitalics]#[fg=#82aaff,bg=#3b4261] #I #W #F #[fg=#1b1d2b,bg=#3b4261,nobold,nounderscore,noitalics]"
|
|
||||||
setw -g window-status-current-format "#[fg=#1b1d2b,bg=#3b4261,nobold,nounderscore,noitalics]#[fg=#b172b0,bg=#3b4261,bold] #I #W #F #[fg=#3b4261,bg=#3b4261,nobold,nounderscore,noitalics]"
|
|
||||||
|
|
||||||
setw -g window-status-separator ""
|
|
||||||
|
|
||||||
# Increase scrollback buffer
|
# Increase scrollback buffer
|
||||||
set -g history-limit 50000
|
set -g history-limit 50000
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# Tokyo Night Day — the light counterpart to tokyonight-moon.toml.
|
||||||
|
#
|
||||||
|
# Same schema and the same role for every colour; only the palette differs, so
|
||||||
|
# the launcher keeps its identity when the desktop switches to light rather
|
||||||
|
# than becoming a different-looking application.
|
||||||
|
#
|
||||||
|
# Colours are the Tokyo Night Day palette, taken from kitty/themes/tokyonight-day.conf
|
||||||
|
# so the launcher and the terminal cannot drift apart.
|
||||||
|
|
||||||
|
[meta]
|
||||||
|
version = 1
|
||||||
|
name = "Tokyo Night Day"
|
||||||
|
description = "Tokyo Night Day, matched to the Panama Hyprland desktop."
|
||||||
|
variant = "light"
|
||||||
|
|
||||||
|
[colors.core]
|
||||||
|
background = "#e1e2e7" # bg
|
||||||
|
foreground = "#3760bf" # fg
|
||||||
|
secondary_background = "#d0d5e3" # bg_dark, a shade below the ground
|
||||||
|
border = "#a8aecb" # a visible hairline on a light ground
|
||||||
|
accent = "#2e7de9" # blue
|
||||||
|
accent_foreground = "#e1e2e7"
|
||||||
|
|
||||||
|
[colors.accents]
|
||||||
|
blue = "#2e7de9"
|
||||||
|
green = "#587539"
|
||||||
|
magenta = "#9854f1"
|
||||||
|
orange = "#b15c00"
|
||||||
|
purple = "#7847bd"
|
||||||
|
red = "#f52a65"
|
||||||
|
yellow = "#8c6c3e"
|
||||||
|
cyan = "#007197"
|
||||||
|
|
||||||
|
[colors.main_window]
|
||||||
|
border = "#a8aecb"
|
||||||
|
footer = { background = "colors.core.secondary_background" }
|
||||||
|
|
||||||
|
[colors.settings_window]
|
||||||
|
border = "#c4c8da"
|
||||||
|
|
||||||
|
[colors.shortcut]
|
||||||
|
border = "colors.core.border"
|
||||||
|
|
||||||
|
[colors.text]
|
||||||
|
default = "colors.core.foreground"
|
||||||
|
muted = "#6172b0" # fg_dark, 4.0:1 on the background
|
||||||
|
danger = "#f52a65"
|
||||||
|
success = "#587539"
|
||||||
|
placeholder = "#7079a8" # 3.25:1; Day's own #848cb5 is 2.54:1, too faint to read
|
||||||
|
selection = { background = "#2e7de9", foreground = "#e1e2e7" }
|
||||||
|
|
||||||
|
[colors.text.links]
|
||||||
|
default = "#2e7de9"
|
||||||
|
visited = "#9854f1"
|
||||||
|
|
||||||
|
[colors.input]
|
||||||
|
border = "#c4c8da"
|
||||||
|
border_focus = "#2e7de9"
|
||||||
|
border_error = "#f52a65"
|
||||||
|
|
||||||
|
[colors.button.primary]
|
||||||
|
background = "#d4d6e4" # bg_highlight
|
||||||
|
foreground = "#3760bf"
|
||||||
|
hover = { background = "#c4c8da" }
|
||||||
|
focus = { outline = "colors.core.accent" }
|
||||||
|
|
||||||
|
[colors.list.item.hover]
|
||||||
|
foreground = "#3760bf"
|
||||||
|
secondary_foreground = "#6172b0"
|
||||||
|
|
||||||
|
[colors.list.item.selection]
|
||||||
|
background = "#c3cdf0" # blue side of the Prism selection, lightened
|
||||||
|
foreground = "#3760bf"
|
||||||
|
secondary_background = "#d8c8e4" # orchid side of the Prism selection, lightened
|
||||||
|
secondary_foreground = "#3760bf"
|
||||||
|
|
||||||
|
[colors.grid.item]
|
||||||
|
background = "#d7d9e5"
|
||||||
|
hover = { outline = "#2e7de9" }
|
||||||
|
selection = { outline = "#9854f1" }
|
||||||
|
|
||||||
|
[colors.scrollbars]
|
||||||
|
background = "#c4c8da"
|
||||||
|
|
||||||
|
[colors.loading]
|
||||||
|
bar = "#2e7de9"
|
||||||
|
spinner = "#3760bf"
|
||||||
@@ -6,12 +6,14 @@
|
|||||||
// and emoji-copy extensions, so its clipboard and emoji views are bound
|
// and emoji-copy extensions, so its clipboard and emoji views are bound
|
||||||
// directly in hypr/keybinds.lua.
|
// directly in hypr/keybinds.lua.
|
||||||
|
|
||||||
// Vicinae 0.26 selects a theme per system appearance. Both point to Moon so
|
// Vicinae 0.26 selects a theme per system appearance, and now there is a
|
||||||
// the launcher never flashes or falls back to its stock palette while the
|
// theme for each: the launcher follows Panama's light/dark setting instead of
|
||||||
// desktop appearance is being initialized.
|
// staying dark on a light desktop. Both are authored here rather than using
|
||||||
|
// Vicinae's bundled Tokyo Night, which ships Night and Storm but not the Moon
|
||||||
|
// and Day variants the rest of this desktop uses.
|
||||||
"theme": {
|
"theme": {
|
||||||
"light": {
|
"light": {
|
||||||
"name": "tokyonight-moon",
|
"name": "tokyonight-day",
|
||||||
"icon_theme": "auto"
|
"icon_theme": "auto"
|
||||||
},
|
},
|
||||||
"dark": {
|
"dark": {
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
[Desktop Entry]
|
[Desktop Entry]
|
||||||
Type=Application
|
Type=Application
|
||||||
Name=Panama Settings
|
Name=Settings
|
||||||
GenericName=System Settings
|
GenericName=System Settings
|
||||||
Comment=Configure the Panama Hyprland desktop
|
Comment=Configure this desktop
|
||||||
Exec=qs ipc call settings open
|
Exec=qs ipc call settings open
|
||||||
Icon=panama-settings
|
Icon=panama-settings
|
||||||
Terminal=false
|
Terminal=false
|
||||||
StartupNotify=false
|
StartupNotify=false
|
||||||
Categories=Settings;
|
Categories=Settings;
|
||||||
Keywords=Panama;Hyprland;Display;Dock;Notifications;Shortcuts;Services;
|
Keywords=Settings;Preferences;Configuration;Display;Sound;Network;Keyboard;Mouse;Privacy;Power;Shortcuts;
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# @vicinae.schemaVersion 1
|
||||||
|
# @vicinae.title Panama: Check System Health
|
||||||
|
# @vicinae.mode silent
|
||||||
|
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
|
||||||
|
# @vicinae.description Review Panama services, integrations, and recovery actions.
|
||||||
|
# @vicinae.keywords ["health", "doctor", "repair", "services"]
|
||||||
|
|
||||||
|
exec "$HOME/.config/quickshell/scripts/panama-action" health
|
||||||
@@ -0,0 +1,598 @@
|
|||||||
|
# Panama Health & Recovery Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Build a quiet, trustworthy System Health surface that diagnoses Panama-owned desktop functionality, exposes redacted reports, and offers only narrow allow-listed repairs.
|
||||||
|
|
||||||
|
**Architecture:** An executable Python helper, `panama-doctor`, is the only operating-system boundary and emits one deterministic JSON schema. A `Health.qml` singleton owns accepted snapshots, scan generations, repair state, and report copying; Settings, the bar, IPC, and Vicinae consume that typed state without constructing commands.
|
||||||
|
|
||||||
|
**Tech Stack:** Python 3 standard library, Bash contract tests, Quickshell/QML, QtQuick, Hyprland IPC, Vicinae script commands, Prism design tokens.
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-08-18-panama-health-recovery-design.md`
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Healthy background scans are silent: no notifications, Signal Glass events, animations, or permanent bar ornament.
|
||||||
|
- Allowed statuses are exactly `ok`, `warning`, `error`, and `unconfigured`; overall status is `healthy`, `warning`, or `error`.
|
||||||
|
- Optional integrations that have never been configured are `unconfigured`, never warnings.
|
||||||
|
- The helper never reads or reports secret values, clipboard contents, notification bodies, calendar event data, SSIDs, addresses, or arbitrary command output.
|
||||||
|
- The helper never installs packages, invokes `sudo`, deletes user data, rewrites arbitrary configuration, or repairs services Panama does not own.
|
||||||
|
- Probe-derived values may populate observations only; check IDs, groups, titles, actions, commands, and arguments are authored constants.
|
||||||
|
- All process launches use argument arrays. UI text and report content never become commands.
|
||||||
|
- Preserve the last valid snapshot on helper failure or malformed JSON.
|
||||||
|
- Repairs are judged by a fresh observed scan, not by process exit status alone.
|
||||||
|
- Do not run a state-changing live repair without a genuinely degraded disposable target or explicit user approval.
|
||||||
|
|
||||||
|
## File map and stable interfaces
|
||||||
|
|
||||||
|
- `config/dot/quickshell/scripts/panama-doctor`: Python CLI and sole diagnostic/repair OS boundary.
|
||||||
|
- `config/dot/quickshell/services/Health.qml`: snapshot state machine, scan/repair processes, report copy, and fixture seams.
|
||||||
|
- `config/dot/quickshell/health-harness.qml`: deterministic IPC harness for generations, malformed data, coalescing, and repairs.
|
||||||
|
- `config/dot/quickshell/modules/settings/HealthPage.qml`: System Health page composition.
|
||||||
|
- `config/dot/quickshell/modules/settings/HealthSummary.qml`: stable-height summary hero and primary controls.
|
||||||
|
- `config/dot/quickshell/modules/settings/HealthCheckRow.qml`: one accessible check row with one action.
|
||||||
|
- `config/dot/quickshell/modules/bar/HealthIndicator.qml`: degraded-only bar entry point.
|
||||||
|
- `config/local/share/vicinae/scripts/check-system-health.sh`: searchable launcher command.
|
||||||
|
- `tests/quickshell/fixtures/doctor/`: isolated command, config, state, and runtime fixtures containing no real workstation data.
|
||||||
|
- `tests/quickshell/panama-doctor-contract.sh`: schema, status, redaction, timeout, ordering, and repair allow-list contract.
|
||||||
|
- `tests/quickshell/health-service-contract.sh`: QML state-machine contract.
|
||||||
|
- `tests/quickshell/health-ui-contract.sh`: Settings, footer, report, indicator, IPC, and Vicinae integration contract.
|
||||||
|
|
||||||
|
The helper's authored check order is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
desktop.hyprland
|
||||||
|
desktop.quickshell
|
||||||
|
desktop.notifications
|
||||||
|
desktop.portals
|
||||||
|
desktop.hyprpaper
|
||||||
|
desktop.hypridle
|
||||||
|
desktop.vicinae
|
||||||
|
input.pipewire
|
||||||
|
input.clipboard
|
||||||
|
input.wallpaper
|
||||||
|
input.capture
|
||||||
|
input.ocr
|
||||||
|
input.brightness
|
||||||
|
integration.nextcloud
|
||||||
|
integration.rustdesk
|
||||||
|
integration.kdeconnect
|
||||||
|
integration.bluebubbles
|
||||||
|
integration.home-assistant
|
||||||
|
integration.calendar
|
||||||
|
panama.runtime-links
|
||||||
|
panama.vicinae-commands
|
||||||
|
panama.selected-terminal
|
||||||
|
panama.selected-launcher
|
||||||
|
panama.processes
|
||||||
|
panama.caffeine
|
||||||
|
```
|
||||||
|
|
||||||
|
Only these repair IDs are executable in release one:
|
||||||
|
|
||||||
|
```text
|
||||||
|
desktop.hyprpaper -> systemctl --user restart hyprpaper.service
|
||||||
|
desktop.hypridle -> systemctl --user restart hypridle.service
|
||||||
|
desktop.vicinae -> systemctl --user restart vicinae.service
|
||||||
|
desktop.quickshell -> panama-action restart-shell (confirmation required)
|
||||||
|
panama.runtime-links -> recreate only known Panama-owned broken symlinks
|
||||||
|
panama.vicinae-commands -> setup/scripts/link-vicinae-scripts
|
||||||
|
panama.caffeine -> release duplicate Panama/Caffeine inhibitor PIDs only
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Prism health mocks and visual approval
|
||||||
|
|
||||||
|
**Approved visual:** A — Diagnostic Ledger. Preserve its restrained issue rail, stable summary hero, two-column healthy ledgers, live sidebar footer, and degraded-only bar capsule.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create outside tracked source: `.superpowers/mocks/system-health/index.html`
|
||||||
|
- Create outside tracked source: `.superpowers/mocks/system-health/panama.css`
|
||||||
|
- Create outside tracked source: `.superpowers/mocks/system-health/mock.js`
|
||||||
|
- Create outside tracked source: `.superpowers/mocks/system-health/a-ledger.html`
|
||||||
|
- Create outside tracked source: `.superpowers/mocks/system-health/b-focus.html`
|
||||||
|
- Create outside tracked source: `.superpowers/mocks/system-health/c-compact.html`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: the existing 272 px Settings sidebar, 48 px titlebar, Tokyo Night Moon Prism tokens, and the approved information architecture.
|
||||||
|
- Produces: one approved visual composition for healthy, warning, checking, and error states plus the degraded-only bar indicator.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Build three static compositions from real copy**
|
||||||
|
|
||||||
|
Use the same warning fixture in all three: `Vicinae` is stopped with action `Restart Vicinae`; `External monitor brightness` needs permission with action `View setup instructions`; `BlueBubbles` is `Not set up`. Keep every variant inside the real Settings geometry and include the footer and bar indicator.
|
||||||
|
|
||||||
|
```text
|
||||||
|
A — Diagnostic ledger: one restrained amber issue rail beside calm grouped rows.
|
||||||
|
B — Focus card: issues receive the visual focus; healthy groups collapse into quieter ledgers below.
|
||||||
|
C — Compact matrix: dense two-column group cards with the same issue-first ordering.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Serve and visually inspect the mocks**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m http.server 52780 --directory .superpowers/mocks/system-health
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all three variants render at `http://localhost:52780`, keyboard focus is visible, no element overflows at 1360x900, and reduced-motion mode has no continuous animation.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Capture the approved direction in the plan**
|
||||||
|
|
||||||
|
Add a short `Approved visual: <variant>` note beneath this task after user selection. Production UI work in Task 4 must reproduce that composition using existing QML tokens rather than copying browser-only effects.
|
||||||
|
|
||||||
|
### Task 2: Deterministic read-only doctor
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `config/dot/quickshell/scripts/panama-doctor`
|
||||||
|
- Create: `tests/quickshell/panama-doctor-contract.sh`
|
||||||
|
- Create: `tests/quickshell/fixtures/doctor/bin/systemctl`
|
||||||
|
- Create: `tests/quickshell/fixtures/doctor/bin/pgrep`
|
||||||
|
- Create: `tests/quickshell/fixtures/doctor/bin/busctl`
|
||||||
|
- Create: `tests/quickshell/fixtures/doctor/bin/qs`
|
||||||
|
- Create: `tests/quickshell/fixtures/doctor/bin/vicinae`
|
||||||
|
- Create: `tests/quickshell/fixtures/doctor/bin/systemd-inhibit`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `PANAMA_DOCTOR_ROOT`, `PANAMA_DOCTOR_HOME`, `PANAMA_DOCTOR_CONFIG_HOME`, `PANAMA_DOCTOR_STATE_HOME`, `PANAMA_DOCTOR_RUNTIME_DIR`, `PANAMA_DOCTOR_PATH`, and `PANAMA_DOCTOR_TIMEOUT` test seams; production defaults resolve from the real process environment.
|
||||||
|
- Produces: `panama-doctor --json`, `panama-doctor --summary`, and a versioned schema with `summary` plus the 25 ordered check objects listed above.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing schema and redaction contract**
|
||||||
|
|
||||||
|
The contract must create an isolated home, tracked source tree, runtime tree, and fake command directory, then assert:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
snapshot="$($doctor --json)"
|
||||||
|
jq -e '.schemaVersion == 1
|
||||||
|
and (.generatedAt | type == "string")
|
||||||
|
and (.summary.status | IN("healthy", "warning", "error"))
|
||||||
|
and (.context.session | IN("hyprland", "other"))
|
||||||
|
and (.context.versions | type == "array")
|
||||||
|
and ([.checks[].id] | length == 25)
|
||||||
|
and ([.checks[].id] | unique | length == 25)
|
||||||
|
and ([.checks[].status] | all(IN("ok", "warning", "error", "unconfigured")))' <<<"$snapshot"
|
||||||
|
[[ "$(jq -r '.checks[].id' <<<"$snapshot")" == "$expected_order" ]]
|
||||||
|
! grep -Fq 'fixture-secret-token' <<<"$snapshot"
|
||||||
|
! grep -Fq 'fixture clipboard body' <<<"$snapshot"
|
||||||
|
! grep -Fq 'AA:BB:CC:DD:EE:FF' <<<"$snapshot"
|
||||||
|
```
|
||||||
|
|
||||||
|
Cover a healthy required service, a missing required executable, an unconfigured optional integration, a configured-but-stopped integration, an inaccessible DDC bus, a timed-out probe, duplicate Caffeine inhibitors, malformed probe output, and concise `--summary` output.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the contract and verify the helper is absent**
|
||||||
|
|
||||||
|
Run: `tests/quickshell/panama-doctor-contract.sh`
|
||||||
|
|
||||||
|
Expected: FAIL because `config/dot/quickshell/scripts/panama-doctor` does not exist.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement authored checks and concurrent bounded probes**
|
||||||
|
|
||||||
|
Use Python standard-library types and deterministic assembly:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Action:
|
||||||
|
kind: Literal["repair", "open", "instructions"]
|
||||||
|
label: str
|
||||||
|
confirm: bool = False
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Check:
|
||||||
|
id: str
|
||||||
|
group: Literal["desktop-foundation", "input-media", "integrations", "panama-tools"]
|
||||||
|
title: str
|
||||||
|
status: Literal["ok", "warning", "error", "unconfigured"]
|
||||||
|
detail: str
|
||||||
|
action: Action | None = None
|
||||||
|
```
|
||||||
|
|
||||||
|
Run independent probes through `ThreadPoolExecutor(max_workers=8)`. Every subprocess call must use a constant argument tuple, `capture_output=True`, `text=True`, and the configured timeout. Convert timeout, non-zero status, and parse failure into a check result. Assemble checks by the authored ID tuple after futures settle; never emit completion order.
|
||||||
|
|
||||||
|
Configuration checks may inspect existence and file type only. Home Assistant is configured when both expected variable names are present, but their values are never retained. Calendar is configured from enabled EDS source count only; event commands are never called. BlueBubbles is configured from the Flatpak installation check. DDC uses only `panama-brightness list` and retains display count plus its authored error classification, never connector names.
|
||||||
|
|
||||||
|
The top-level `context` contains only an authored session class and an ordered array of parsed Hyprland, Quickshell, Fedora, and Panama revision versions. `panama.processes` counts only exact authored process names and flags duplicate Quickshell, Vicinae, Hyprpaper, or Hypridle instances without exposing command lines. Integration actions are authored too: Nextcloud, RustDesk, KDE Connect, and BlueBubbles may offer their exact Open action; Home Assistant and calendar failures route to `home-phone` and `datetime`; never derive an application or page name from probe output.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the doctor contract**
|
||||||
|
|
||||||
|
Run: `tests/quickshell/panama-doctor-contract.sh`
|
||||||
|
|
||||||
|
Expected: `panama doctor contract: PASS`.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit the read-only engine**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add config/dot/quickshell/scripts/panama-doctor tests/quickshell/panama-doctor-contract.sh tests/quickshell/fixtures/doctor
|
||||||
|
git commit -m "Add Panama system health diagnostics"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 3: Health singleton and typed IPC state machine
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `config/dot/quickshell/services/Health.qml`
|
||||||
|
- Create: `config/dot/quickshell/health-harness.qml`
|
||||||
|
- Create: `tests/quickshell/health-service-contract.sh`
|
||||||
|
- Modify: `config/dot/quickshell/shell.qml`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `panama-doctor --json` and `panama-doctor --repair CHECK_ID --json`.
|
||||||
|
- Produces: `Health.snapshot`, `Health.checks`, `Health.summary`, `Health.status`, `Health.actionable`, `Health.busy`, `Health.diagnosticUnavailable`, `Health.lastError`, `Health.repairingId`, `Health.lastRepair`, `Health.lastCopyResult`, `Health.refresh()`, `Health.repair(id, external)`, `Health.copyReport()`, and IPC target `health` with `refresh`, `status`, `open`, and `repair(id)`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing QML state contract**
|
||||||
|
|
||||||
|
The harness exposes fixture methods that call the real singleton's pure consumption seams:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
function accept(text: string, generation: int): bool { return Health.consumeSnapshot(text, generation); }
|
||||||
|
function queue(): void { Health.refresh(); Health.refresh(); }
|
||||||
|
function status(): string { return JSON.stringify(Health.diagnostics()); }
|
||||||
|
```
|
||||||
|
|
||||||
|
Assert that a valid warning snapshot is accepted, an older generation is ignored, malformed JSON preserves the prior checks and marks the engine unavailable, two refreshes while running schedule exactly one follow-up, a valid repair triggers one rescan, and unknown/non-repairable IDs start no process.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the contract and verify it fails**
|
||||||
|
|
||||||
|
Run: `tests/quickshell/health-service-contract.sh`
|
||||||
|
|
||||||
|
Expected: FAIL because `Health.qml` and the harness do not exist.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement the singleton state machine**
|
||||||
|
|
||||||
|
Define the stable state shape:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
property var snapshot: ({})
|
||||||
|
property var checks: []
|
||||||
|
property var summary: ({ status: "healthy", healthy: 0, warnings: 0, errors: 0, unconfigured: 0 })
|
||||||
|
property string status: "healthy"
|
||||||
|
property bool diagnosticUnavailable: false
|
||||||
|
property bool queuedRefresh: false
|
||||||
|
property int generation: 0
|
||||||
|
property int acceptedGeneration: 0
|
||||||
|
property string repairingId: ""
|
||||||
|
property var lastRepair: ({})
|
||||||
|
property string lastCopyResult: ""
|
||||||
|
readonly property bool actionable: status === "warning" || status === "error"
|
||||||
|
readonly property bool busy: scanProcess.running || repairProcess.running
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `Process.exec([root.helperPath, "--json"])`; attach the current generation to the collector before launch. `consumeSnapshot(text, generation)` validates schema version, summary keys, context shape, unique IDs, groups, statuses, titles, details, and action shapes before replacing state. A 2200 ms one-shot startup timer requests the initial scan. A running scan sets `queuedRefresh`; exit consumes at most one queued follow-up. `copyReport()` sends only `JSON.stringify(root.snapshot, null, 2)` to `wl-copy` through a `Process` stdin buffer and writes success or failure to `lastCopyResult` without touching the clipboard service's history model.
|
||||||
|
|
||||||
|
The `health` IPC `status()` returns only the already-redacted summary, busy flags, generation, and check IDs/statuses. `open()` calls `ShellState.openSettings("services")` then refreshes. IPC `repair(id)` calls `Health.repair(id, true)` and returns a Boolean acceptance result; Settings calls `Health.repair(id, false)`. A failed externally-originated repair uses an argument-array `notify-send` process with the existing `Panama action failed` title, while Settings failures remain inline.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run service and IPC contracts**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tests/quickshell/health-service-contract.sh
|
||||||
|
tests/quickshell/settings-window-contract.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: both PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit the service layer**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add config/dot/quickshell/services/Health.qml config/dot/quickshell/health-harness.qml tests/quickshell/health-service-contract.sh config/dot/quickshell/shell.qml
|
||||||
|
git commit -m "Add Panama health state service"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 4: Approved System Health Settings page
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `config/dot/quickshell/modules/settings/HealthPage.qml`
|
||||||
|
- Create: `config/dot/quickshell/modules/settings/HealthSummary.qml`
|
||||||
|
- Create: `config/dot/quickshell/modules/settings/HealthCheckRow.qml`
|
||||||
|
- Modify: `config/dot/quickshell/modules/settings/SettingsShell.qml`
|
||||||
|
- Modify: `config/dot/quickshell/modules/settings/SettingsSidebar.qml`
|
||||||
|
- Modify: `config/dot/quickshell/services/SettingsSearch.qml`
|
||||||
|
- Delete: `config/dot/quickshell/modules/settings/ServicesPage.qml`
|
||||||
|
- Create: `tests/quickshell/health-ui-contract.sh`
|
||||||
|
- Modify: `tests/quickshell/settings-pages-contract.sh`
|
||||||
|
- Modify: `tests/quickshell/settings-search-contract.sh`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: all read-only state and methods from `Health.qml`; route remains the stable internal name `services`.
|
||||||
|
- Produces: System Health summary, issues-first cards, four grouped ledgers, live clickable sidebar footer, Copy Report feedback, and confirmation requests for disruptive repairs.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing Settings and accessibility assertions**
|
||||||
|
|
||||||
|
Assert static structure and fixture-rendered state:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rg -Fq 'label: "System Health"' config/dot/quickshell/modules/settings/SettingsSidebar.qml
|
||||||
|
rg -Fq 'onClicked: Health.copyReport()' config/dot/quickshell/modules/settings/HealthSummary.qml
|
||||||
|
rg -Fq 'onTapped: root.pageRequested("services")' config/dot/quickshell/modules/settings/SettingsSidebar.qml
|
||||||
|
rg -Fq 'text: "Checking…"' config/dot/quickshell/modules/settings/HealthSummary.qml
|
||||||
|
rg -Fq 'Health.refresh()' config/dot/quickshell/modules/settings/HealthPage.qml
|
||||||
|
```
|
||||||
|
|
||||||
|
The runtime harness must prove warning rows appear before healthy groups, unconfigured is visible as `Not set up`, every status has text in addition to color, refresh preserves row geometry, keyboard focus reaches both hero actions and row actions, and a Quickshell-restart repair opens a confirmation sheet.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run UI contracts and verify failure**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tests/quickshell/health-ui-contract.sh
|
||||||
|
tests/quickshell/settings-pages-contract.sh
|
||||||
|
tests/quickshell/settings-search-contract.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL because the approved Health components are absent.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement the approved composition**
|
||||||
|
|
||||||
|
Use `SettingsPage`, `SettingsCard`, `SettingsButton`, `Theme`, and `PrismEdge`. Keep the summary hero stable at 126 px and each check row at least 62 px. Derive labels exactly:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
function statusLabel(status: string): string {
|
||||||
|
if (status === "ok") return "Healthy";
|
||||||
|
if (status === "warning") return "Needs attention";
|
||||||
|
if (status === "error") return "Action required";
|
||||||
|
return "Not set up";
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`HealthPage.Component.onCompleted` calls `Health.refresh()`. Issues are checks with `warning` or `error`. Group cards preserve helper order. Each row exposes at most one action. `open` actions route to exact Settings pages; `instructions` actions reveal authored inline instructions; `repair` actions call `Health.repair(id, false)` after confirmation only when `action.confirm === true`.
|
||||||
|
|
||||||
|
The sidebar footer is a 54 px `TapHandler` target with status text derived from Health, not a hardcoded string. It opens `services`; when no scan has completed it says `Checking Panama desktop`. A malformed or failed doctor run retains the last rows, changes only the hero to `Health check unavailable`, and exposes one bounded `Retry` action. The page ends with the approved boundary note and an `Open GNOME Settings` action for networking, printers, users, and other Fedora-owned areas.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run UI contracts and inspect the rendered fixture**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tests/quickshell/health-ui-contract.sh
|
||||||
|
tests/quickshell/settings-pages-contract.sh
|
||||||
|
tests/quickshell/settings-search-contract.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all PASS with zero QML warnings.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit the Settings experience**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add config/dot/quickshell/modules/settings config/dot/quickshell/services/SettingsSearch.qml tests/quickshell/health-ui-contract.sh tests/quickshell/settings-pages-contract.sh tests/quickshell/settings-search-contract.sh
|
||||||
|
git commit -m "Build the System Health settings page"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 5: Quiet bar indicator and launcher entry point
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `config/dot/quickshell/modules/bar/HealthIndicator.qml`
|
||||||
|
- Modify: `config/dot/quickshell/modules/bar/Bar.qml`
|
||||||
|
- Create: `config/local/share/vicinae/scripts/check-system-health.sh`
|
||||||
|
- Modify: `config/dot/quickshell/scripts/panama-action`
|
||||||
|
- Modify: `tests/quickshell/health-ui-contract.sh`
|
||||||
|
- Modify: `tests/quickshell/panama-action-contract.sh`
|
||||||
|
- Modify: `tests/quickshell/panama-commands-contract.sh`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `Health.actionable`, `Health.status`, and `Health.summary`; existing `panama-action` dispatcher and Settings IPC.
|
||||||
|
- Produces: one degraded-only bar affordance and Vicinae command `Panama: Check System Health`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Extend contracts before production files**
|
||||||
|
|
||||||
|
Assert the indicator is absent for healthy/unconfigured-only fixtures, visible amber for warnings, visible red for errors, includes a textual accessible label, and opens `services`. Extend command fixtures so:
|
||||||
|
|
||||||
|
```text
|
||||||
|
panama-action health -> qs ipc call health open
|
||||||
|
check-system-health.sh title -> Panama: Check System Health
|
||||||
|
check-system-health.sh exec -> $HOME/.config/quickshell/scripts/panama-action health
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run focused tests and verify failure**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tests/quickshell/health-ui-contract.sh
|
||||||
|
tests/quickshell/panama-action-contract.sh
|
||||||
|
tests/quickshell/panama-commands-contract.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL on the missing indicator and command.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement the quiet entry points**
|
||||||
|
|
||||||
|
Place `HealthIndicator` in the right-side bar row before `ActivityIndicator`. It has no reserved width while hidden, no animation, and one compact shield/wrench glyph with an issue-count tooltip or accessible description. Use `Theme.warn` only for warnings and `Theme.danger` only for errors. Clicking calls `ShellState.openSettings("services")` and `Health.refresh()`.
|
||||||
|
|
||||||
|
Add this dispatcher case and usage token:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
health) qs ipc call health open ;;
|
||||||
|
```
|
||||||
|
|
||||||
|
Create a Vicinae script with schema version 1, silent mode, Panama Settings icon, keywords `health`, `doctor`, `repair`, `services`, and the stable `panama-action health` execution path.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run focused tests**
|
||||||
|
|
||||||
|
Run the three commands from Step 2.
|
||||||
|
|
||||||
|
Expected: all PASS; command count increases from 17 to 18.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit the entry points**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add config/dot/quickshell/modules/bar config/local/share/vicinae/scripts/check-system-health.sh config/dot/quickshell/scripts/panama-action tests/quickshell
|
||||||
|
git commit -m "Add quiet System Health entry points"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 6: Allow-listed repairs and observed recovery
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `config/dot/quickshell/scripts/panama-doctor`
|
||||||
|
- Modify: `tests/quickshell/panama-doctor-contract.sh`
|
||||||
|
- Modify: `config/dot/quickshell/services/Health.qml`
|
||||||
|
- Modify: `tests/quickshell/health-service-contract.sh`
|
||||||
|
- Modify: `config/dot/quickshell/modules/settings/HealthCheckRow.qml`
|
||||||
|
- Modify: `tests/quickshell/health-ui-contract.sh`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: the fixed repair matrix in this plan and current accepted checks from `Health.qml`.
|
||||||
|
- Produces: `panama-doctor --repair CHECK_ID --json` result `{schemaVersion, checkId, accepted, exitCode, message}`, inline repair state, and one post-repair scan.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add exact repair-command tests**
|
||||||
|
|
||||||
|
For every repair ID, use fake commands and isolated paths to assert the exact argv. Assert all of these are rejected before any process or filesystem write:
|
||||||
|
|
||||||
|
```text
|
||||||
|
unknown.check
|
||||||
|
integration.home-assistant
|
||||||
|
input.brightness
|
||||||
|
desktop.notifications
|
||||||
|
../../escape
|
||||||
|
desktop.vicinae;touch injected
|
||||||
|
```
|
||||||
|
|
||||||
|
For runtime links, fixtures must prove only these link names are eligible: `hypr`, `quickshell`, `uwsm`, and `vicinae`; a regular user-owned directory is reported but never replaced. For Caffeine, only duplicate rows with application `Panama`, current UID, reason `Caffeine`, and mode `block` may yield numeric PIDs; leave one valid inhibitor alive and release extras.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run repair contracts and verify failure**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tests/quickshell/panama-doctor-contract.sh
|
||||||
|
tests/quickshell/health-service-contract.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL because `--repair` is not implemented.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement the authored repair registry**
|
||||||
|
|
||||||
|
Represent commands as immutable constant tuples or dedicated functions:
|
||||||
|
|
||||||
|
```python
|
||||||
|
REPAIR_COMMANDS = {
|
||||||
|
"desktop.hyprpaper": ("systemctl", "--user", "restart", "hyprpaper.service"),
|
||||||
|
"desktop.hypridle": ("systemctl", "--user", "restart", "hypridle.service"),
|
||||||
|
"desktop.vicinae": ("systemctl", "--user", "restart", "vicinae.service"),
|
||||||
|
"desktop.quickshell": ("panama-action", "restart-shell"),
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Handle runtime links, Vicinae command linking, and duplicate inhibitors in dedicated functions that accept no caller-controlled path or command. Return JSON on every known failure. Unknown IDs exit 2 with `accepted: false` and do not invoke any runner.
|
||||||
|
|
||||||
|
`Health.repair(id, external)` requires the ID to exist in the current snapshot with `action.kind === "repair"`, records `repairingId`, runs the helper with an argument array, parses the result, clears the busy row, and requests exactly one fresh scan. Keep the row degraded until that scan reports recovery.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run repair and UI contracts**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tests/quickshell/panama-doctor-contract.sh
|
||||||
|
tests/quickshell/health-service-contract.sh
|
||||||
|
tests/quickshell/health-ui-contract.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit repairs**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add config/dot/quickshell/scripts/panama-doctor config/dot/quickshell/services/Health.qml config/dot/quickshell/modules/settings/HealthCheckRow.qml tests/quickshell
|
||||||
|
git commit -m "Add bounded Panama recovery actions"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 7: Full verification, controller-deferred live audit, and documentation
|
||||||
|
|
||||||
|
**Integration note:** `origin/main` added Mouse, Privacy, Region, and Online
|
||||||
|
Accounts destinations while this feature was in review. Merge commit `4ef2f01`
|
||||||
|
preserves those routes and the newer Settings navigation architecture, keeps
|
||||||
|
the stable `services` route rendered by `HealthPage`, and leaves
|
||||||
|
`ServicesPage.qml` retired. Its useful Fedora handoffs for Users, Sharing,
|
||||||
|
Colour profiles, and Digital wellbeing now live in the boundary-last System
|
||||||
|
Health card alongside the existing network handoff, with focused static and
|
||||||
|
isolated runtime coverage.
|
||||||
|
|
||||||
|
**Live-audit handoff:** Per the integration brief, this task does not reload the
|
||||||
|
daily-driver Quickshell, invoke a live repair, or run the read-only live
|
||||||
|
doctor/IPC comparison. Those checks remain for the controller after code review.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `config/dot/hypr/DESKTOP-PARITY.md`
|
||||||
|
- Modify: `config/dot/quickshell/modules/settings/README.md`
|
||||||
|
- Modify: `docs/superpowers/plans/2026-08-18-panama-health-recovery.md`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: the complete feature and existing regression suite.
|
||||||
|
- Produces: current user documentation and final contract evidence. The
|
||||||
|
redacted live health snapshot and live shell audit are deferred to the
|
||||||
|
controller after code review.
|
||||||
|
|
||||||
|
- [x] **Step 1: Document boundaries and entry points**
|
||||||
|
|
||||||
|
Document `Panama: Check System Health`, Settings → System Health, the degraded-only bar indicator, `panama-doctor --summary`, the no-`sudo`/no-package-install boundary, and the fact that GNOME/Fedora tools remain responsible for generic system configuration.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run syntax, focused, and full contracts**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m py_compile config/dot/quickshell/scripts/panama-doctor
|
||||||
|
bash -n config/dot/quickshell/scripts/panama-action
|
||||||
|
tests/quickshell/panama-doctor-contract.sh
|
||||||
|
tests/quickshell/health-service-contract.sh
|
||||||
|
tests/quickshell/health-ui-contract.sh
|
||||||
|
for test in tests/quickshell/*contract.sh; do "$test"; done
|
||||||
|
for test in tests/hypr/*contract.sh; do "$test"; done
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: every command exits 0. After the latest Settings and installer work,
|
||||||
|
the current inventory is 66 Quickshell contracts and 2 Hyprland contracts (the original
|
||||||
|
pre-merge estimate was 58).
|
||||||
|
|
||||||
|
Integration result: syntax and all focused Health/Settings contracts pass. Two
|
||||||
|
complete serial Quickshell runs each passed 63/65, but failed on different
|
||||||
|
order-sensitive contracts. Run one failed Displays and Settings Hyprland Write;
|
||||||
|
run two failed Focus Session and Health Service. Each failed contract passed
|
||||||
|
immediately when rerun alone. Hyprland contracts passed 2/2. No out-of-scope
|
||||||
|
test or service code was changed to hide this suite-order interference.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Controller runs a redacted live read-only comparison**
|
||||||
|
|
||||||
|
Deferred to the controller after code review; Task 7 does not produce this
|
||||||
|
live output.
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
config/dot/quickshell/scripts/panama-doctor --json >"$(mktemp)"
|
||||||
|
config/dot/quickshell/scripts/panama-doctor --summary
|
||||||
|
systemctl --user is-active hyprpaper.service hypridle.service vicinae.service pipewire.service
|
||||||
|
qs ipc call health refresh
|
||||||
|
qs ipc call health status | jq '{status, busy, checks: [.checks[] | {id, status}]}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: helper and direct service states agree. Do not print details from integrations; copied and IPC reports contain only redacted authored observations.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Controller reloads and inspects the live shell**
|
||||||
|
|
||||||
|
Deferred to the controller after code review; Task 7 does not reload or inspect
|
||||||
|
the daily-driver shell.
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
qs reload
|
||||||
|
sleep 4
|
||||||
|
journalctl --user --since '-2 minutes' --no-pager | rg -i 'quickshell|qml|panama' | tail -200
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: the shell returns, System Health opens, the healthy state is silent, and there are no new QML errors or binding-loop warnings. Do not invoke a repair during this step.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Final diff and commit**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git diff --check
|
||||||
|
git status --short
|
||||||
|
git diff --stat origin/main...HEAD
|
||||||
|
git add config/dot/hypr/DESKTOP-PARITY.md config/dot/quickshell/modules/settings/README.md docs/superpowers/plans/2026-08-18-panama-health-recovery.md
|
||||||
|
git commit -m "Document Panama health and recovery"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: only intentional Health & Recovery files are present and no workstation-specific values appear in the diff.
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
# Panama Health & Recovery Design
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Panama Health & Recovery makes the desktop explain itself. It verifies the
|
||||||
|
local services, dependencies, links, and integrations that Panama relies on,
|
||||||
|
then presents useful recovery actions without asking the user to read logs or
|
||||||
|
diagnose a collection of unrelated Linux processes.
|
||||||
|
|
||||||
|
The feature is intentionally quiet. A healthy desktop produces no notification,
|
||||||
|
banner, or permanent bar ornament. Problems appear in Panama Settings and, when
|
||||||
|
actionable, as one restrained bar indicator. User-initiated repairs receive
|
||||||
|
immediate Prism OSD or inline feedback.
|
||||||
|
|
||||||
|
## Product boundaries
|
||||||
|
|
||||||
|
The first release covers Panama-owned or Panama-integrated functionality:
|
||||||
|
|
||||||
|
- Hyprland, Quickshell, the notification server, XDG desktop portals, PipeWire,
|
||||||
|
Vicinae, the clipboard watcher, wallpaper, idle policy, and Panama's runtime
|
||||||
|
configuration links.
|
||||||
|
- The Panama command collection, screenshot and OCR dependencies, DDC
|
||||||
|
brightness support, and the currently selected terminal and launcher.
|
||||||
|
- Nextcloud, RustDesk, KDE Connect, BlueBubbles, Home Assistant, calendar
|
||||||
|
aggregation, and the configured autostart entries.
|
||||||
|
- Orphaned Panama processes and inhibitors, including duplicate Caffeine locks.
|
||||||
|
- Versions and non-sensitive diagnostic context needed for a useful copied
|
||||||
|
report.
|
||||||
|
|
||||||
|
It does not become a package manager, a generic system monitor, or a replacement
|
||||||
|
for Fedora's troubleshooting tools. It never installs packages, invokes `sudo`,
|
||||||
|
deletes user data, rewrites arbitrary configuration, or repairs services Panama
|
||||||
|
does not own.
|
||||||
|
|
||||||
|
An optional integration that has never been configured is neutral **Not set
|
||||||
|
up**, not a warning. A configured integration that cannot operate is degraded.
|
||||||
|
This distinction prevents the health UI from pressuring the user to enable
|
||||||
|
features they do not want.
|
||||||
|
|
||||||
|
## Information architecture
|
||||||
|
|
||||||
|
The existing **Startup & Services** destination becomes **System Health**. This
|
||||||
|
avoids two pages reporting the same background services. Its existing Open and
|
||||||
|
Refresh actions remain available through the richer health rows.
|
||||||
|
|
||||||
|
The page has four levels:
|
||||||
|
|
||||||
|
1. A compact summary hero: **Healthy**, **Needs attention**, or **Action
|
||||||
|
required**, the last completed scan time, Refresh, and Copy Report.
|
||||||
|
2. An issues-first section shown only when one or more checks are degraded.
|
||||||
|
3. Grouped cards for Desktop Foundation, Input & Media, Integrations, and Panama
|
||||||
|
Tools. Healthy rows remain visible but visually quiet.
|
||||||
|
4. A short boundary note linking to GNOME or Fedora tools for system areas Panama
|
||||||
|
does not own.
|
||||||
|
|
||||||
|
Each row contains a stable title, one-sentence observation, status label, and at
|
||||||
|
most one primary action. Actions use concrete language such as **Restart
|
||||||
|
Vicinae**, **Repair command link**, **Open Home settings**, or **View setup
|
||||||
|
instructions**. There is no generic Fix Everything button.
|
||||||
|
|
||||||
|
The Settings sidebar's existing health footer becomes real and clickable. It
|
||||||
|
shows the aggregate state and opens System Health. The top bar gains a small
|
||||||
|
`HealthIndicator` only while an actionable warning or error exists; clicking it
|
||||||
|
opens the same page. Background scans never publish Signal Glass events or
|
||||||
|
desktop notifications.
|
||||||
|
|
||||||
|
## Diagnostic engine
|
||||||
|
|
||||||
|
`config/dot/quickshell/scripts/panama-doctor` is the single operating-system
|
||||||
|
boundary. It supports:
|
||||||
|
|
||||||
|
- `panama-doctor --json` for a complete versioned snapshot.
|
||||||
|
- `panama-doctor --summary` for a concise human-readable installer or terminal
|
||||||
|
result.
|
||||||
|
- `panama-doctor --repair CHECK_ID --json` for an explicitly allow-listed repair.
|
||||||
|
|
||||||
|
The helper emits one schema:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"generatedAt": "2026-08-18T12:00:00Z",
|
||||||
|
"summary": {
|
||||||
|
"status": "warning",
|
||||||
|
"healthy": 18,
|
||||||
|
"warnings": 1,
|
||||||
|
"errors": 0,
|
||||||
|
"unconfigured": 2
|
||||||
|
},
|
||||||
|
"checks": [
|
||||||
|
{
|
||||||
|
"id": "launcher.panama-commands",
|
||||||
|
"group": "panama-tools",
|
||||||
|
"title": "Panama Commands",
|
||||||
|
"status": "warning",
|
||||||
|
"detail": "16 of 17 commands are loaded",
|
||||||
|
"action": {
|
||||||
|
"kind": "repair",
|
||||||
|
"label": "Repair command link"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Allowed statuses are `ok`, `warning`, `error`, and `unconfigured`. Check IDs,
|
||||||
|
group IDs, titles, and repair mappings are authored constants. Probe output may
|
||||||
|
populate observations but can never become a command or executable argument.
|
||||||
|
|
||||||
|
Checks run concurrently where doing so is safe, with short per-probe timeouts.
|
||||||
|
A failed or timed-out probe yields a check result rather than aborting the whole
|
||||||
|
snapshot. Output order is deterministic so tests, copied reports, and visual
|
||||||
|
rows do not jump between scans.
|
||||||
|
|
||||||
|
No secrets are read. The report may state whether a Home Assistant URL or token
|
||||||
|
is configured, but never includes either value. It excludes clipboard contents,
|
||||||
|
notification bodies, calendar event data, SSIDs, device addresses, environment
|
||||||
|
values, file contents, and command output that has not been explicitly parsed.
|
||||||
|
|
||||||
|
## Quickshell state and refresh model
|
||||||
|
|
||||||
|
`services/Health.qml` owns the latest accepted snapshot, aggregate severity,
|
||||||
|
busy state, last scan time, and the result of the most recent repair. It invokes
|
||||||
|
`panama-doctor` with argument arrays through `Process`; UI components never
|
||||||
|
construct shell commands.
|
||||||
|
|
||||||
|
Health performs one delayed scan after the shell reaches a stable startup state.
|
||||||
|
It scans again when the System Health page is opened, when the user presses
|
||||||
|
Refresh, and after a repair settles. There is no periodic polling loop while the
|
||||||
|
desktop is idle. Services that already expose event-driven state remain the
|
||||||
|
authoritative source for their own interactive controls; Health is a diagnostic
|
||||||
|
snapshot, not a competing live service model.
|
||||||
|
|
||||||
|
Every scan receives a monotonically increasing generation. Late output from an
|
||||||
|
older scan is discarded. A malformed snapshot leaves the last valid result in
|
||||||
|
place, marks the diagnostic engine unavailable, and offers a bounded Retry.
|
||||||
|
|
||||||
|
The shell exposes a typed `health` IPC target with `refresh`, `status`, `open`,
|
||||||
|
and `repair(id)` operations. Vicinae gains **Panama: Check System Health**, which
|
||||||
|
opens the page and requests a fresh scan through the existing `panama-action`
|
||||||
|
dispatcher.
|
||||||
|
|
||||||
|
## Repair policy
|
||||||
|
|
||||||
|
Repairs are narrow, reversible, and attached to one check. The first release may:
|
||||||
|
|
||||||
|
- Restart Panama's user services such as Vicinae, Hyprpaper, or Hypridle.
|
||||||
|
- Recreate Panama-owned symlinks when their destination is known and tracked.
|
||||||
|
- Reload Vicinae's Panama command collection.
|
||||||
|
- Release duplicate user-owned inhibitors whose metadata identifies Panama and
|
||||||
|
Caffeine.
|
||||||
|
- Restart Quickshell through the verified `panama-action restart-shell` path.
|
||||||
|
- Open the exact Panama Settings page required to finish credentials or entity
|
||||||
|
selection.
|
||||||
|
|
||||||
|
Restarting a working service is not presented as a repair. Repairs that interrupt
|
||||||
|
visible desktop chrome require a confirmation sheet in Settings. Navigation and
|
||||||
|
setup actions do not. Package installation, privileged service changes, display
|
||||||
|
mode writes, and destructive cleanup are never automatic; the UI shows concise
|
||||||
|
instructions instead.
|
||||||
|
|
||||||
|
After a repair, Health rescans and judges success from the observed result. A
|
||||||
|
zero exit status alone never turns a row green. Failure remains inline on the
|
||||||
|
affected row and also produces the existing Panama action-failure notification
|
||||||
|
when the action originated outside Settings.
|
||||||
|
|
||||||
|
## Visual language and interaction
|
||||||
|
|
||||||
|
System Health uses the established Settings cards and Prism tokens. Healthy
|
||||||
|
states use a small muted green dot and subdued **Healthy** copy. Warnings use
|
||||||
|
amber; red is reserved for functionality that is configured, required, and
|
||||||
|
currently broken. `unconfigured` rows use neutral gray.
|
||||||
|
|
||||||
|
The summary hero does not use a decorative gauge, percentage score, pulse,
|
||||||
|
shimmer, or animated gradient. A desktop is not “82% healthy.” The headline and
|
||||||
|
issue count are more understandable and do not create false precision.
|
||||||
|
|
||||||
|
Rows keep their height stable while refreshing. The previous snapshot remains
|
||||||
|
visible with a quiet **Checking…** label rather than replacing the page with a
|
||||||
|
spinner. Keyboard focus order reaches Refresh, Copy Report, issue rows, repair
|
||||||
|
actions, and external handoffs. Status is always expressed in text as well as
|
||||||
|
color.
|
||||||
|
|
||||||
|
Before production components are edited, the page and degraded bar indicator
|
||||||
|
will be shown in several static mocks using the existing Settings geometry. The
|
||||||
|
chosen mock must preserve this information architecture and Panama's current
|
||||||
|
Prism language rather than introduce a new visual system.
|
||||||
|
|
||||||
|
## Failure handling
|
||||||
|
|
||||||
|
- Missing required executables become actionable check results.
|
||||||
|
- Missing optional applications remain neutral until configured.
|
||||||
|
- A doctor crash, timeout, or malformed JSON does not clear the last good
|
||||||
|
snapshot or crash Quickshell.
|
||||||
|
- Concurrent refresh requests coalesce into one follow-up scan.
|
||||||
|
- A repair request for an unknown or non-repairable ID is rejected before any
|
||||||
|
process starts.
|
||||||
|
- Copy Report uses only the already-redacted snapshot and reports clipboard
|
||||||
|
failure inline.
|
||||||
|
- If the Settings window is closed during a scan or repair, the process may
|
||||||
|
finish; reopening the page shows the settled result.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- Run the real helper against isolated fake command, config, state, and runtime
|
||||||
|
directories and prove every status transition deterministically.
|
||||||
|
- Validate the JSON schema, stable check IDs, deterministic ordering, and
|
||||||
|
uniqueness of each ID.
|
||||||
|
- Prove unconfigured integrations remain neutral while configured failures are
|
||||||
|
degraded.
|
||||||
|
- Prove reports contain no fixture secrets, clipboard text, calendar data,
|
||||||
|
addresses, or unparsed environment values.
|
||||||
|
- Exercise every repair through the allow-list, assert its exact command, and
|
||||||
|
prove unknown IDs cannot execute anything.
|
||||||
|
- Test scan generations, malformed snapshots, refresh coalescing, repair
|
||||||
|
rescans, and preservation of the last valid state in a Quickshell harness.
|
||||||
|
- Verify Settings routing, search entries, the live sidebar footer, and the
|
||||||
|
degraded-only bar indicator without QML warnings.
|
||||||
|
- Validate the Vicinae command and typed IPC surface.
|
||||||
|
- Run a read-only doctor scan on the real workstation and compare key results to
|
||||||
|
direct service checks. State-changing live repair tests require an actually
|
||||||
|
degraded disposable target or explicit user approval.
|
||||||
|
- Restart the live shell, inspect the fresh log, and visually review healthy,
|
||||||
|
warning, error, unconfigured, refreshing, and repair-result states.
|
||||||
|
|
||||||
|
## Delivery slices
|
||||||
|
|
||||||
|
1. Diagnostic schema, read-only probes, redaction, and contract tests.
|
||||||
|
2. `Health.qml`, typed IPC, startup/manual refresh, and fixture harness.
|
||||||
|
3. System Health Settings page, live sidebar footer, search, and report copy.
|
||||||
|
4. Degraded-only bar indicator and Vicinae command.
|
||||||
|
5. Allow-listed repairs, confirmations, post-repair verification, and live audit.
|
||||||
|
|
||||||
|
The slices are one feature and land together. Their order keeps the UI backed by
|
||||||
|
real diagnostics from its first production render.
|
||||||
@@ -1,11 +1,24 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
source ~/.local/share/Panama/bin/ascii
|
|
||||||
# Set host name
|
# Panama's installer. Safe to re-run: every stage is idempotent, and this is
|
||||||
|
# also the upgrade path.
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}"
|
||||||
|
source "$PANAMA_PATH/bin/ascii"
|
||||||
|
|
||||||
|
# ── Hostname, which is optional ──────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# Declining this used to `exit`, which aborted the ENTIRE installation. The
|
||||||
|
# prompt defaults to N, so simply pressing Enter -- the obvious thing to do when
|
||||||
|
# you do not want to rename your machine -- installed nothing at all and said
|
||||||
|
# nothing about it.
|
||||||
echo -e "Current hostname is: $(hostname)"
|
echo -e "Current hostname is: $(hostname)"
|
||||||
read -p "Do you want to change the hostname? [y/N]: " confirm_change
|
read -r -p "Do you want to change the hostname? [y/N]: " confirm_change
|
||||||
if [[ "$confirm_change" =~ ^[Yy]$ ]]; then
|
if [[ "$confirm_change" =~ ^[Yy]$ ]]; then
|
||||||
read -p "Hostname: " HOST_NAME
|
read -r -p "Hostname: " HOST_NAME
|
||||||
read -p "Set hostname to '$HOST_NAME'? [y/N]: " confirm_hostname
|
read -r -p "Set hostname to '$HOST_NAME'? [y/N]: " confirm_hostname
|
||||||
if [[ "$confirm_hostname" =~ ^[Yy]$ ]]; then
|
if [[ "$confirm_hostname" =~ ^[Yy]$ ]]; then
|
||||||
sudo hostnamectl set-hostname "$HOST_NAME"
|
sudo hostnamectl set-hostname "$HOST_NAME"
|
||||||
echo "Hostname set to: $(hostname)"
|
echo "Hostname set to: $(hostname)"
|
||||||
@@ -13,18 +26,45 @@ if [[ "$confirm_change" =~ ^[Yy]$ ]]; then
|
|||||||
echo "Hostname not changed."
|
echo "Hostname not changed."
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
echo "Not changing hostname."
|
echo "Keeping the current hostname."
|
||||||
exit
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Ensure computer doesn't go to sleep.
|
# ── Keep the machine awake for the duration ──────────────────────────────────
|
||||||
gsettings set org.gnome.desktop.screensaver lock-enabled false
|
# Package installation takes long enough to hit an idle lock, and being locked
|
||||||
gsettings set org.gnome.desktop.session idle-delay 0
|
# out mid-transaction is unpleasant. Restored on every exit path, including
|
||||||
|
# failure and Ctrl-C, so an interrupted install does not leave the screen
|
||||||
|
# permanently awake.
|
||||||
|
restore_idle() {
|
||||||
|
gsettings set org.gnome.desktop.screensaver lock-enabled true 2>/dev/null || true
|
||||||
|
gsettings set org.gnome.desktop.session idle-delay 300 2>/dev/null || true
|
||||||
|
}
|
||||||
|
trap restore_idle EXIT INT TERM
|
||||||
|
|
||||||
# Run each setup stage in its own process. This keeps strict-shell options and
|
gsettings set org.gnome.desktop.screensaver lock-enabled false 2>/dev/null || true
|
||||||
# helper variables local to the script that owns them.
|
gsettings set org.gnome.desktop.session idle-delay 0 2>/dev/null || true
|
||||||
for script in ~/.local/share/Panama/setup/scripts/*; do "$script"; done
|
|
||||||
|
|
||||||
# Revert to normal idle settings
|
# ── Stages ───────────────────────────────────────────────────────────────────
|
||||||
gsettings set org.gnome.desktop.screensaver lock-enabled true
|
# Each runs in its own process so strict-shell options and helper variables stay
|
||||||
gsettings set org.gnome.desktop.session idle-delay 300
|
# local to the script that owns them. A failing stage is reported and the rest
|
||||||
|
# still run: a missing optional package should not stop the dotfiles being
|
||||||
|
# linked. The summary at the end is what decides whether the install worked,
|
||||||
|
# because a failure scrolled past twenty minutes ago is a failure nobody saw.
|
||||||
|
failed=()
|
||||||
|
for script in "$PANAMA_PATH"/setup/scripts/*; do
|
||||||
|
[[ -x "$script" ]] || continue
|
||||||
|
stage="$(basename "$script")"
|
||||||
|
printf '\n=== %s ===\n' "$stage"
|
||||||
|
if ! "$script"; then
|
||||||
|
failed+=("$stage")
|
||||||
|
printf '!!! %s failed\n' "$stage" >&2
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
printf '\n'
|
||||||
|
if (( ${#failed[@]} == 0 )); then
|
||||||
|
echo "Panama installed. Log out and choose the Hyprland session to start it."
|
||||||
|
else
|
||||||
|
printf 'Panama installed with %d failed stage(s): %s\n' "${#failed[@]}" "${failed[*]}" >&2
|
||||||
|
printf 'Re-running ./install is safe and will retry them.\n' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|||||||
@@ -1,38 +1,45 @@
|
|||||||
hyprland
|
NetworkManager
|
||||||
hyprland-uwsm
|
|
||||||
uwsm
|
|
||||||
quickshell
|
|
||||||
vicinae
|
|
||||||
hyprlock
|
|
||||||
hypridle
|
|
||||||
hyprpaper
|
|
||||||
hyprpicker
|
|
||||||
hyprsunset
|
|
||||||
hyprpolkitagent
|
|
||||||
hyprshutdown
|
|
||||||
hyprpwcenter
|
|
||||||
hyprsysteminfo
|
|
||||||
hyprland-guiutils
|
|
||||||
xdg-desktop-portal-hyprland
|
|
||||||
grim
|
|
||||||
slurp
|
|
||||||
grimblast
|
|
||||||
satty
|
|
||||||
wl-clipboard
|
|
||||||
wf-recorder
|
|
||||||
gpu-screen-recorder
|
|
||||||
brightnessctl
|
|
||||||
playerctl
|
|
||||||
pamixer
|
|
||||||
udiskie
|
|
||||||
wofi
|
|
||||||
adw-gtk3-theme
|
adw-gtk3-theme
|
||||||
adwaita-icon-theme
|
adwaita-icon-theme
|
||||||
adwaita-sans-fonts
|
adwaita-sans-fonts
|
||||||
qt6-qtwayland
|
brightnessctl
|
||||||
nm-connection-editor
|
ddcutil
|
||||||
|
gpu-screen-recorder
|
||||||
|
grim
|
||||||
|
grimblast
|
||||||
|
gtk-update-icon-cache
|
||||||
|
hypridle
|
||||||
|
hyprland
|
||||||
|
hyprland-guiutils
|
||||||
|
hyprland-uwsm
|
||||||
|
hyprlock
|
||||||
|
hyprpaper
|
||||||
|
hyprpicker
|
||||||
|
hyprpolkitagent
|
||||||
|
hyprpwcenter
|
||||||
|
hyprshutdown
|
||||||
|
hyprsunset
|
||||||
|
hyprsysteminfo
|
||||||
kde-connect
|
kde-connect
|
||||||
|
libnotify
|
||||||
|
nm-connection-editor
|
||||||
|
orca
|
||||||
|
pamixer
|
||||||
|
playerctl
|
||||||
|
qrencode
|
||||||
|
qt6-qtwayland
|
||||||
|
quickshell
|
||||||
|
satty
|
||||||
|
slurp
|
||||||
|
system-config-printer
|
||||||
tesseract
|
tesseract
|
||||||
tesseract-langpack-eng
|
tesseract-langpack-eng
|
||||||
|
udiskie
|
||||||
|
uwsm
|
||||||
|
vicinae
|
||||||
|
wf-recorder
|
||||||
|
wireplumber
|
||||||
|
wl-clipboard
|
||||||
|
wofi
|
||||||
|
xdg-desktop-portal-hyprland
|
||||||
zbar
|
zbar
|
||||||
system-config-printer
|
|
||||||
|
|||||||
@@ -1,18 +1,27 @@
|
|||||||
awk
|
awk
|
||||||
bat
|
bat
|
||||||
|
btop
|
||||||
cargo
|
cargo
|
||||||
curl
|
curl
|
||||||
eza
|
eza
|
||||||
|
fontconfig
|
||||||
|
fwupd
|
||||||
fzf
|
fzf
|
||||||
git-all
|
|
||||||
gh
|
gh
|
||||||
|
git-all
|
||||||
gum
|
gum
|
||||||
|
jq
|
||||||
|
kitty
|
||||||
ksshaskpass
|
ksshaskpass
|
||||||
|
libselinux-utils
|
||||||
neovim
|
neovim
|
||||||
openssl
|
openssl
|
||||||
|
pciutils
|
||||||
|
python3-dnf
|
||||||
python3-neovim
|
python3-neovim
|
||||||
rustup
|
rustup
|
||||||
|
tmux
|
||||||
unzip
|
unzip
|
||||||
wireguard-tools
|
|
||||||
wget
|
wget
|
||||||
|
wireguard-tools
|
||||||
zoxide
|
zoxide
|
||||||
|
|||||||
+84
-10
@@ -70,6 +70,73 @@ for dir in "${dirs[@]}"; do
|
|||||||
log "Linked $PANAMA_DOT/$dir → $CONFIG/$dir"
|
log "Linked $PANAMA_DOT/$dir → $CONFIG/$dir"
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# tmux.conf ends with a source-file of current-theme.conf, generated from the
|
||||||
|
# colour scheme rather than committed. Seed it so a fresh checkout starts themed
|
||||||
|
# -- the source-file is -q, so a missing file is silent, which would leave tmux
|
||||||
|
# unstyled with nothing to explain it.
|
||||||
|
TMUX_THEME="$PANAMA_DOT/tmux/current-theme.conf"
|
||||||
|
if [ -e "$TMUX_THEME" ]; then
|
||||||
|
log "Keeping existing tmux theme at $TMUX_THEME"
|
||||||
|
elif [ -d "$PANAMA_DOT/tmux/themes" ]; then
|
||||||
|
tmux_scheme="dark"
|
||||||
|
tmux_prefs="${XDG_CONFIG_HOME:-$HOME/.config}/panama/settings.json"
|
||||||
|
if [ -r "$tmux_prefs" ]; then
|
||||||
|
tmux_stored="$(jq -r '.colorScheme // "dark"' "$tmux_prefs" 2>/dev/null || echo dark)"
|
||||||
|
[ "$tmux_stored" = "light" ] && tmux_scheme="light"
|
||||||
|
fi
|
||||||
|
[ "$tmux_scheme" = "light" ] && tmux_name="tokyonight-day" || tmux_name="tokyonight-moon"
|
||||||
|
cp "$PANAMA_DOT/tmux/themes/$tmux_name.conf" "$TMUX_THEME"
|
||||||
|
log "Seeded tmux $tmux_scheme theme ($tmux_name) → $TMUX_THEME"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# btop reads themes from its own config directory, but OWNS btop.conf -- it
|
||||||
|
# rewrites that file on exit -- so only the theme files are exposed, per file,
|
||||||
|
# and the config itself is left to btop. panama-theme-apps edits the single
|
||||||
|
# color_theme line in place.
|
||||||
|
BTOP_THEME_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/btop/themes"
|
||||||
|
mkdir -p "$BTOP_THEME_DIR"
|
||||||
|
for btop_theme_src in "$PANAMA_DOT"/btop/themes/*.theme; do
|
||||||
|
[ -e "$btop_theme_src" ] || continue
|
||||||
|
btop_theme_dst="$BTOP_THEME_DIR/$(basename "$btop_theme_src")"
|
||||||
|
if [ -L "$btop_theme_dst" ]; then
|
||||||
|
rm "$btop_theme_dst"
|
||||||
|
fi
|
||||||
|
if [ -e "$btop_theme_dst" ]; then
|
||||||
|
log "Keeping existing btop theme at $btop_theme_dst"
|
||||||
|
else
|
||||||
|
ln -s "$btop_theme_src" "$btop_theme_dst"
|
||||||
|
log "Linked btop theme → $btop_theme_dst"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# GTK3 has no include mechanism, so its settings.ini is generated whole from a
|
||||||
|
# template rather than layered. Without this, a fresh checkout has a template
|
||||||
|
# and no settings.ini, and GTK3 applications fall back to their built-in theme.
|
||||||
|
# panama-theme-apps rewrites both files on every scheme change after this.
|
||||||
|
for gtk_version in 3.0 4.0; do
|
||||||
|
gtk_template="$PANAMA_DOT/gtk-$gtk_version/settings.ini.template"
|
||||||
|
gtk_settings="$PANAMA_DOT/gtk-$gtk_version/settings.ini"
|
||||||
|
[ -r "$gtk_template" ] || continue
|
||||||
|
if [ -e "$gtk_settings" ]; then
|
||||||
|
log "Keeping existing GTK settings at $gtk_settings"
|
||||||
|
else
|
||||||
|
gtk_scheme="dark"
|
||||||
|
gtk_prefs="${XDG_CONFIG_HOME:-$HOME/.config}/panama/settings.json"
|
||||||
|
if [ -r "$gtk_prefs" ]; then
|
||||||
|
gtk_stored="$(jq -r '.colorScheme // "dark"' "$gtk_prefs" 2>/dev/null || echo dark)"
|
||||||
|
[ "$gtk_stored" = "light" ] && gtk_scheme="light"
|
||||||
|
fi
|
||||||
|
if [ "$gtk_scheme" = "light" ]; then
|
||||||
|
gtk_name="adw-gtk3"; gtk_dark=0
|
||||||
|
else
|
||||||
|
gtk_name="adw-gtk3-dark"; gtk_dark=1
|
||||||
|
fi
|
||||||
|
sed -e "s/@GTK_THEME@/$gtk_name/" -e "s/@PREFER_DARK@/$gtk_dark/" \
|
||||||
|
"$gtk_template" > "$gtk_settings"
|
||||||
|
log "Generated GTK $gtk_version settings ($gtk_name) → $gtk_settings"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
# kitty.conf ends with `include current-theme.conf`, and that file is generated
|
# kitty.conf ends with `include current-theme.conf`, and that file is generated
|
||||||
# from the desktop colour scheme rather than committed -- it is machine state.
|
# from the desktop colour scheme rather than committed -- it is machine state.
|
||||||
# A fresh checkout therefore has no such file, and kitty starts by complaining
|
# A fresh checkout therefore has no such file, and kitty starts by complaining
|
||||||
@@ -95,19 +162,26 @@ fi
|
|||||||
# from ~/.config/vicinae. Keep the authored theme in Panama with the rest of
|
# from ~/.config/vicinae. Keep the authored theme in Panama with the rest of
|
||||||
# the launcher config and expose only that file at Vicinae's runtime path.
|
# the launcher config and expose only that file at Vicinae's runtime path.
|
||||||
VICINAE_THEME_DIR="$HOME/.local/share/vicinae/themes"
|
VICINAE_THEME_DIR="$HOME/.local/share/vicinae/themes"
|
||||||
VICINAE_THEME="$VICINAE_THEME_DIR/tokyonight-moon.toml"
|
|
||||||
mkdir -p "$VICINAE_THEME_DIR"
|
mkdir -p "$VICINAE_THEME_DIR"
|
||||||
|
|
||||||
if [ -L "$VICINAE_THEME" ]; then
|
# Every authored theme, not just the dark one: vicinae.json selects a theme per
|
||||||
rm "$VICINAE_THEME"
|
# system appearance, so linking only Moon left the launcher falling back to its
|
||||||
fi
|
# stock palette whenever Panama was in light mode.
|
||||||
|
for theme_src in "$PANAMA_DOT"/vicinae/themes/*.toml; do
|
||||||
|
[ -e "$theme_src" ] || continue
|
||||||
|
theme_dst="$VICINAE_THEME_DIR/$(basename "$theme_src")"
|
||||||
|
|
||||||
if [ -e "$VICINAE_THEME" ]; then
|
if [ -L "$theme_dst" ]; then
|
||||||
log "Keeping existing Vicinae theme at $VICINAE_THEME"
|
rm "$theme_dst"
|
||||||
else
|
fi
|
||||||
ln -s "$PANAMA_DOT/vicinae/themes/tokyonight-moon.toml" "$VICINAE_THEME"
|
|
||||||
log "Linked Tokyo Night Moon theme → $VICINAE_THEME"
|
if [ -e "$theme_dst" ]; then
|
||||||
fi
|
log "Keeping existing Vicinae theme at $theme_dst"
|
||||||
|
else
|
||||||
|
ln -s "$theme_src" "$theme_dst"
|
||||||
|
log "Linked Vicinae theme → $theme_dst"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
# Panama-native applications live in the user data directory so launchers can
|
# Panama-native applications live in the user data directory so launchers can
|
||||||
# discover them alongside system desktop entries. Keep each authored file in
|
# discover them alongside system desktop entries. Keep each authored file in
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ done
|
|||||||
assert_contains 'title: "Default applications"'
|
assert_contains 'title: "Default applications"'
|
||||||
assert_contains 'title: "User autostart"'
|
assert_contains 'title: "User autostart"'
|
||||||
assert_contains 'title: "Compositor autostart"'
|
assert_contains 'title: "Compositor autostart"'
|
||||||
|
assert_contains 'AutostartAppPicker {'
|
||||||
|
assert_contains 'DefaultApps.addAutostart('
|
||||||
|
assert_contains 'label: "Add an application"'
|
||||||
assert_contains 'categories'
|
assert_contains 'categories'
|
||||||
assert_contains 'genericName'
|
assert_contains 'genericName'
|
||||||
assert_contains '.sort('
|
assert_contains '.sort('
|
||||||
@@ -123,4 +126,14 @@ fi
|
|||||||
[[ "$(rg --count 'activatable:' "$page")" -ge 2 ]] \
|
[[ "$(rg --count 'activatable:' "$page")" -ge 2 ]] \
|
||||||
|| fail 'default and autostart rows are not both whole-row activatable'
|
|| fail 'default and autostart rows are not both whole-row activatable'
|
||||||
|
|
||||||
|
picker="$project_root/config/dot/quickshell/modules/settings/AutostartAppPicker.qml"
|
||||||
|
qmldir="$project_root/config/dot/quickshell/modules/settings/qmldir"
|
||||||
|
[[ -f "$picker" ]] || fail 'autostart application picker is missing'
|
||||||
|
rg -Fq 'required property var existing' "$picker" \
|
||||||
|
|| fail 'autostart picker cannot exclude existing entries'
|
||||||
|
rg -Fq 'signal picked(string id)' "$picker" \
|
||||||
|
|| fail 'autostart picker does not emit a validated desktop id'
|
||||||
|
rg -q '^AutostartAppPicker 1\.0 AutostartAppPicker\.qml$' "$qmldir" \
|
||||||
|
|| fail 'autostart picker is not registered in the Settings module'
|
||||||
|
|
||||||
printf 'applications settings contract: PASS\n'
|
printf 'applications settings contract: PASS\n'
|
||||||
|
|||||||
@@ -34,15 +34,35 @@ mkdir -p "$fixture/drm" "$fixture/dev" "$fixture/bin" "$fixture/i2c"
|
|||||||
# Two connectors with a monitor, two without. DP-2 answers DDC; DP-3 is
|
# Two connectors with a monitor, two without. DP-2 answers DDC; DP-3 is
|
||||||
# connected but does not implement brightness. HDMI-A-1 and DP-1 are empty and
|
# connected but does not implement brightness. HDMI-A-1 and DP-1 are empty and
|
||||||
# must never be probed at all.
|
# must never be probed at all.
|
||||||
|
# A connector has an EDID bus (the `ddc` symlink) and, on DisplayPort, an AUX
|
||||||
|
# bus that appears as a child directory. DDC/CI rides the AUX channel where one
|
||||||
|
# exists, and the `ddc` line answers nothing on a DP connector even though it
|
||||||
|
# still resolves -- so the aux argument here is what a real DisplayPort monitor
|
||||||
|
# looks like, and omitting it is what HDMI and DVI look like.
|
||||||
make_connector() {
|
make_connector() {
|
||||||
local name="$1" bus="$2" status="$3"
|
local name="$1" ddc_bus="$2" status="$3" aux_bus="${4:-}"
|
||||||
mkdir -p "$fixture/drm/$name"
|
local device="$fixture/devices/$name"
|
||||||
printf '%s\n' "$status" >"$fixture/drm/$name/status"
|
|
||||||
mkdir -p "$fixture/i2c/i2c-$bus"
|
# /sys/class/drm/<connector> is a SYMLINK to the real device directory, and
|
||||||
ln -sfn "$fixture/i2c/i2c-$bus" "$fixture/drm/$name/ddc"
|
# this fixture mirrors that rather than using a plain directory. It matters:
|
||||||
|
# `find` does not follow the path it is given, so code that searches the
|
||||||
|
# unresolved path finds nothing while appearing to work anywhere the entry
|
||||||
|
# happens to be a real directory.
|
||||||
|
mkdir -p "$device"
|
||||||
|
printf '%s\n' "$status" >"$device/status"
|
||||||
|
mkdir -p "$fixture/i2c/i2c-$ddc_bus"
|
||||||
|
ln -sfn "$fixture/i2c/i2c-$ddc_bus" "$device/ddc"
|
||||||
|
[[ -n "$aux_bus" ]] && mkdir -p "$device/i2c-$aux_bus"
|
||||||
|
|
||||||
|
mkdir -p "$fixture/drm"
|
||||||
|
ln -sfn "$device" "$fixture/drm/$name"
|
||||||
|
return 0
|
||||||
}
|
}
|
||||||
|
# DP-2 is the DisplayPort case: its EDID line is bus 5, which answers nothing,
|
||||||
|
# and its AUX child is bus 9, which does. Choosing bus 5 here finds no monitor
|
||||||
|
# at all, which is exactly the bug this pins down.
|
||||||
make_connector card1-DP-1 4 disconnected
|
make_connector card1-DP-1 4 disconnected
|
||||||
make_connector card1-DP-2 5 connected
|
make_connector card1-DP-2 5 connected 9
|
||||||
make_connector card1-DP-3 6 connected
|
make_connector card1-DP-3 6 connected
|
||||||
make_connector card1-HDMI-A-1 7 disconnected
|
make_connector card1-HDMI-A-1 7 disconnected
|
||||||
|
|
||||||
@@ -75,7 +95,7 @@ for arg in "$@"; do
|
|||||||
done
|
done
|
||||||
|
|
||||||
case "$bus" in
|
case "$bus" in
|
||||||
5) printf 'VCP 10 C 120 200\n'; exit 0 ;;
|
9) printf 'VCP 10 C 120 200\n'; exit 0 ;;
|
||||||
*) exit 1 ;;
|
*) exit 1 ;;
|
||||||
esac
|
esac
|
||||||
STUB
|
STUB
|
||||||
@@ -103,8 +123,9 @@ jq -e . >/dev/null 2>&1 <<<"$listing" || fail "list did not emit JSON: $listing"
|
|||||||
[[ "$(jq -r '.displays[0].connector' <<<"$listing")" == "DP-2" ]] \
|
[[ "$(jq -r '.displays[0].connector' <<<"$listing")" == "DP-2" ]] \
|
||||||
|| fail "the connector name must match Hyprland's output name: $listing"
|
|| fail "the connector name must match Hyprland's output name: $listing"
|
||||||
|
|
||||||
[[ "$(jq -r '.displays[0].bus' <<<"$listing")" == "5" ]] \
|
# The AUX bus, not the EDID bus its `ddc` symlink points at.
|
||||||
|| fail "the display was mapped to the wrong I2C bus: $listing"
|
[[ "$(jq -r '.displays[0].bus' <<<"$listing")" == "9" ]] \
|
||||||
|
|| fail "the display was mapped to its EDID bus instead of its DisplayPort AUX bus, where nothing answers: $listing"
|
||||||
|
|
||||||
# 120 of a maximum of 200 is 60%.
|
# 120 of a maximum of 200 is 60%.
|
||||||
[[ "$(jq -r '.displays[0].value' <<<"$listing")" == "60" ]] \
|
[[ "$(jq -r '.displays[0].value' <<<"$listing")" == "60" ]] \
|
||||||
@@ -123,12 +144,12 @@ if grep -qxE '4|7' "$DDCUTIL_PROBE_LOG"; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Writes scale to the reported maximum ─────────────────────────────────────
|
# ── Writes scale to the reported maximum ─────────────────────────────────────
|
||||||
run_helper set 5 40
|
run_helper set 9 40
|
||||||
[[ "$(tail -1 "$DDCUTIL_SET_LOG")" == "set 5 80" ]] \
|
[[ "$(tail -1 "$DDCUTIL_SET_LOG")" == "set 9 80" ]] \
|
||||||
|| fail "set did not scale to the display's maximum: $(cat "$DDCUTIL_SET_LOG")"
|
|| fail "set did not scale to the display's maximum: $(cat "$DDCUTIL_SET_LOG")"
|
||||||
|
|
||||||
run_helper set 5 500
|
run_helper set 9 500
|
||||||
[[ "$(tail -1 "$DDCUTIL_SET_LOG")" == "set 5 200" ]] \
|
[[ "$(tail -1 "$DDCUTIL_SET_LOG")" == "set 9 200" ]] \
|
||||||
|| fail "an out-of-range percent was not clamped: $(cat "$DDCUTIL_SET_LOG")"
|
|| fail "an out-of-range percent was not clamped: $(cat "$DDCUTIL_SET_LOG")"
|
||||||
|
|
||||||
# ── No I2C access explains itself ────────────────────────────────────────────
|
# ── No I2C access explains itself ────────────────────────────────────────────
|
||||||
|
|||||||
+122
@@ -0,0 +1,122 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# Every external command Panama's own scripts invoke must be installed by
|
||||||
|
# Panama's own package lists.
|
||||||
|
#
|
||||||
|
# This exists because the lists had drifted badly. jq is used by thirty-one call
|
||||||
|
# sites across the helpers and the contracts; kitty has a full shipped config
|
||||||
|
# and a dock pin; tmux and btop have shipped themes that the colour scheme
|
||||||
|
# switches. None of the four were declared. So a fresh machine that followed
|
||||||
|
# this repository's own install instructions would not have them.
|
||||||
|
#
|
||||||
|
# The failure is quiet by design, which is what makes it worth a test: the
|
||||||
|
# helpers are written to report "not installed" rather than crash, so a missing
|
||||||
|
# dependency presents as a feature that silently is not there.
|
||||||
|
#
|
||||||
|
# Commands from coreutils and the shell itself are not checked -- nothing
|
||||||
|
# installs those separately, and listing them would be noise.
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
printf 'declared dependencies contract: %s\n' "$1" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Shell syntax and builtins. These are not commands anyone installs, and the
|
||||||
|
# first version of this contract reported `then`, `esac` and `done` as missing
|
||||||
|
# packages, which buried the four real findings in a hundred lines of noise.
|
||||||
|
SHELL_WORDS='^(if|then|else|elif|fi|for|while|until|do|done|case|esac|in|function|select|time|coproc|break|continue|return|exit|local|readonly|declare|export|unset|shift|eval|exec|source|trap|set|shopt|alias|unalias|builtin|command|enable|help|let|read|mapfile|printf|echo|test|true|false|wait|jobs|bg|fg|kill|pwd|cd|dirs|pushd|popd|umask|type|hash|getopts|split|sync)$'
|
||||||
|
|
||||||
|
# Provided by any Fedora install: coreutils, util-linux, the shell, and the
|
||||||
|
# systemd/session tooling. Nothing here is a choice Panama makes.
|
||||||
|
BASELINE='^(sh|bash|cat|cut|sed|awk|gawk|grep|egrep|head|tail|sort|uniq|tr|wc|find|xargs|basename|dirname|mkdir|rm|cp|mv|ln|chmod|chown|stat|df|du|date|sleep|env|id|tee|touch|mktemp|readlink|realpath|seq|comm|join|paste|od|file|nl|fold|column|tput|timeout|flock|install|sha256sum|md5sum|base64|nproc|uptime|free|uname|hostname|whoami|ps|pgrep|pkill|kill|killall|lsblk|mount|umount|sudo|su|rpm|dnf|flatpak|git|python3|ss|ip|lsof)$'
|
||||||
|
|
||||||
|
SESSION='^(systemctl|busctl|journalctl|loginctl|hostnamectl|localectl|systemd-inhibit|systemd-run|udevadm|gsettings|dconf|dbus-send|dbus-monitor|hyprctl|qs|quickshell|gnf|panama|wl-copy|wl-paste)$'
|
||||||
|
|
||||||
|
declared="$(cat "$repo_dir"/setup/packages/* 2>/dev/null | sed 's/#.*//' | tr -d ' ' | grep -v '^$' | sort -u)"
|
||||||
|
[[ -n "$declared" ]] || fail 'no package lists found'
|
||||||
|
|
||||||
|
# A package is not always named after its command. Only the genuine mismatches
|
||||||
|
# are mapped, so an unmapped command is a real omission rather than a lookup
|
||||||
|
# failure.
|
||||||
|
package_for() {
|
||||||
|
case "$1" in
|
||||||
|
zbarimg) printf 'zbar' ;;
|
||||||
|
fc-list|fc-match) printf 'fontconfig' ;;
|
||||||
|
lspci) printf 'pciutils' ;;
|
||||||
|
getenforce) printf 'libselinux-utils' ;;
|
||||||
|
nmcli) printf 'NetworkManager' ;;
|
||||||
|
wpctl) printf 'wireplumber' ;;
|
||||||
|
nvim) printf 'neovim' ;;
|
||||||
|
fwupdmgr) printf 'fwupd' ;;
|
||||||
|
dnf4) printf 'python3-dnf' ;;
|
||||||
|
notify-send) printf 'libnotify' ;;
|
||||||
|
wl-copy|wl-paste) printf 'wl-clipboard' ;;
|
||||||
|
rg) printf 'ripgrep' ;;
|
||||||
|
python3) printf 'python3' ;;
|
||||||
|
*) printf '%s' "$1" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
missing=()
|
||||||
|
checked=0
|
||||||
|
|
||||||
|
while read -r script; do
|
||||||
|
[[ -n "$script" ]] || continue
|
||||||
|
head -1 "$script" | grep -qE 'bash|/sh' || continue
|
||||||
|
|
||||||
|
# Commands appearing at the start of a statement or after a pipe. Crude, but
|
||||||
|
# it is looking for undeclared dependencies, not building a call graph.
|
||||||
|
#
|
||||||
|
# No minimum length. An earlier version required three characters, which
|
||||||
|
# quietly excluded the most-used dependency in the repository -- jq, at
|
||||||
|
# thirty-one call sites -- along with rg, ss and ip. A dependency checker
|
||||||
|
# with a blind spot for short names is worse than none, because it reports
|
||||||
|
# PASS.
|
||||||
|
while read -r cmd; do
|
||||||
|
[[ -n "$cmd" ]] || continue
|
||||||
|
[[ "$cmd" =~ $SHELL_WORDS ]] && continue
|
||||||
|
[[ "$cmd" =~ $BASELINE ]] && continue
|
||||||
|
[[ "$cmd" =~ $SESSION ]] && continue
|
||||||
|
|
||||||
|
pkg="$(package_for "$cmd")"
|
||||||
|
grep -qx "$pkg" <<<"$declared" && continue
|
||||||
|
|
||||||
|
# Only report a command that actually exists on this machine. An
|
||||||
|
# invented name in a comment or a heredoc is a false positive; a real
|
||||||
|
# binary that nothing declares is the thing being looked for.
|
||||||
|
command -v "$cmd" >/dev/null 2>&1 || continue
|
||||||
|
|
||||||
|
missing+=("$cmd (from $(basename "$script"), package: $pkg)")
|
||||||
|
done < <({
|
||||||
|
# Statement-initial or after a pipe.
|
||||||
|
grep -oE '(^|[|;&]|\$\()[[:space:]]*[a-z][a-z0-9_-]+' "$script" \
|
||||||
|
| grep -oE '[a-z][a-z0-9_-]+$'
|
||||||
|
|
||||||
|
# Behind a wrapper. ddcutil is always invoked as `timeout 10 ddcutil`,
|
||||||
|
# so it never appears statement-initial and was missed entirely.
|
||||||
|
grep -oE '\b(timeout[[:space:]]+[0-9.]+|sudo|nohup|env)[[:space:]]+[a-z][a-z0-9_-]+' "$script" \
|
||||||
|
| grep -oE '[a-z][a-z0-9_-]+$'
|
||||||
|
|
||||||
|
# `command -v X` is how these helpers probe for a tool before using it,
|
||||||
|
# which makes it the clearest possible statement of a dependency.
|
||||||
|
grep -oE 'command -v[[:space:]]+[a-z][a-z0-9_-]+' "$script" \
|
||||||
|
| grep -oE '[a-z][a-z0-9_-]+$'
|
||||||
|
} | sort -u)
|
||||||
|
|
||||||
|
checked=$((checked + 1))
|
||||||
|
done < <(find "$repo_dir/config/dot/quickshell/scripts" \
|
||||||
|
"$repo_dir/config/local/share/vicinae/scripts" \
|
||||||
|
"$repo_dir/setup/scripts" "$repo_dir/bin" \
|
||||||
|
-type f 2>/dev/null)
|
||||||
|
|
||||||
|
if (( ${#missing[@]} > 0 )); then
|
||||||
|
printf 'declared dependencies contract: commands used but never installed:\n' >&2
|
||||||
|
printf ' %s\n' "${missing[@]}" | sort -u >&2
|
||||||
|
fail 'add each to a list in setup/packages/, or the feature silently will not exist on a fresh machine'
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf 'declared dependencies contract: PASS (%d scripts)\n' "$checked"
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user