Compare commits
55
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
09e5f1ad6b | ||
|
|
7541a45e77 | ||
|
|
1a91d00f2c | ||
|
|
19063a3e02 |
@@ -13,3 +13,11 @@
|
|||||||
# Python helper bytecode is local runtime state.
|
# Python helper bytecode is local runtime state.
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
|
|
||||||
|
# Generated from the colour scheme; machine state, not configuration.
|
||||||
|
/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 ────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
+41
-11
@@ -36,7 +36,9 @@ hl.config({
|
|||||||
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 +46,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 +91,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 +122,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 +202,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 = {
|
||||||
|
|||||||
@@ -183,7 +183,6 @@ font_size 14.0
|
|||||||
|
|
||||||
#: Cursor customization {{{
|
#: Cursor customization {{{
|
||||||
|
|
||||||
cursor #c8d3f5
|
|
||||||
|
|
||||||
#: Default cursor color. If set to the special value none the cursor
|
#: Default cursor color. If set to the special value none the cursor
|
||||||
#: will be rendered with a "reverse video" effect. It's color will be
|
#: will be rendered with a "reverse video" effect. It's color will be
|
||||||
@@ -193,7 +192,6 @@ cursor #c8d3f5
|
|||||||
#: precedence. Also, the cursor colors are modified if the cell
|
#: precedence. Also, the cursor colors are modified if the cell
|
||||||
#: background and foreground colors have very low contrast.
|
#: background and foreground colors have very low contrast.
|
||||||
|
|
||||||
cursor_text_color #111325
|
|
||||||
|
|
||||||
#: The color of text under the cursor. If you want it rendered with
|
#: The color of text under the cursor. If you want it rendered with
|
||||||
#: the background color of the cell underneath instead, use the
|
#: the background color of the cell underneath instead, use the
|
||||||
@@ -312,7 +310,6 @@ cursor_shape block
|
|||||||
#: robustly with the ever-changing sea of bugs that is Cocoa is too
|
#: robustly with the ever-changing sea of bugs that is Cocoa is too
|
||||||
#: much effort.
|
#: much effort.
|
||||||
|
|
||||||
url_color #4fd6be
|
|
||||||
# url_style curly
|
# url_style curly
|
||||||
|
|
||||||
#: The color and style for highlighting URLs on mouse-over. url_style
|
#: The color and style for highlighting URLs on mouse-over. url_style
|
||||||
@@ -734,12 +731,10 @@ window_border_width 1pt
|
|||||||
#: placed centrally. A value of top-left means the padding will be
|
#: placed centrally. A value of top-left means the padding will be
|
||||||
#: only at the bottom and right edges.
|
#: only at the bottom and right edges.
|
||||||
|
|
||||||
active_border_color #82aaff
|
|
||||||
|
|
||||||
#: The color for the border of the active window. Set this to none to
|
#: The color for the border of the active window. Set this to none to
|
||||||
#: not draw borders around the active window.
|
#: not draw borders around the active window.
|
||||||
|
|
||||||
inactive_border_color #2f334d
|
|
||||||
|
|
||||||
#: The color for the border of inactive windows.
|
#: The color for the border of inactive windows.
|
||||||
|
|
||||||
@@ -960,16 +955,11 @@ tab_powerline_style round
|
|||||||
#: Template to use for active tabs. If not specified falls back to
|
#: Template to use for active tabs. If not specified falls back to
|
||||||
#: tab_title_template.
|
#: tab_title_template.
|
||||||
|
|
||||||
active_tab_foreground #1e2030
|
|
||||||
active_tab_background #82aaff
|
|
||||||
# active_tab_font_style bold-italic
|
# active_tab_font_style bold-italic
|
||||||
inactive_tab_foreground #c8d3f5
|
|
||||||
inactive_tab_background #42465a
|
|
||||||
# inactive_tab_font_style normal
|
# inactive_tab_font_style normal
|
||||||
|
|
||||||
#: Tab bar colors and styles.
|
#: Tab bar colors and styles.
|
||||||
|
|
||||||
tab_bar_background #222436
|
|
||||||
|
|
||||||
#: Background color for the tab bar. Defaults to using the terminal
|
#: Background color for the tab bar. Defaults to using the terminal
|
||||||
#: background color.
|
#: background color.
|
||||||
@@ -983,8 +973,8 @@ tab_bar_margin_color none
|
|||||||
|
|
||||||
#: Color scheme {{{
|
#: Color scheme {{{
|
||||||
|
|
||||||
foreground #c8d3f5
|
|
||||||
background #222436
|
include current-theme.conf
|
||||||
|
|
||||||
#: The foreground and background colors.
|
#: The foreground and background colors.
|
||||||
|
|
||||||
@@ -1043,8 +1033,6 @@ dim_opacity 1
|
|||||||
#: How much to dim text that has the DIM/FAINT attribute set. One
|
#: How much to dim text that has the DIM/FAINT attribute set. One
|
||||||
#: means no dimming and zero means fully dimmed (i.e. invisible).
|
#: means no dimming and zero means fully dimmed (i.e. invisible).
|
||||||
|
|
||||||
selection_foreground #2d3f76
|
|
||||||
selection_background #c8d3f5
|
|
||||||
|
|
||||||
#: The foreground and background colors for text selected with the
|
#: The foreground and background colors for text selected with the
|
||||||
#: mouse. Setting both of these to none will cause a "reverse video"
|
#: mouse. Setting both of these to none will cause a "reverse video"
|
||||||
@@ -1060,47 +1048,29 @@ selection_background #c8d3f5
|
|||||||
#: dull and bright version, for the first 16 colors. You can set the
|
#: dull and bright version, for the first 16 colors. You can set the
|
||||||
#: remaining 240 colors as color16 to color255.
|
#: remaining 240 colors as color16 to color255.
|
||||||
|
|
||||||
color0 #1b1d2b
|
|
||||||
color8 #444a73
|
|
||||||
|
|
||||||
#: black
|
#: black
|
||||||
|
|
||||||
color1 #ff757f
|
|
||||||
color9 #ff757f
|
|
||||||
|
|
||||||
#: red
|
#: red
|
||||||
|
|
||||||
color2 #a5e8b5
|
|
||||||
color10 #a5e8b5
|
|
||||||
|
|
||||||
#: green
|
#: green
|
||||||
color3 #ffc777
|
|
||||||
color11 #ffc777
|
|
||||||
|
|
||||||
#: yellow
|
#: yellow
|
||||||
|
|
||||||
color4 #82aaff
|
|
||||||
color12 #82aaff
|
|
||||||
|
|
||||||
#: blue
|
#: blue
|
||||||
|
|
||||||
color5 #c099ff
|
|
||||||
color13 #c099ff
|
|
||||||
|
|
||||||
#: magenta
|
#: magenta
|
||||||
|
|
||||||
color6 #86e1fc
|
|
||||||
color14 #86e1fc
|
|
||||||
|
|
||||||
#: cyan
|
#: cyan
|
||||||
|
|
||||||
color7 #828bb8
|
|
||||||
color15 #c8d3f5
|
|
||||||
|
|
||||||
#: white
|
#: white
|
||||||
|
|
||||||
color16 #ff966c
|
|
||||||
color17 #c53b53
|
|
||||||
|
|
||||||
# mark1_foreground black
|
# mark1_foreground black
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# Tokyo Night Day — the palette's own light variant.
|
||||||
|
#
|
||||||
|
# The same hues as Moon at a different lightness, so a terminal in light mode
|
||||||
|
# still belongs to this desktop rather than looking like a different machine.
|
||||||
|
# Kept in the same order as tokyonight-moon.conf so the two can be diffed.
|
||||||
|
|
||||||
|
cursor #3760bf
|
||||||
|
cursor_text_color #e1e2e7
|
||||||
|
url_color #118c74
|
||||||
|
active_border_color #2e7de9
|
||||||
|
inactive_border_color #c4c8da
|
||||||
|
active_tab_foreground #e1e2e7
|
||||||
|
active_tab_background #2e7de9
|
||||||
|
inactive_tab_foreground #3760bf
|
||||||
|
inactive_tab_background #c4c8da
|
||||||
|
tab_bar_background #e1e2e7
|
||||||
|
foreground #3760bf
|
||||||
|
background #e1e2e7
|
||||||
|
selection_foreground #e1e2e7
|
||||||
|
selection_background #99a7df
|
||||||
|
color0 #e9e9ed
|
||||||
|
color8 #a1a6c5
|
||||||
|
color1 #f52a65
|
||||||
|
color9 #f52a65
|
||||||
|
color2 #587539
|
||||||
|
color10 #587539
|
||||||
|
color3 #8c6c3e
|
||||||
|
color11 #8c6c3e
|
||||||
|
color4 #2e7de9
|
||||||
|
color12 #2e7de9
|
||||||
|
color5 #9854f1
|
||||||
|
color13 #9854f1
|
||||||
|
color6 #007197
|
||||||
|
color14 #007197
|
||||||
|
color7 #6172b0
|
||||||
|
color15 #3760bf
|
||||||
|
color16 #b15c00
|
||||||
|
color17 #c64343
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# Tokyo Night Moon — the dark theme Panama ships.
|
||||||
|
#
|
||||||
|
# Extracted from kitty.conf so the two schemes can be swapped. kitty.conf
|
||||||
|
# includes current-theme.conf, which Panama generates from the colour scheme
|
||||||
|
# setting; that generated file is gitignored because it is machine state.
|
||||||
|
#
|
||||||
|
# Live changes go through `kitty @ set-colors`, which is why kitty.conf enables
|
||||||
|
# remote control. Without it a scheme change would only reach terminals opened
|
||||||
|
# afterwards.
|
||||||
|
|
||||||
|
cursor #c8d3f5
|
||||||
|
cursor_text_color #111325
|
||||||
|
url_color #4fd6be
|
||||||
|
active_border_color #82aaff
|
||||||
|
inactive_border_color #2f334d
|
||||||
|
active_tab_foreground #1e2030
|
||||||
|
active_tab_background #82aaff
|
||||||
|
inactive_tab_foreground #c8d3f5
|
||||||
|
inactive_tab_background #42465a
|
||||||
|
tab_bar_background #222436
|
||||||
|
foreground #c8d3f5
|
||||||
|
background #222436
|
||||||
|
selection_foreground #2d3f76
|
||||||
|
selection_background #c8d3f5
|
||||||
|
color0 #1b1d2b
|
||||||
|
color8 #444a73
|
||||||
|
color1 #ff757f
|
||||||
|
color9 #ff757f
|
||||||
|
color2 #a5e8b5
|
||||||
|
color10 #a5e8b5
|
||||||
|
color3 #ffc777
|
||||||
|
color11 #ffc777
|
||||||
|
color4 #82aaff
|
||||||
|
color12 #82aaff
|
||||||
|
color5 #c099ff
|
||||||
|
color13 #c099ff
|
||||||
|
color6 #86e1fc
|
||||||
|
color14 #86e1fc
|
||||||
|
color7 #828bb8
|
||||||
|
color15 #c8d3f5
|
||||||
|
color16 #ff966c
|
||||||
|
color17 #c53b53
|
||||||
@@ -10,3 +10,6 @@ vim.api.nvim_create_autocmd("BufWritePre", {
|
|||||||
vim.fn.winrestview(view)
|
vim.fn.winrestview(view)
|
||||||
end,
|
end,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
-- Follow the desktop's light/dark setting while running, not only at startup.
|
||||||
|
require("config.panama").watch()
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
-- Panama desktop integration.
|
||||||
|
--
|
||||||
|
-- Neovim is the one application here that neither reads the desktop portal nor
|
||||||
|
-- has a control socket open by default, so it reads the shared settings file
|
||||||
|
-- directly -- the same ~/.config/panama/settings.json that the shell and the
|
||||||
|
-- Hyprland config read.
|
||||||
|
--
|
||||||
|
-- Nothing here may raise. A missing or malformed settings file must cost the
|
||||||
|
-- user their colour scheme preference and nothing else; editing text is more
|
||||||
|
-- important than matching the desktop.
|
||||||
|
|
||||||
|
local M = {}
|
||||||
|
|
||||||
|
local function settings_path()
|
||||||
|
local config_home = os.getenv("XDG_CONFIG_HOME")
|
||||||
|
if config_home == nil or config_home == "" then
|
||||||
|
local home = os.getenv("HOME")
|
||||||
|
if home == nil or home == "" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
config_home = home .. "/.config"
|
||||||
|
end
|
||||||
|
return config_home .. "/panama/settings.json"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- "dark" or "light". Defaults to dark, which is what Panama ships.
|
||||||
|
function M.color_scheme()
|
||||||
|
local path = settings_path()
|
||||||
|
if not path then
|
||||||
|
return "dark"
|
||||||
|
end
|
||||||
|
|
||||||
|
local ok, contents = pcall(function()
|
||||||
|
local file = io.open(path, "r")
|
||||||
|
if not file then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local text = file:read("*a")
|
||||||
|
file:close()
|
||||||
|
return text
|
||||||
|
end)
|
||||||
|
if not ok or not contents or contents == "" then
|
||||||
|
return "dark"
|
||||||
|
end
|
||||||
|
|
||||||
|
local decoded_ok, decoded = pcall(vim.json.decode, contents)
|
||||||
|
if not decoded_ok or type(decoded) ~= "table" then
|
||||||
|
return "dark"
|
||||||
|
end
|
||||||
|
|
||||||
|
return decoded.colorScheme == "light" and "light" or "dark"
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.is_light()
|
||||||
|
return M.color_scheme() == "light"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- tokyonight ships a light variant in the same family, so light mode stays the
|
||||||
|
-- same identity rather than becoming a different editor theme.
|
||||||
|
function M.tokyonight_style()
|
||||||
|
return M.is_light() and "day" or "moon"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Re-apply the scheme when the window regains focus.
|
||||||
|
--
|
||||||
|
-- Neovim reads the setting once at startup and has no control socket open by
|
||||||
|
-- default, so an editor already running when the desktop scheme flips would
|
||||||
|
-- otherwise stay on the old palette until it was restarted. FocusGained is
|
||||||
|
-- cheap, happens exactly when you would notice the mismatch, and does nothing
|
||||||
|
-- at all unless the scheme actually changed.
|
||||||
|
function M.watch()
|
||||||
|
local applied = M.color_scheme()
|
||||||
|
|
||||||
|
vim.api.nvim_create_autocmd("FocusGained", {
|
||||||
|
group = vim.api.nvim_create_augroup("PanamaColorScheme", { clear = true }),
|
||||||
|
callback = function()
|
||||||
|
local current = M.color_scheme()
|
||||||
|
if current == applied then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
applied = current
|
||||||
|
vim.o.background = current
|
||||||
|
pcall(vim.cmd.colorscheme, current == "light" and "tokyonight-day" or "tokyonight-moon")
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
@@ -1,31 +1,68 @@
|
|||||||
|
-- Tokyo Night, following the desktop's colour scheme.
|
||||||
|
--
|
||||||
|
-- Moon when Panama is dark, Day when it is light. Same theme family either way,
|
||||||
|
-- so the editor keeps the identity the rest of the desktop has rather than
|
||||||
|
-- becoming a different-looking application when the scheme flips.
|
||||||
|
--
|
||||||
|
-- The readability fixes below are deliberately dark-only. They were written
|
||||||
|
-- against Moon's palette -- a pale comment colour, a mid-grey gutter -- and
|
||||||
|
-- applying them to Day would put light grey text on a light background, which
|
||||||
|
-- is exactly the legibility problem they exist to solve, inverted.
|
||||||
|
|
||||||
|
local panama = require("config.panama")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
{
|
{
|
||||||
"folke/tokyonight.nvim",
|
"folke/tokyonight.nvim",
|
||||||
opts = {
|
opts = function()
|
||||||
style = "moon",
|
local light = panama.is_light()
|
||||||
transparent = true,
|
|
||||||
on_colors = function(colors)
|
return {
|
||||||
colors.comment = "#a0a7c5"
|
style = panama.tokyonight_style(),
|
||||||
colors.fg_gutter = "#787f93"
|
light_style = "day",
|
||||||
colors.terminal_black = "#828bb8"
|
transparent = true,
|
||||||
end,
|
|
||||||
on_highlights = function(highlights, colors)
|
on_colors = function(colors)
|
||||||
-- Fix inline code visibility in markdown
|
if light then
|
||||||
highlights["@markup.raw.markdown_inline"] = {
|
-- Day's defaults are tuned for a light ground and mostly need no
|
||||||
bg = colors.terminal_black,
|
-- help. Comments are the exception: the shipped #848cb5 measures
|
||||||
fg = colors.fg,
|
-- 2.54:1 against the #e1e2e7 background, well under the 3:1 floor
|
||||||
}
|
-- for secondary text. This is 3.25:1 -- readable, and still clearly
|
||||||
highlights["RenderMarkdownCodeInline"] = {
|
-- dimmer than Normal's 4.52:1 so it does not compete with code.
|
||||||
bg = colors.terminal_black,
|
colors.comment = "#7079a8"
|
||||||
fg = colors.fg,
|
return
|
||||||
}
|
end
|
||||||
-- Fix LspReference* readability: DiagnosticUnnecessary dims fg for unused
|
|
||||||
-- imports, making text nearly invisible against LspReferenceText's background
|
colors.comment = "#a0a7c5"
|
||||||
highlights["LspReferenceText"] = { bg = colors.fg_gutter, fg = colors.fg }
|
colors.fg_gutter = "#787f93"
|
||||||
highlights["LspReferenceRead"] = { bg = colors.fg_gutter, fg = colors.fg }
|
colors.terminal_black = "#828bb8"
|
||||||
highlights["LspReferenceWrite"] = { bg = colors.fg_gutter, fg = colors.fg }
|
end,
|
||||||
end,
|
|
||||||
},
|
on_highlights = function(highlights, colors)
|
||||||
|
-- Inline code in markdown is invisible at both lightnesses without
|
||||||
|
-- an explicit background, because the theme leaves it unset.
|
||||||
|
highlights["@markup.raw.markdown_inline"] = {
|
||||||
|
bg = light and colors.bg_highlight or colors.terminal_black,
|
||||||
|
fg = colors.fg,
|
||||||
|
}
|
||||||
|
highlights["RenderMarkdownCodeInline"] = {
|
||||||
|
bg = light and colors.bg_highlight or colors.terminal_black,
|
||||||
|
fg = colors.fg,
|
||||||
|
}
|
||||||
|
|
||||||
|
-- LspReference* readability: DiagnosticUnnecessary dims fg for unused
|
||||||
|
-- imports, making text nearly invisible against LspReferenceText's
|
||||||
|
-- background.
|
||||||
|
local reference = {
|
||||||
|
bg = light and colors.bg_visual or colors.fg_gutter,
|
||||||
|
fg = colors.fg,
|
||||||
|
}
|
||||||
|
highlights["LspReferenceText"] = reference
|
||||||
|
highlights["LspReferenceRead"] = reference
|
||||||
|
highlights["LspReferenceWrite"] = reference
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
end,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"LazyVim/LazyVim",
|
"LazyVim/LazyVim",
|
||||||
|
|||||||
@@ -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" }
|
||||||
},
|
},
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -1,24 +1,44 @@
|
|||||||
// Backlight slider, via brightnessctl.
|
// Brightness, from whichever source this machine actually has.
|
||||||
//
|
//
|
||||||
// This machine drives an external DisplayPort monitor and has no backlight
|
// Two exist and they are not interchangeable:
|
||||||
// class device at all (brightnessctl only reports keyboard/NIC LEDs), so the
|
//
|
||||||
// row removes itself rather than sitting there as a dead control. Probed once
|
// The kernel backlight class, driven by brightnessctl. Laptop panels have it;
|
||||||
// at startup — backlight devices do not appear and disappear.
|
// this desktop does not -- brightnessctl reports only keyboard and NIC LEDs.
|
||||||
|
//
|
||||||
|
// DDC/CI, the channel the buttons on a monitor's bezel drive. That is the
|
||||||
|
// only brightness an external display has, and it is per-monitor.
|
||||||
|
//
|
||||||
|
// A machine may have neither, either, or both, so this renders a row per source
|
||||||
|
// found and removes itself entirely when there are none, rather than sitting
|
||||||
|
// there as a dead control.
|
||||||
|
//
|
||||||
|
// Connector labels appear only when there is more than one row. A single
|
||||||
|
// slider needs no explanation of which screen it dims.
|
||||||
|
|
||||||
import QtQuick
|
import QtQuick
|
||||||
import Quickshell
|
import Quickshell
|
||||||
import Quickshell.Io
|
import Quickshell.Io
|
||||||
import qs.widgets
|
import qs.widgets
|
||||||
import qs.config
|
import qs.config
|
||||||
|
import qs.services
|
||||||
|
|
||||||
Item {
|
Item {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
property bool available: false
|
property bool backlightAvailable: false
|
||||||
property real value: 0
|
property real backlightValue: 0
|
||||||
|
|
||||||
visible: root.available
|
readonly property int rowCount: (root.backlightAvailable ? 1 : 0) + Brightness.displays.length
|
||||||
implicitHeight: root.available ? 32 : 0
|
readonly property bool labelled: root.rowCount > 1
|
||||||
|
|
||||||
|
visible: root.rowCount > 0
|
||||||
|
implicitHeight: rows.implicitHeight
|
||||||
|
|
||||||
|
// Probing I2C takes on the order of a second, so it waits until the panel
|
||||||
|
// is actually on screen rather than running at shell startup. Monitors do
|
||||||
|
// not come and go, so once is enough.
|
||||||
|
onVisibleChanged: if (visible && !Brightness.scanned) Brightness.refresh()
|
||||||
|
Component.onCompleted: if (root.visible && !Brightness.scanned) Brightness.refresh()
|
||||||
|
|
||||||
// `-m` is the machine-readable form: name,class,current,percent,max
|
// `-m` is the machine-readable form: name,class,current,percent,max
|
||||||
Process {
|
Process {
|
||||||
@@ -34,36 +54,105 @@ Item {
|
|||||||
const fields = line.split(",");
|
const fields = line.split(",");
|
||||||
if (fields.length < 5 || fields[1] !== "backlight")
|
if (fields.length < 5 || fields[1] !== "backlight")
|
||||||
continue;
|
continue;
|
||||||
root.available = true;
|
root.backlightAvailable = true;
|
||||||
root.value = parseInt(fields[3]) / 100;
|
root.backlightValue = parseInt(fields[3]) / 100;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function apply(v: real): void {
|
function applyBacklight(v: real): void {
|
||||||
root.value = v;
|
root.backlightValue = v;
|
||||||
// Never go fully dark: a 0% backlight looks like a broken shell.
|
// Never go fully dark: a 0% backlight looks like a broken shell.
|
||||||
Quickshell.execDetached(["brightnessctl", "-c", "backlight", "-q", "set", Math.max(1, Math.round(v * 100)) + "%"]);
|
Quickshell.execDetached(["brightnessctl", "-c", "backlight", "-q", "set", Math.max(1, Math.round(v * 100)) + "%"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ValueSlider draws its own leading icon, but symbolic icons need
|
Column {
|
||||||
// recolouring to be visible — see ThemedIcon.
|
id: rows
|
||||||
ThemedIcon {
|
|
||||||
id: glyph
|
|
||||||
anchors.left: parent.left
|
anchors.left: parent.left
|
||||||
anchors.leftMargin: 6
|
anchors.right: parent.right
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
spacing: 4
|
||||||
size: 17
|
|
||||||
icon: "display-brightness-symbolic"
|
BrightnessRow {
|
||||||
|
width: rows.width
|
||||||
|
visible: root.backlightAvailable
|
||||||
|
label: "Built-in"
|
||||||
|
value: root.backlightValue
|
||||||
|
onMoved: v => root.applyBacklight(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: Brightness.displays
|
||||||
|
|
||||||
|
BrightnessRow {
|
||||||
|
required property var modelData
|
||||||
|
width: rows.width
|
||||||
|
// Hyprland already knows what each output is called, so the
|
||||||
|
// name comes from there rather than from a second source that
|
||||||
|
// could disagree with the Displays page. The connector is the
|
||||||
|
// fallback, so a display is never an unlabelled slider.
|
||||||
|
label: Displays.monitorNamed(modelData.connector)?.description || modelData.connector
|
||||||
|
value: modelData.value / 100
|
||||||
|
onMoved: v => Brightness.set(modelData.bus, Math.round(v * 100))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ValueSlider {
|
component BrightnessRow: Item {
|
||||||
anchors.left: glyph.right
|
id: row
|
||||||
anchors.leftMargin: 12
|
|
||||||
anchors.right: parent.right
|
property string label: ""
|
||||||
anchors.rightMargin: 32
|
property real value: 0
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
signal moved(real value)
|
||||||
value: root.value
|
|
||||||
onMoved: v => root.apply(v)
|
implicitHeight: caption.height + control.height
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: caption
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.leftMargin: 6
|
||||||
|
anchors.rightMargin: 6
|
||||||
|
anchors.top: parent.top
|
||||||
|
visible: root.labelled
|
||||||
|
height: visible ? implicitHeight + 2 : 0
|
||||||
|
text: row.label
|
||||||
|
color: Theme.fgDim
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
|
||||||
|
// The slider and its glyph share one strip so the two stay aligned
|
||||||
|
// whether or not a caption sits above them. Anchoring the glyph to
|
||||||
|
// both a caption and a centre line instead would conflict, and an
|
||||||
|
// anchor set to undefined is not released.
|
||||||
|
Item {
|
||||||
|
id: control
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.top: caption.bottom
|
||||||
|
height: 32
|
||||||
|
|
||||||
|
// ValueSlider draws its own leading icon, but symbolic icons need
|
||||||
|
// recolouring to be visible — see ThemedIcon.
|
||||||
|
ThemedIcon {
|
||||||
|
id: glyph
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.leftMargin: 6
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
size: 17
|
||||||
|
icon: "display-brightness-symbolic"
|
||||||
|
}
|
||||||
|
|
||||||
|
ValueSlider {
|
||||||
|
anchors.left: glyph.right
|
||||||
|
anchors.leftMargin: 12
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.rightMargin: 32
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
value: row.value
|
||||||
|
onMoved: v => row.moved(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Displays.
|
// Displays.
|
||||||
//
|
//
|
||||||
// Resolution, refresh rate, scale, and rotation, plus the gaming display
|
// Resolution, refresh rate, scale, and rotation, plus panel brightness and the
|
||||||
// policy that was already here.
|
// gaming display policy that was already here.
|
||||||
//
|
//
|
||||||
// Every geometry change goes through an apply-then-confirm countdown. This is
|
// Every geometry change goes through an apply-then-confirm countdown. This is
|
||||||
// the one page where a wrong value can leave the screen unreadable or blank,
|
// the one page where a wrong value can leave the screen unreadable or blank,
|
||||||
@@ -32,7 +32,14 @@ SettingsPage {
|
|||||||
root.selectedOutput = Displays.monitors.length > 0 ? Displays.monitors[0].name : "";
|
root.selectedOutput = Displays.monitors.length > 0 ? Displays.monitors[0].name : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
Component.onCompleted: root.syncSelectedOutput()
|
// Probing I2C for DDC-capable monitors takes on the order of a second, so
|
||||||
|
// it runs when this page is opened rather than at shell startup. Monitors
|
||||||
|
// do not appear while you are looking at a settings page, so once is enough.
|
||||||
|
Component.onCompleted: {
|
||||||
|
root.syncSelectedOutput();
|
||||||
|
if (!Brightness.scanned)
|
||||||
|
Brightness.refresh();
|
||||||
|
}
|
||||||
Connections {
|
Connections {
|
||||||
target: Displays
|
target: Displays
|
||||||
function onMonitorsChanged(): void { root.syncSelectedOutput(); }
|
function onMonitorsChanged(): void { root.syncSelectedOutput(); }
|
||||||
@@ -142,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)
|
||||||
@@ -200,9 +207,48 @@ SettingsPage {
|
|||||||
SliderRow { setting: "nightLightTemperature"; divider: false }
|
SliderRow { setting: "nightLightTemperature"; divider: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Panel brightness, over DDC/CI.
|
||||||
|
//
|
||||||
|
// This is hardware state rather than a stored preference: the monitor
|
||||||
|
// remembers it, the bezel buttons change it behind Panama's back, and
|
||||||
|
// writing it into settings.json would mean restoring a value the panel had
|
||||||
|
// already moved on from. So there is no schema key here and no SliderRow --
|
||||||
|
// the rows read and write the display directly.
|
||||||
|
SettingsCard {
|
||||||
|
visible: Brightness.available || Brightness.lastError !== ""
|
||||||
|
title: "Brightness"
|
||||||
|
subtitle: Brightness.available
|
||||||
|
? "Sent to the monitor over DDC/CI, the same channel its buttons use."
|
||||||
|
: Brightness.lastError
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: Brightness.displays
|
||||||
|
|
||||||
|
SettingRow {
|
||||||
|
id: brightnessRow
|
||||||
|
required property var modelData
|
||||||
|
required property int index
|
||||||
|
|
||||||
|
label: Displays.monitorNamed(modelData.connector)?.description || modelData.connector
|
||||||
|
detail: modelData.connector ? modelData.connector + " · " + modelData.value + "%"
|
||||||
|
: modelData.value + "%"
|
||||||
|
divider: brightnessRow.index < Brightness.displays.length - 1
|
||||||
|
controlWidth: 190
|
||||||
|
|
||||||
|
ValueSlider {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
anchors.right: parent.right
|
||||||
|
width: parent.width
|
||||||
|
value: brightnessRow.modelData.value / 100
|
||||||
|
onMoved: v => Brightness.set(brightnessRow.modelData.bus, Math.round(v * 100))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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.
|
||||||
@@ -83,11 +110,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
|
||||||
|
|||||||
+157
@@ -0,0 +1,157 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# External monitor brightness over DDC/CI.
|
||||||
|
#
|
||||||
|
# A desktop with no backlight class device has no brightness control at all --
|
||||||
|
# brightnessctl only sees keyboard and NIC LEDs. The panel itself still has a
|
||||||
|
# brightness setting, reachable over the monitor's DDC/CI channel (VCP feature
|
||||||
|
# 0x10), which is what the buttons on the bezel drive.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# panama-brightness list -> {"displays":[...],"error":""}
|
||||||
|
# panama-brightness get <bus> -> integer percent
|
||||||
|
# panama-brightness set <bus> <pct> -> applies, prints nothing
|
||||||
|
#
|
||||||
|
# Displays are enumerated from sysfs rather than from `ddcutil detect`. The
|
||||||
|
# kernel publishes the connector-to-I2C-bus mapping directly, as
|
||||||
|
# /sys/class/drm/<card>-<connector>/ddc, along with whether anything is plugged
|
||||||
|
# in. That is better than parsing detect output in three ways: the format is
|
||||||
|
# stable where detect's brief output is undocumented, the connector name comes
|
||||||
|
# out exactly as Hyprland and the Displays page already spell it (DP-2), and
|
||||||
|
# only connectors with a monitor attached get probed -- one bus on this machine
|
||||||
|
# instead of fourteen, which is the difference between a fast scan and a slow
|
||||||
|
# one, since each probe of an empty bus waits for a timeout.
|
||||||
|
#
|
||||||
|
# No model name is reported. Hyprland already knows the human-readable
|
||||||
|
# description of every output, so the UI joins on the connector name rather than
|
||||||
|
# having two sources of truth for what a monitor is called.
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
readonly VCP_BRIGHTNESS=0x10
|
||||||
|
|
||||||
|
# Test seams. The contract needs to exercise enumeration and parsing on a
|
||||||
|
# machine whose real monitors it must not touch, so both roots this script
|
||||||
|
# reads are overridable. Nothing sets them in normal use.
|
||||||
|
readonly DRM_ROOT="${PANAMA_BRIGHTNESS_DRM_ROOT:-/sys/class/drm}"
|
||||||
|
readonly DEV_ROOT="${PANAMA_BRIGHTNESS_DEV_ROOT:-/dev}"
|
||||||
|
|
||||||
|
emit_error() {
|
||||||
|
printf '{"displays":[],"error":%s}\n' "$(jq -Rn --arg e "$1" '$e')"
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
command -v ddcutil >/dev/null 2>&1 || emit_error 'ddcutil is not installed'
|
||||||
|
|
||||||
|
# Reading a VCP value needs read/write access to the monitor's I2C bus. The
|
||||||
|
# udev rule ddcutil ships grants that to the seat user through uaccess, but only
|
||||||
|
# to devices created after the rule was installed -- so a machine that installed
|
||||||
|
# ddcutil without rebooting has the rule in place and no access to show for it.
|
||||||
|
# That is by far the most likely reason for an empty list, and it is fixable in
|
||||||
|
# one command, so say so rather than reporting "no displays".
|
||||||
|
has_accessible_bus() {
|
||||||
|
local dev
|
||||||
|
for dev in "$DEV_ROOT"/i2c-*; do
|
||||||
|
[[ -r "$dev" && -w "$dev" ]] && return 0
|
||||||
|
done
|
||||||
|
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() {
|
||||||
|
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'
|
||||||
|
|
||||||
|
local rows=() connector bus value path
|
||||||
|
for path in "$DRM_ROOT"/card*-*; do
|
||||||
|
[[ -e "$path/ddc" ]] || continue
|
||||||
|
[[ "$(cat "$path/status" 2>/dev/null)" == "connected" ]] || continue
|
||||||
|
|
||||||
|
# card1-DP-2 -> DP-2, the name Hyprland uses.
|
||||||
|
connector="$(basename "$path")"
|
||||||
|
connector="${connector#card*-}"
|
||||||
|
|
||||||
|
bus="$(bus_for_connector "$path")"
|
||||||
|
[[ "$bus" =~ ^[0-9]+$ ]] || continue
|
||||||
|
|
||||||
|
# A monitor that does not implement 0x10 is not an error; it simply
|
||||||
|
# cannot be controlled, and is left out rather than shown as a slider
|
||||||
|
# that does nothing.
|
||||||
|
value="$(cmd_get "$bus")" || continue
|
||||||
|
[[ -n "$value" ]] || continue
|
||||||
|
|
||||||
|
rows+=("$(jq -cn \
|
||||||
|
--argjson bus "$bus" \
|
||||||
|
--arg connector "$connector" \
|
||||||
|
--argjson value "$value" \
|
||||||
|
'{bus: $bus, connector: $connector, value: $value}')")
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ ${#rows[@]} -eq 0 ]]; then
|
||||||
|
emit_error 'no connected monitor reports DDC/CI brightness. Some panels implement it only when "DDC/CI" is enabled in their on-screen menu.'
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '{"displays":[%s],"error":""}\n' "$(IFS=,; printf '%s' "${rows[*]}")"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Prints the current brightness as a whole percent, or nothing when the display
|
||||||
|
# cannot report it. `getvcp --brief` is documented as machine readable and
|
||||||
|
# answers "VCP 10 C <current> <max>"; the max is almost always 100 but is not
|
||||||
|
# guaranteed to be, so it is read rather than assumed.
|
||||||
|
cmd_get() {
|
||||||
|
local bus="$1" out current max
|
||||||
|
out="$(timeout 10 ddcutil --bus "$bus" getvcp "$VCP_BRIGHTNESS" --brief 2>/dev/null)" || return 1
|
||||||
|
read -r _ _ _ current max <<<"$out"
|
||||||
|
[[ "$current" =~ ^[0-9]+$ && "$max" =~ ^[0-9]+$ && "$max" -gt 0 ]] || return 1
|
||||||
|
printf '%s' "$(( current * 100 / max ))"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_set() {
|
||||||
|
local bus="$1" percent="$2" max out
|
||||||
|
[[ "$percent" =~ ^[0-9]+$ ]] || return 1
|
||||||
|
(( percent > 100 )) && percent=100
|
||||||
|
|
||||||
|
out="$(timeout 10 ddcutil --bus "$bus" getvcp "$VCP_BRIGHTNESS" --brief 2>/dev/null)" || return 1
|
||||||
|
read -r _ _ _ _ max <<<"$out"
|
||||||
|
[[ "$max" =~ ^[0-9]+$ && "$max" -gt 0 ]] || max=100
|
||||||
|
|
||||||
|
timeout 10 ddcutil --bus "$bus" setvcp "$VCP_BRIGHTNESS" "$(( percent * max / 100 ))" >/dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
case "${1:-list}" in
|
||||||
|
list) cmd_list ;;
|
||||||
|
get) cmd_get "${2:?bus required}" ;;
|
||||||
|
set) cmd_set "${2:?bus required}" "${3:?percent required}" ;;
|
||||||
|
*) printf 'usage: panama-brightness [list|get <bus>|set <bus> <percent>]\n' >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
@@ -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
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
set -u
|
set -u
|
||||||
|
|
||||||
|
readonly PANAMA_OSD_SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|
||||||
strict_delivery() {
|
strict_delivery() {
|
||||||
[[ ${PANAMA_OSD_STRICT:-false} == true || ${PANAMA_OSD_STRICT:-false} == 1 ]]
|
[[ ${PANAMA_OSD_STRICT:-false} == true || ${PANAMA_OSD_STRICT:-false} == 1 ]]
|
||||||
}
|
}
|
||||||
@@ -67,18 +69,151 @@ adjust_microphone() {
|
|||||||
show_volume "$target" microphone
|
show_volume "$target" microphone
|
||||||
}
|
}
|
||||||
|
|
||||||
|
brightness_percent() {
|
||||||
|
local output="$1" percent
|
||||||
|
percent="$(awk -F, 'NR == 1 { value=$5; gsub(/%/, "", value); print value }' <<<"$output")"
|
||||||
|
[[ $percent =~ ^[0-9]+$ ]] || return 1
|
||||||
|
printf '%s\n' "$percent"
|
||||||
|
}
|
||||||
|
|
||||||
|
brightness_error() {
|
||||||
|
local detail="$1" label="External brightness unavailable"
|
||||||
|
if [[ $detail == *udev* || $detail == *accessible* || $detail == *permission* ]]; then
|
||||||
|
label="Brightness needs permission"
|
||||||
|
fi
|
||||||
|
|
||||||
|
show_message dialog-warning-symbolic "$label" || true
|
||||||
|
if command -v notify-send >/dev/null 2>&1; then
|
||||||
|
notify-send --app-name=Panama --icon=display-brightness-symbolic \
|
||||||
|
"Brightness unavailable" "$detail" >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
discover_ddc_bus() {
|
||||||
|
local helper="$1" cache_file="$2" list_json focused selected error bus connector
|
||||||
|
|
||||||
|
command -v jq >/dev/null 2>&1 || {
|
||||||
|
brightness_error "jq is required to discover DDC/CI displays."
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
list_json="$("$helper" list 2>/dev/null)" || {
|
||||||
|
brightness_error "The external brightness helper could not inspect connected displays."
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
if ! jq -e 'type == "object" and (.displays | type == "array")' >/dev/null 2>&1 <<<"$list_json"; then
|
||||||
|
brightness_error "The external brightness helper returned invalid display information."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
error="$(jq -r '.error // empty' <<<"$list_json")"
|
||||||
|
if [[ -n $error ]]; then
|
||||||
|
brightness_error "$error"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
focused="$(hyprctl -j monitors 2>/dev/null \
|
||||||
|
| jq -r '.[] | select(.focused == true) | .name' 2>/dev/null \
|
||||||
|
| head -n1)"
|
||||||
|
selected="$(jq -r --arg connector "$focused" '
|
||||||
|
([.displays[] | select(.connector == $connector)][0] // .displays[0] // empty)
|
||||||
|
| [.bus, .connector]
|
||||||
|
| @tsv
|
||||||
|
' <<<"$list_json")"
|
||||||
|
IFS=$'\t' read -r bus connector <<<"$selected"
|
||||||
|
if [[ ! $bus =~ ^[0-9]+$ ]]; then
|
||||||
|
brightness_error "No connected monitor exposes DDC/CI brightness control."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
umask 077
|
||||||
|
printf '%s\t%s\n' "$bus" "$connector" >"$cache_file"
|
||||||
|
printf '%s\n' "$bus"
|
||||||
|
}
|
||||||
|
|
||||||
|
adjust_ddc_brightness() {
|
||||||
|
local action="$1" step="$2"
|
||||||
|
local helper="${PANAMA_OSD_BRIGHTNESS_HELPER:-$PANAMA_OSD_SCRIPT_DIR/panama-brightness}"
|
||||||
|
local runtime_dir="${PANAMA_OSD_RUNTIME_DIR:-${XDG_RUNTIME_DIR:-/tmp}/panama-osd-${UID}}"
|
||||||
|
local cache_file="$runtime_dir/brightness-bus" lock_file="$runtime_dir/brightness.lock"
|
||||||
|
local bus="" connector="" current target lock_fd
|
||||||
|
|
||||||
|
[[ -x $helper ]] || {
|
||||||
|
brightness_error "The external brightness helper is not installed."
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
mkdir -p "$runtime_dir" || return 0
|
||||||
|
chmod 700 "$runtime_dir" 2>/dev/null || true
|
||||||
|
|
||||||
|
exec {lock_fd}>"$lock_file" || return 0
|
||||||
|
# DDC transactions on one I2C bus cannot safely overlap. A short wait also
|
||||||
|
# sheds an excessive key-repeat backlog instead of replaying it seconds later.
|
||||||
|
flock -w 2 "$lock_fd" || return 0
|
||||||
|
|
||||||
|
if [[ -r $cache_file ]]; then
|
||||||
|
IFS=$'\t' read -r bus connector <"$cache_file" || true
|
||||||
|
[[ $bus =~ ^[0-9]+$ ]] || bus=""
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n $bus ]]; then
|
||||||
|
current="$("$helper" get "$bus" 2>/dev/null)" || current=""
|
||||||
|
if [[ ! $current =~ ^[0-9]+$ ]]; then
|
||||||
|
: >"$cache_file"
|
||||||
|
bus=""
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z $bus ]]; then
|
||||||
|
bus="$(discover_ddc_bus "$helper" "$cache_file")" || return 0
|
||||||
|
current="$("$helper" get "$bus" 2>/dev/null)" || current=""
|
||||||
|
fi
|
||||||
|
if [[ ! $current =~ ^[0-9]+$ ]]; then
|
||||||
|
brightness_error "The selected monitor stopped responding over DDC/CI."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ $action == up ]]; then
|
||||||
|
target=$(( current + step ))
|
||||||
|
else
|
||||||
|
target=$(( current - step ))
|
||||||
|
fi
|
||||||
|
(( target > 100 )) && target=100
|
||||||
|
(( target < 0 )) && target=0
|
||||||
|
|
||||||
|
if ! "$helper" set "$bus" "$target" >/dev/null 2>&1; then
|
||||||
|
brightness_error "The selected monitor did not accept the brightness change."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
show_progress brightness "$target" "${target}%"
|
||||||
|
}
|
||||||
|
|
||||||
adjust_brightness() {
|
adjust_brightness() {
|
||||||
local action="${1:-}" step="${2:-5}" output percent
|
local action="${1:-}" step="${2:-5}" output percent
|
||||||
|
[[ $step =~ ^[0-9]+$ ]] || {
|
||||||
|
printf 'Usage: panama-osd brightness up|down [step]\n' >&2
|
||||||
|
return 2
|
||||||
|
}
|
||||||
case "$action" in
|
case "$action" in
|
||||||
up) brightnessctl -e4 -n2 set "${step}%+" >/dev/null || return ;;
|
up|down) ;;
|
||||||
down) brightnessctl -e4 -n2 set "${step}%-" >/dev/null || return ;;
|
|
||||||
*) printf 'Usage: panama-osd brightness up|down [step]\n' >&2; return 2 ;;
|
*) printf 'Usage: panama-osd brightness up|down [step]\n' >&2; return 2 ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
output="$(brightnessctl -m -c backlight 2>/dev/null)" || return 0
|
# Laptop panels expose a kernel backlight class and remain the fastest,
|
||||||
percent="$(awk -F, 'NR == 1 { value=$5; gsub(/%/, "", value); print value }' <<<"$output")"
|
# most reliable path. Desktops fall through to DDC/CI monitor control.
|
||||||
[[ $percent =~ ^[0-9]+$ ]] || return 0
|
output="$(brightnessctl -m -c backlight 2>/dev/null)" || output=""
|
||||||
show_progress brightness "$percent" "${percent}%"
|
if percent="$(brightness_percent "$output")"; then
|
||||||
|
if [[ $action == up ]]; then
|
||||||
|
brightnessctl -e4 -n2 -c backlight set "${step}%+" >/dev/null || return 0
|
||||||
|
else
|
||||||
|
brightnessctl -e4 -n2 -c backlight set "${step}%-" >/dev/null || return 0
|
||||||
|
fi
|
||||||
|
output="$(brightnessctl -m -c backlight 2>/dev/null)" || return 0
|
||||||
|
percent="$(brightness_percent "$output")" || return 0
|
||||||
|
show_progress brightness "$percent" "${percent}%"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
adjust_ddc_brightness "$action" "$step"
|
||||||
}
|
}
|
||||||
|
|
||||||
media_action() {
|
media_action() {
|
||||||
|
|||||||
+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[*]}")"
|
||||||
+149
@@ -0,0 +1,149 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# Propagates the colour scheme to applications that do not read the desktop
|
||||||
|
# portal.
|
||||||
|
#
|
||||||
|
# Most modern applications DO follow org.freedesktop.portal.Settings and need
|
||||||
|
# nothing from us: GTK4/libadwaita, Qt6, Chromium and Electron all read
|
||||||
|
# org.freedesktop.appearance color-scheme, which xdg-desktop-portal-gtk serves
|
||||||
|
# from gsettings. Panama sets that, so those follow automatically.
|
||||||
|
#
|
||||||
|
# Terminals are the notable exception -- they predate the standard and carry
|
||||||
|
# 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
|
||||||
|
#
|
||||||
|
# kitty gets it twice: the generated include file so terminals opened later
|
||||||
|
# start correct, and a live `set-colors` over its control socket so terminals
|
||||||
|
# already open change now. Without the second, a scheme change appears to do
|
||||||
|
# nothing until you open a new window.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
scheme="${1:-dark}"
|
||||||
|
case "$scheme" in
|
||||||
|
dark|light) ;;
|
||||||
|
*) printf 'usage: panama-theme-apps [dark|light]\n' >&2; exit 2 ;;
|
||||||
|
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"
|
||||||
|
theme_file="$kitty_dir/themes/tokyonight-moon.conf"
|
||||||
|
[[ "$scheme" == "light" ]] && theme_file="$kitty_dir/themes/tokyonight-day.conf"
|
||||||
|
|
||||||
|
status_kitty="skipped"
|
||||||
|
if [[ -r "$theme_file" ]]; then
|
||||||
|
# Written atomically: kitty may read this while a new window is starting.
|
||||||
|
if cp "$theme_file" "$kitty_dir/current-theme.conf.tmp" 2>/dev/null \
|
||||||
|
&& mv "$kitty_dir/current-theme.conf.tmp" "$kitty_dir/current-theme.conf" 2>/dev/null; then
|
||||||
|
status_kitty="written"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Live-apply to running terminals. kitty appends its PID to the socket name
|
||||||
|
# from listen_on, so there is one socket per instance -- "unix:@mykitty"
|
||||||
|
# alone reaches nothing, which is exactly how this looked like remote
|
||||||
|
# control being disabled when it was not.
|
||||||
|
if command -v kitty >/dev/null 2>&1 && command -v ss >/dev/null 2>&1; then
|
||||||
|
applied=0
|
||||||
|
while read -r socket; do
|
||||||
|
[[ -n "$socket" ]] || continue
|
||||||
|
if kitty @ --to "unix:@${socket#@}" set-colors --all --configured "$theme_file" >/dev/null 2>&1; then
|
||||||
|
applied=$((applied + 1))
|
||||||
|
fi
|
||||||
|
done < <(ss -xl 2>/dev/null | grep -oE '@mykitty[^[:space:]]*' | sort -u)
|
||||||
|
(( applied > 0 )) && status_kitty="applied to $applied"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 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,10 +90,7 @@ 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
|
commands.push(["hyprctl", "setcursor", root.cursorTheme, size]);
|
||||||
// has not answered yet. The next change will catch up.
|
|
||||||
if (root.cursorTheme !== "")
|
|
||||||
commands.push(["hyprctl", "setcursor", root.cursorTheme, size]);
|
|
||||||
root.enqueue(commands);
|
root.enqueue(commands);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
pragma Singleton
|
||||||
|
|
||||||
|
// Panel brightness for external monitors, over DDC/CI.
|
||||||
|
//
|
||||||
|
// brightnessctl covers laptop panels through the kernel's backlight class. A
|
||||||
|
// desktop driving a DisplayPort monitor has no such device, so it has no
|
||||||
|
// brightness control at all -- the only way to dim the screen is the buttons on
|
||||||
|
// the bezel. DDC/CI is the channel those buttons drive, and monitors expose it
|
||||||
|
// over the same I2C lines that carry EDID.
|
||||||
|
//
|
||||||
|
// Two things shape everything here:
|
||||||
|
//
|
||||||
|
// Detection is slow. Probing every I2C bus takes on the order of a second,
|
||||||
|
// which is far too slow to sit in front of a settings page opening. It runs
|
||||||
|
// once, on demand, and afterwards each display is addressed by its bus number
|
||||||
|
// directly.
|
||||||
|
//
|
||||||
|
// Writes are slow AND rate-limited by the monitor's firmware. A slider drag
|
||||||
|
// emits values continuously; sending each one produces a queue the panel
|
||||||
|
// works through seconds after the user let go, and some monitors drop or
|
||||||
|
// garble writes that arrive too fast. So `value` updates immediately for the
|
||||||
|
// UI and the hardware write is debounced, with only the latest value sent.
|
||||||
|
//
|
||||||
|
// Displays are keyed by DRM connector name (DP-2) so they line up with what
|
||||||
|
// Hyprland, the Displays page, and the monitor list already call them.
|
||||||
|
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import QtQuick
|
||||||
|
|
||||||
|
Singleton {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-brightness"
|
||||||
|
|
||||||
|
// [{ bus, connector, model, value }] where value is 0..100.
|
||||||
|
property var displays: []
|
||||||
|
property bool scanning: false
|
||||||
|
|
||||||
|
// Empty when everything is fine. Carries the helper's explanation
|
||||||
|
// otherwise -- most usefully the udev command that grants I2C access,
|
||||||
|
// which is the difference between "brightness is unavailable" and
|
||||||
|
// "brightness is one command away".
|
||||||
|
property string lastError: ""
|
||||||
|
|
||||||
|
readonly property bool available: root.displays.length > 0
|
||||||
|
|
||||||
|
// True once a scan has completed, however it went. Lets the UI tell "not
|
||||||
|
// looked yet" apart from "looked and found nothing", which otherwise render
|
||||||
|
// identically and leave a permanently empty panel with no explanation.
|
||||||
|
property bool scanned: false
|
||||||
|
|
||||||
|
// Pending writes, keyed by bus. A monitor being dragged accumulates exactly
|
||||||
|
// one entry no matter how many values the slider emits.
|
||||||
|
property var pending: ({})
|
||||||
|
|
||||||
|
function refresh(): void {
|
||||||
|
if (root.scanning)
|
||||||
|
return;
|
||||||
|
root.scanning = true;
|
||||||
|
scan.running = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayFor(connector: string): var {
|
||||||
|
return root.displays.find(display => display.connector === connector) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sets brightness for one display. The stored value moves at once so the
|
||||||
|
// slider tracks the pointer; the hardware follows when the drag settles.
|
||||||
|
function set(bus: int, percent: int): void {
|
||||||
|
const clamped = Math.max(0, Math.min(100, Math.round(percent)));
|
||||||
|
|
||||||
|
root.displays = root.displays.map(display =>
|
||||||
|
display.bus === bus ? Object.assign({}, display, { value: clamped }) : display);
|
||||||
|
|
||||||
|
const next = Object.assign({}, root.pending);
|
||||||
|
next[String(bus)] = clamped;
|
||||||
|
root.pending = next;
|
||||||
|
writeDebounce.restart();
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: scan
|
||||||
|
command: [root.helperPath, "list"]
|
||||||
|
stdout: StdioCollector {
|
||||||
|
onStreamFinished: {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(this.text);
|
||||||
|
root.displays = Array.isArray(parsed.displays) ? parsed.displays : [];
|
||||||
|
root.lastError = String(parsed.error ?? "");
|
||||||
|
} catch (error) {
|
||||||
|
root.displays = [];
|
||||||
|
root.lastError = "Could not read the brightness helper's output.";
|
||||||
|
console.warn("Brightness: could not parse helper output:", error);
|
||||||
|
}
|
||||||
|
root.scanning = false;
|
||||||
|
root.scanned = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Long enough that a drag produces one write rather than dozens, short
|
||||||
|
// enough that a single click still feels immediate.
|
||||||
|
Timer {
|
||||||
|
id: writeDebounce
|
||||||
|
interval: 120
|
||||||
|
onTriggered: root.pump()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Writes run one at a time, and each is read back.
|
||||||
|
//
|
||||||
|
// Serial because DDC/CI is a bus protocol with no arbitration: two ddcutil
|
||||||
|
// processes talking to the same monitor interleave their exchanges and both
|
||||||
|
// can come back with garbage. Read back because a write is not a promise --
|
||||||
|
// panels clamp to their own range, ignore values while waking from standby,
|
||||||
|
// and drop writes that arrive too quickly. Without the read the slider shows
|
||||||
|
// what Panama asked for rather than what the monitor did, which is the same
|
||||||
|
// class of lie as trusting `hyprctl keyword` to have applied something.
|
||||||
|
property int writingBus: -1
|
||||||
|
|
||||||
|
function pump(): void {
|
||||||
|
if (writer.running || reader.running)
|
||||||
|
return;
|
||||||
|
|
||||||
|
for (const bus in root.pending) {
|
||||||
|
const value = root.pending[bus];
|
||||||
|
const remaining = Object.assign({}, root.pending);
|
||||||
|
delete remaining[bus];
|
||||||
|
root.pending = remaining;
|
||||||
|
|
||||||
|
root.writingBus = parseInt(bus);
|
||||||
|
writer.command = [root.helperPath, "set", bus, String(value)];
|
||||||
|
writer.running = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: writer
|
||||||
|
onExited: {
|
||||||
|
reader.command = [root.helperPath, "get", String(root.writingBus)];
|
||||||
|
reader.running = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: reader
|
||||||
|
stdout: StdioCollector {
|
||||||
|
onStreamFinished: {
|
||||||
|
const actual = parseInt(this.text.trim());
|
||||||
|
if (!isNaN(actual)) {
|
||||||
|
root.displays = root.displays.map(display =>
|
||||||
|
display.bus === root.writingBus
|
||||||
|
? Object.assign({}, display, { value: actual })
|
||||||
|
: display);
|
||||||
|
}
|
||||||
|
root.writingBus = -1;
|
||||||
|
// Anything queued while this write was in flight goes now.
|
||||||
|
root.pump();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,8 @@ import qs.config
|
|||||||
Singleton {
|
Singleton {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
|
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"
|
||||||
property string lastError: ""
|
property string lastError: ""
|
||||||
|
|
||||||
@@ -76,9 +78,21 @@ 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],
|
||||||
@@ -92,6 +106,12 @@ Singleton {
|
|||||||
commands.push(["hyprctl", "eval",
|
commands.push(["hyprctl", "eval",
|
||||||
`hl.config({ general = { col = { inactive_border = "${inactive}" } } })`]);
|
`hl.config({ general = { col = { inactive_border = "${inactive}" } } })`]);
|
||||||
|
|
||||||
|
// Applications that predate org.freedesktop.appearance and carry their
|
||||||
|
// own palettes -- terminals, chiefly. Everything that reads the portal
|
||||||
|
// (GTK4, Qt6, Chromium, Electron) is already handled by the gsettings
|
||||||
|
// write above and needs nothing here.
|
||||||
|
commands.push([root.appThemePath, root.dark ? "dark" : "light"]);
|
||||||
|
|
||||||
root.enqueue(commands);
|
root.enqueue(commands);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user