Merge remote-tracking branch 'origin/main' into feat/panama-health
# Conflicts: # config/dot/quickshell/modules/settings/ServicesPage.qml # config/dot/quickshell/modules/settings/SettingsShell.qml # config/dot/quickshell/modules/settings/SettingsSidebar.qml
This commit is contained in:
@@ -16,3 +16,8 @@ __pycache__/
|
||||
|
||||
# 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 |
|
||||
|---|---|
|
||||
| `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/uwsm/` | Session environment (see the uwsm caveat in the hypr README) |
|
||||
| `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]
|
||||
# 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
|
||||
# themes that aren't installed (Tahoe-Dark, WhiteSur-cursors) without anything
|
||||
# appearing broken.
|
||||
gtk-theme-name=adw-gtk3-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
|
||||
gtk-application-prefer-dark-theme=1
|
||||
gtk-application-prefer-dark-theme=@PREFER_DARK@
|
||||
|
||||
gtk-toolbar-style=GTK_TOOLBAR_ICONS
|
||||
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,7 @@ Last live audit: 2026-08-17, Fedora 44, Hyprland 0.56.2, Quickshell 0.3.0.
|
||||
| Removable media | udiskie plus udisks notifications | Live |
|
||||
| Autostart apps | Nextcloud, Bitwarden, and RustDesk system service/tray | 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 |
|
||||
|
||||
## GNOME extension migration
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ Don't "fix" them.
|
||||
| File | Contents |
|
||||
|---|---|
|
||||
| `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 |
|
||||
| `monitors.lua` | DP-2 geometry, scaling, and the HDR decision |
|
||||
| `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
|
||||
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;
|
||||
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.
|
||||
|
||||
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
|
||||
no settings file at all.
|
||||
- **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.
|
||||
|
||||
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
|
||||
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
|
||||
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
|
||||
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.
|
||||
`tests/quickshell/keybinds-contract.sh` fails if any bind lacks a description, so
|
||||
this cannot regress silently.
|
||||
@@ -275,7 +275,7 @@ The mental model is unchanged from Forge:
|
||||
| `SUPER + V` | Clipboard history |
|
||||
| `SUPER + .` | Emoji picker |
|
||||
| `SUPER + S` | Quick settings |
|
||||
| `SUPER + I` | Panama Settings |
|
||||
| `SUPER + I` | Settings |
|
||||
| `SUPER + SHIFT + F` | Start or reveal focus session |
|
||||
| `SUPER + B` | Notification centre |
|
||||
| `SUPER + grave` | Workspace overview |
|
||||
@@ -288,7 +288,7 @@ The mental model is unchanged from Forge:
|
||||
|
||||
### Apps
|
||||
`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.
|
||||
|
||||
## Calendar and notifications
|
||||
@@ -326,7 +326,7 @@ one place. Home and Phone continue the same surface rather than opening extra
|
||||
dashboard windows.
|
||||
|
||||
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
|
||||
only previews the value; releasing it sends one brightness request. A normal
|
||||
power toggle leaves Home Assistant responsible for restoring its previous
|
||||
|
||||
@@ -21,10 +21,18 @@ hl.config({
|
||||
repeat_delay = prefs.get("keyRepeatDelay", 500),
|
||||
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),
|
||||
|
||||
-- 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,
|
||||
|
||||
-- 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 .. " + C", hl.dsp.exec_cmd(calculator), { description = "Calculator" })
|
||||
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" })
|
||||
|
||||
-- ── Launcher ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -98,7 +98,26 @@ Singleton {
|
||||
// back to shipped defaults and let the next write replace it.
|
||||
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.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",
|
||||
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: [
|
||||
{ value: 0, label: "Off" },
|
||||
{ value: 3, label: "Content-aware" }
|
||||
{ value: 0, label: "Off",
|
||||
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" }
|
||||
},
|
||||
@@ -140,8 +151,12 @@ Singleton {
|
||||
label: "Direct scanout",
|
||||
detail: "Lets fullscreen content bypass compositing",
|
||||
options: [
|
||||
{ value: 0, label: "Off" },
|
||||
{ value: 2, label: "Automatic" }
|
||||
{ value: 0, label: "Off",
|
||||
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" }
|
||||
},
|
||||
@@ -257,6 +272,24 @@ Singleton {
|
||||
detail: "XKB layout name, or a comma-separated list to switch between",
|
||||
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: "", 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",
|
||||
label: "Num Lock on login",
|
||||
@@ -281,12 +314,28 @@ Singleton {
|
||||
},
|
||||
{
|
||||
key: "followMouse", type: "enum", def: 1, group: "input",
|
||||
label: "Focus follows pointer",
|
||||
detail: "Click to focus matches GNOME; sloppy focus follows the pointer",
|
||||
label: "Pointer focus",
|
||||
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: [
|
||||
{ value: 0, label: "Never" },
|
||||
{ value: 1, label: "Click to focus" },
|
||||
{ value: 2, label: "Sloppy focus" }
|
||||
{ value: 0, label: "Click to focus",
|
||||
detail: "Moving the pointer never changes 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" }
|
||||
},
|
||||
@@ -310,6 +359,203 @@ Singleton {
|
||||
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" }
|
||||
},
|
||||
|
||||
// ── 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 ─────────────────────────────────────────────────────
|
||||
{
|
||||
key: "nightLightEnabled", type: "bool", def: false, group: "nightLight",
|
||||
|
||||
@@ -4,3 +4,4 @@ singleton PreferenceSchema 1.0 PreferenceSchema.qml
|
||||
singleton HomePreferences 1.0 HomePreferences.qml
|
||||
singleton Settings 1.0 Settings.qml
|
||||
singleton Theme 1.0 Theme.qml
|
||||
singleton Migrations 1.0 Migrations.qml
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,20 @@ import qs.config
|
||||
import qs.services
|
||||
|
||||
SettingsPage {
|
||||
title: "About Panama"
|
||||
id: root
|
||||
|
||||
title: "About"
|
||||
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 {
|
||||
title: "Panama Desktop"
|
||||
title: "Desktop"
|
||||
subtitle: "Tokyo Night Moon · Prism glass · native tiling"
|
||||
|
||||
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 {
|
||||
title: "Design principles"
|
||||
|
||||
|
||||
@@ -25,32 +25,61 @@ SettingsPage {
|
||||
|
||||
SettingsCard {
|
||||
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 }
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
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 }
|
||||
}
|
||||
|
||||
// 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 {
|
||||
title: "Contrast"
|
||||
subtitle: "Unfocused windows can be faded to make the focused one obvious, or left at full strength if that is harder to read."
|
||||
title: "Magnifier"
|
||||
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 {
|
||||
title: "System accessibility"
|
||||
subtitle: "Screen reader, zoom, and on-screen keyboard are provided by GNOME's accessibility stack."
|
||||
title: "Contrast"
|
||||
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 {
|
||||
label: "GNOME accessibility settings"
|
||||
detail: "Opens in GNOME Settings"
|
||||
detail: "For the parts GNOME's own stack still owns"
|
||||
action: "Open"
|
||||
divider: false
|
||||
onTriggered: SystemSettings.openGnomePanel("universal-access")
|
||||
|
||||
@@ -46,7 +46,7 @@ SettingsPage {
|
||||
title: "Background"
|
||||
subtitle: 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 {
|
||||
id: wallpapers
|
||||
@@ -184,7 +184,7 @@ 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."
|
||||
subtitle: "This desktop 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 }
|
||||
|
||||
@@ -142,6 +142,40 @@ 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 {
|
||||
title: "User autostart"
|
||||
subtitle: "These desktop entries live in your user configuration. Select a row to toggle it."
|
||||
@@ -175,7 +209,7 @@ SettingsPage {
|
||||
|
||||
SettingsCard {
|
||||
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 {
|
||||
visible: !DefaultApps.busy && DefaultApps.luaAutostartEntries.length === 0
|
||||
|
||||
@@ -19,13 +19,18 @@ import qs.services
|
||||
SettingsPage {
|
||||
id: root
|
||||
|
||||
|
||||
title: "Network & Devices"
|
||||
lede: Connectivity.activeNetwork
|
||||
? "Connected to " + Connectivity.activeNetwork.name
|
||||
: "Wi-Fi, Bluetooth, and the things Fedora owns."
|
||||
|
||||
// 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
|
||||
|
||||
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 {
|
||||
title: "Bluetooth"
|
||||
visible: Connectivity.adapter !== null
|
||||
|
||||
@@ -47,18 +47,16 @@ SettingsPage {
|
||||
|
||||
SettingsCard {
|
||||
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 {
|
||||
label: "Layout"
|
||||
detail: "Dwindle with preserved split direction"
|
||||
value: "Tiling"
|
||||
}
|
||||
TextRow {
|
||||
label: "Workspace movement"
|
||||
detail: "Alt+H / Alt+L, add Shift to move a window"
|
||||
value: "Dynamic"
|
||||
}
|
||||
// These were two read-only rows reporting "Tiling" and "Dynamic", which
|
||||
// described settings rather than facts -- both are ordinary Hyprland
|
||||
// options that simply had no controls. TextRow's own documentation says
|
||||
// a setting the user could reasonably change does not belong in it.
|
||||
ChoiceRow { setting: "windowLayout" }
|
||||
ToggleRow { setting: "preserveSplit" }
|
||||
ChoiceRow { setting: "forceSplit" }
|
||||
ToggleRow { setting: "windowSnapping" }
|
||||
ActionRow {
|
||||
label: "Gaps, corners, and effects"
|
||||
detail: "Adjusted on the Appearance page, beside a live preview"
|
||||
@@ -68,6 +66,18 @@ SettingsPage {
|
||||
}
|
||||
}
|
||||
|
||||
// GNOME's Multitasking panel, in Hyprland's terms.
|
||||
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" }
|
||||
ChoiceRow { setting: "followMouse"; divider: false }
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Focus"
|
||||
|
||||
@@ -111,10 +121,10 @@ SettingsPage {
|
||||
|
||||
SettingsCard {
|
||||
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 {
|
||||
label: "Restore Panama defaults"
|
||||
label: "Restore defaults"
|
||||
detail: "Applies immediately, including to the compositor"
|
||||
action: "Restore defaults"
|
||||
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 {
|
||||
anchors.centerIn: parent
|
||||
text: "Panama"
|
||||
text: Qt.formatTime(new Date(), "h:mm")
|
||||
color: Theme.alpha(Theme.fg, 0.45)
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: 9
|
||||
|
||||
@@ -149,7 +149,7 @@ SettingsPage {
|
||||
ActionRow {
|
||||
visible: Displays.isOverridden(root.monitor ? root.monitor.name : "")
|
||||
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"
|
||||
divider: false
|
||||
onTriggered: Displays.forget(root.monitor.name)
|
||||
@@ -248,7 +248,7 @@ SettingsPage {
|
||||
|
||||
SettingsCard {
|
||||
title: "Gaming display policy"
|
||||
subtitle: "Applied immediately and restored when Panama starts."
|
||||
subtitle: "Applied immediately and restored when the session starts."
|
||||
|
||||
ToggleRow { setting: "autoHdr" }
|
||||
ChoiceRow { setting: "vrrPolicy" }
|
||||
|
||||
@@ -143,6 +143,7 @@ SettingsPage {
|
||||
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);
|
||||
@@ -162,6 +163,11 @@ SettingsPage {
|
||||
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)
|
||||
@@ -384,7 +390,7 @@ SettingsPage {
|
||||
|
||||
SettingsCard {
|
||||
title: "Fedora system settings"
|
||||
subtitle: "Network accounts, printers, users, and Fedora updates remain owned by system tools."
|
||||
subtitle: "These areas remain owned by Fedora and GNOME's mature system panels."
|
||||
|
||||
Item {
|
||||
width: parent.width
|
||||
@@ -415,5 +421,38 @@ SettingsPage {
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ SettingsPage {
|
||||
readonly property string greeting: openedHour < 12 ? "Good morning" : (openedHour < 18 ? "Good afternoon" : "Good evening")
|
||||
|
||||
title: `${root.greeting}, Gabriel`
|
||||
lede: "Your Panama desktop is configured and ready."
|
||||
lede: "Your desktop is configured and ready."
|
||||
|
||||
SettingsCard {
|
||||
title: SystemSettings.monitorDescription || "Active display"
|
||||
|
||||
@@ -202,7 +202,7 @@ SettingsPage {
|
||||
|
||||
SettingRow {
|
||||
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
|
||||
|
||||
Row {
|
||||
@@ -239,7 +239,7 @@ SettingsPage {
|
||||
|
||||
SettingRow {
|
||||
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
|
||||
controlWidth: 176
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
// 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"; 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 {
|
||||
label: "Notification history"
|
||||
detail: "Live notifications retained by Panama"
|
||||
detail: "Live notifications retained by the shell"
|
||||
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 {
|
||||
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"
|
||||
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 {
|
||||
title: "Idle behaviour"
|
||||
subtitle: IdleLock.managed
|
||||
? "Panama is managing hypridle. Changes take effect immediately."
|
||||
: "hypridle is running Panama's shipped configuration. Turn on management below to make these adjustable."
|
||||
? "Idle timings are managed here. Changes take effect immediately."
|
||||
: "hypridle is running its shipped configuration. Turn on management below to make these adjustable."
|
||||
|
||||
SliderRow { setting: "screenBlankMinutes"; zeroLabel: "Never" }
|
||||
SliderRow { setting: "lockMinutes"; zeroLabel: "Never" }
|
||||
@@ -41,10 +75,10 @@ SettingsPage {
|
||||
|
||||
SettingsCard {
|
||||
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 {
|
||||
label: "Let Panama manage idle timings"
|
||||
label: "Manage idle timings here"
|
||||
detail: IdleLock.serviceState === "active"
|
||||
? "hypridle is running"
|
||||
: "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,4 +1,4 @@
|
||||
# Panama Settings
|
||||
# Settings
|
||||
|
||||
The control centre for everything Panama owns. Anything the system owns —
|
||||
hardware, accounts, printers — is delegated to GNOME Settings and labelled as
|
||||
|
||||
@@ -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 {
|
||||
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 {
|
||||
icon: ""
|
||||
@@ -52,7 +52,7 @@ SettingsPage {
|
||||
|
||||
SettingsCard {
|
||||
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 {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -53,7 +53,7 @@ Rectangle {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 18
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "Panama Settings"
|
||||
text: "Settings"
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
@@ -112,6 +112,10 @@ Rectangle {
|
||||
case "notifications": return notificationsPage;
|
||||
case "screen-intelligence": return screenIntelligencePage;
|
||||
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 "power": return powerPage;
|
||||
case "datetime": return dateTimePage;
|
||||
@@ -166,6 +170,10 @@ Rectangle {
|
||||
Component { id: notificationsPage; NotificationsPage {} }
|
||||
Component { id: screenIntelligencePage; ScreenIntelligencePage {} }
|
||||
Component { id: shortcutsPage; ShortcutsPage {} }
|
||||
Component { id: mousePage; MousePage {} }
|
||||
Component { id: privacyPage; PrivacyPage {} }
|
||||
Component { id: regionPage; RegionPage {} }
|
||||
Component { id: onlineAccountsPage; OnlineAccountsPage {} }
|
||||
Component { id: healthPage; HealthPage {} }
|
||||
Component { id: aboutPage; AboutPage {} }
|
||||
|
||||
|
||||
@@ -31,13 +31,17 @@ Rectangle {
|
||||
{ page: "sound", label: "Sound", icon: "\u{F057E}" },
|
||||
{ page: "notifications", label: "Notifications & Focus", icon: "\u{F009A}" },
|
||||
{ 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: "power", label: "Power & Lock", icon: "\u{F0425}" },
|
||||
{ page: "datetime", label: "Date & Time", icon: "\u{F0954}" },
|
||||
{ page: "applications", label: "Applications", icon: "\u{F003B}" },
|
||||
{ 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
|
||||
@@ -56,15 +60,6 @@ Rectangle {
|
||||
height: implicitHeight
|
||||
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 {
|
||||
width: parent.width
|
||||
height: 36
|
||||
|
||||
@@ -8,7 +8,7 @@ FloatingWindow {
|
||||
|
||||
readonly property var homePhoneDiagnostics: settingsShell.homePhoneDiagnostics
|
||||
|
||||
title: "Panama Settings"
|
||||
title: "Settings"
|
||||
visible: ShellState.settingsOpen
|
||||
implicitWidth: 1120
|
||||
implicitHeight: 760
|
||||
|
||||
@@ -28,24 +28,19 @@ SettingsPage {
|
||||
SettingsCard {
|
||||
title: "Keyboard"
|
||||
|
||||
TextRow {
|
||||
label: "Keyboard layout"
|
||||
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."
|
||||
value: Settings ? DesktopPreferences.get("keyboardLayout") : "us"
|
||||
}
|
||||
// These were read-only text, on the grounds that a layout change needed
|
||||
// a compositor reload. It does not: setting input:kb_variant through
|
||||
// hl.config re-keymaps attached keyboards immediately -- verified by
|
||||
// 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" }
|
||||
TextEntryRow { setting: "keyboardOptions"; placeholder: "compose:ralt" }
|
||||
SliderRow { setting: "keyRepeatDelay" }
|
||||
SliderRow { setting: "keyRepeatRate" }
|
||||
ToggleRow { setting: "numlockByDefault"; divider: false }
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Pointer"
|
||||
|
||||
ChoiceRow { setting: "followMouse" }
|
||||
SliderRow { setting: "pointerSensitivity" }
|
||||
SliderRow { setting: "cursorInactiveTimeout"; zeroLabel: "Never"; divider: false }
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Hardware input"
|
||||
|
||||
@@ -155,7 +150,7 @@ SettingsPage {
|
||||
SettingsCard {
|
||||
visible: Object.keys(Keybinds.overrides).length > 0
|
||||
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 {
|
||||
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
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
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
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
|
||||
@@ -48,3 +48,9 @@ SoundDeviceRow 1.0 SoundDeviceRow.qml
|
||||
TimeOfDayRow 1.0 TimeOfDayRow.qml
|
||||
LocationPicker 1.0 LocationPicker.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())
|
||||
@@ -57,6 +57,39 @@ has_accessible_bus() {
|
||||
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'
|
||||
|
||||
@@ -69,8 +102,7 @@ cmd_list() {
|
||||
connector="$(basename "$path")"
|
||||
connector="${connector#card*-}"
|
||||
|
||||
bus="$(basename "$(readlink -f "$path/ddc")")"
|
||||
bus="${bus#i2c-}"
|
||||
bus="$(bus_for_connector "$path")"
|
||||
[[ "$bus" =~ ^[0-9]+$ ]] || continue
|
||||
|
||||
# A monitor that does not implement 0x10 is not an error; it simply
|
||||
|
||||
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
|
||||
|
||||
readonly PANAMA_OSD_SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
strict_delivery() {
|
||||
[[ ${PANAMA_OSD_STRICT:-false} == true || ${PANAMA_OSD_STRICT:-false} == 1 ]]
|
||||
}
|
||||
@@ -67,18 +69,151 @@ adjust_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() {
|
||||
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
|
||||
up) brightnessctl -e4 -n2 set "${step}%+" >/dev/null || return ;;
|
||||
down) brightnessctl -e4 -n2 set "${step}%-" >/dev/null || return ;;
|
||||
up|down) ;;
|
||||
*) printf 'Usage: panama-osd brightness up|down [step]\n' >&2; return 2 ;;
|
||||
esac
|
||||
|
||||
output="$(brightnessctl -m -c backlight 2>/dev/null)" || return 0
|
||||
percent="$(awk -F, 'NR == 1 { value=$5; gsub(/%/, "", value); print value }' <<<"$output")"
|
||||
[[ $percent =~ ^[0-9]+$ ]] || return 0
|
||||
show_progress brightness "$percent" "${percent}%"
|
||||
# Laptop panels expose a kernel backlight class and remain the fastest,
|
||||
# most reliable path. Desktops fall through to DDC/CI monitor control.
|
||||
output="$(brightnessctl -m -c backlight 2>/dev/null)" || output=""
|
||||
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() {
|
||||
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# System power profile, via the PowerProfiles D-Bus API.
|
||||
#
|
||||
# GNOME's Power panel offers Balanced / Performance / Power Saver; this is the
|
||||
# same daemon behind it. On Fedora 44 the implementation is tuned-ppd rather
|
||||
# than power-profiles-daemon, but it serves the same net.hadess.PowerProfiles
|
||||
# interface, which is why this talks to the interface rather than to either
|
||||
# binary -- powerprofilesctl is not even installed here.
|
||||
#
|
||||
# Setting a profile needs no privileges: the daemon accepts a property write
|
||||
# from the active session user.
|
||||
#
|
||||
# Usage:
|
||||
# panama-power-profile list -> {"profiles":[...],"active":"...","degraded":"..."}
|
||||
# panama-power-profile set <name>
|
||||
#
|
||||
# PerformanceDegraded is reported because it is the one thing that makes the
|
||||
# choice a lie: a thermally throttled laptop reports "performance" while
|
||||
# behaving otherwise, and GNOME surfaces exactly this. It is an empty string
|
||||
# when nothing is wrong.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
readonly BUS_NAME=net.hadess.PowerProfiles
|
||||
readonly OBJECT=/net/hadess/PowerProfiles
|
||||
|
||||
emit_error() {
|
||||
printf '{"profiles":[],"active":"","degraded":"","error":%s}\n' "$(jq -Rn --arg e "$1" '$e')"
|
||||
exit 0
|
||||
}
|
||||
|
||||
command -v busctl >/dev/null 2>&1 || emit_error 'busctl is not available'
|
||||
|
||||
property() {
|
||||
busctl get-property "$BUS_NAME" "$OBJECT" "$BUS_NAME" "$1" 2>/dev/null
|
||||
}
|
||||
|
||||
cmd_list() {
|
||||
# A machine with no power-profiles daemon is a normal state -- plenty of
|
||||
# desktops have none -- so it is reported rather than treated as a failure.
|
||||
busctl status "$BUS_NAME" >/dev/null 2>&1 \
|
||||
|| emit_error 'No power profile service is running. GNOME uses power-profiles-daemon; Fedora ships tuned-ppd.'
|
||||
|
||||
local active degraded profiles
|
||||
active="$(property ActiveProfile | sed 's/^s //; s/"//g')"
|
||||
degraded="$(property PerformanceDegraded | sed 's/^s //; s/"//g')"
|
||||
|
||||
# Profiles is an array of dicts, which busctl renders flat:
|
||||
# v aa{sv} 3 2 "Profile" s "power-saver" "Driver" s "tuned" 2 "Profile" ...
|
||||
# so each profile is the string following its own "Profile" marker. Matching
|
||||
# the marker matters: "Driver" values sit in the same stream, and on this
|
||||
# machine the driver is called "tuned", which a looser pattern happily
|
||||
# reports as a fourth profile that does not exist.
|
||||
profiles="$(property Profiles \
|
||||
| grep -oE '"Profile" s "[a-z-]+"' \
|
||||
| sed 's/.*s "//; s/"$//' \
|
||||
| awk '!seen[$0]++')"
|
||||
|
||||
[[ -n "$profiles" ]] || emit_error 'The power profile service reported no profiles.'
|
||||
|
||||
jq -cn \
|
||||
--arg active "$active" \
|
||||
--arg degraded "$degraded" \
|
||||
--argjson profiles "$(printf '%s\n' "$profiles" | jq -Rn '[inputs | select(length > 0)]')" \
|
||||
'{profiles: $profiles, active: $active, degraded: $degraded, error: ""}'
|
||||
}
|
||||
|
||||
cmd_set() {
|
||||
local profile="${1:-}"
|
||||
# Constrained rather than passed through: this reaches a system service.
|
||||
[[ "$profile" =~ ^[a-z-]+$ ]] || {
|
||||
printf 'panama-power-profile: refusing a profile name with unexpected characters\n' >&2
|
||||
return 2
|
||||
}
|
||||
busctl set-property "$BUS_NAME" "$OBJECT" "$BUS_NAME" ActiveProfile s "$profile" 2>&1 >/dev/null \
|
||||
| head -2 >&2
|
||||
return 0
|
||||
}
|
||||
|
||||
case "${1:-list}" in
|
||||
list) cmd_list ;;
|
||||
set) shift; cmd_set "${1:-}" ;;
|
||||
*) printf 'usage: panama-power-profile [list|set <profile>]\n' >&2; exit 2 ;;
|
||||
esac
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Device security facts, as JSON.
|
||||
#
|
||||
# Everything here is READ-ONLY and deliberately so. Secure Boot, TPM presence,
|
||||
# disk encryption, SELinux mode and the firewall are set in firmware, at install
|
||||
# time, or by system policy -- none of them is a desktop preference, and a
|
||||
# settings app that offered to toggle them would either fail or do something
|
||||
# far-reaching from a switch that looks like any other.
|
||||
#
|
||||
# What it is for is answering "is this machine set up the way I think it is",
|
||||
# which is the question GNOME's Device Security panel exists to answer and which
|
||||
# otherwise needs five commands and root.
|
||||
#
|
||||
# Each fact is reported as {value, ok} where `ok` marks the reassuring state, so
|
||||
# the UI can highlight what deserves attention without hard-coding the meaning
|
||||
# of each string. Anything that cannot be determined reports "Unknown" with
|
||||
# ok:false rather than guessing, because a security readout that quietly reports
|
||||
# "fine" when it failed to look is worse than no readout.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
fact() {
|
||||
jq -cn --arg label "$1" --arg value "$2" --argjson ok "$3" --arg detail "${4:-}" \
|
||||
'{label: $label, value: $value, ok: $ok, detail: $detail}'
|
||||
}
|
||||
|
||||
facts=()
|
||||
|
||||
# ── Secure Boot ──────────────────────────────────────────────────────────────
|
||||
if command -v mokutil >/dev/null 2>&1; then
|
||||
case "$(mokutil --sb-state 2>/dev/null)" in
|
||||
*"SecureBoot enabled"*) facts+=("$(fact "Secure Boot" "Enabled" true "Firmware verifies the bootloader and kernel signatures")" ) ;;
|
||||
*"SecureBoot disabled"*) facts+=("$(fact "Secure Boot" "Disabled" false "Firmware does not verify what it boots")") ;;
|
||||
*) facts+=("$(fact "Secure Boot" "Unknown" false "The firmware did not report a Secure Boot state")") ;;
|
||||
esac
|
||||
elif [[ -d /sys/firmware/efi ]]; then
|
||||
facts+=("$(fact "Secure Boot" "Unknown" false "Install mokutil to report this")")
|
||||
else
|
||||
facts+=("$(fact "Secure Boot" "Not applicable" false "This machine booted in legacy BIOS mode")")
|
||||
fi
|
||||
|
||||
# ── TPM ──────────────────────────────────────────────────────────────────────
|
||||
tpm_major="$(cat /sys/class/tpm/tpm0/tpm_version_major 2>/dev/null || true)"
|
||||
if [[ -n "$tpm_major" ]]; then
|
||||
facts+=("$(fact "TPM" "Version $tpm_major" true "A trusted platform module is present and usable")")
|
||||
elif [[ -e /sys/class/tpm/tpm0 ]]; then
|
||||
facts+=("$(fact "TPM" "Present" true "A trusted platform module is present")")
|
||||
else
|
||||
facts+=("$(fact "TPM" "None" false "No trusted platform module, so keys cannot be sealed to this machine")")
|
||||
fi
|
||||
|
||||
# ── Disk encryption ──────────────────────────────────────────────────────────
|
||||
# Counts LUKS mappings rather than naming them: which volume is encrypted is
|
||||
# more detail than this readout needs, and device names are not meaningful here.
|
||||
crypt_count="$(lsblk -o TYPE 2>/dev/null | grep -c '^crypt$' || true)"
|
||||
[[ "$crypt_count" =~ ^[0-9]+$ ]] || crypt_count=0
|
||||
if (( crypt_count > 0 )); then
|
||||
facts+=("$(fact "Disk encryption" "$crypt_count encrypted volume$( (( crypt_count == 1 )) || printf 's')" true "Data at rest is protected by LUKS")")
|
||||
else
|
||||
facts+=("$(fact "Disk encryption" "None" false "No LUKS volume is unlocked on this machine")")
|
||||
fi
|
||||
|
||||
# ── SELinux ──────────────────────────────────────────────────────────────────
|
||||
if command -v getenforce >/dev/null 2>&1; then
|
||||
case "$(getenforce 2>/dev/null)" in
|
||||
Enforcing) facts+=("$(fact "SELinux" "Enforcing" true "Policy violations are blocked")") ;;
|
||||
Permissive) facts+=("$(fact "SELinux" "Permissive" false "Violations are logged but allowed")") ;;
|
||||
Disabled) facts+=("$(fact "SELinux" "Disabled" false "Mandatory access control is off")") ;;
|
||||
*) facts+=("$(fact "SELinux" "Unknown" false "")") ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# ── Firewall ─────────────────────────────────────────────────────────────────
|
||||
if systemctl list-unit-files firewalld.service >/dev/null 2>&1; then
|
||||
if [[ "$(systemctl is-active firewalld 2>/dev/null)" == "active" ]]; then
|
||||
facts+=("$(fact "Firewall" "Active" true "firewalld is filtering incoming connections")")
|
||||
else
|
||||
facts+=("$(fact "Firewall" "Inactive" false "firewalld is installed but not running")")
|
||||
fi
|
||||
fi
|
||||
|
||||
printf '[%s]\n' "$(IFS=,; printf '%s' "${facts[*]}")"
|
||||
@@ -11,6 +11,11 @@
|
||||
# Terminals are the notable exception -- they predate the standard and carry
|
||||
# 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
|
||||
@@ -26,6 +31,84 @@ case "$scheme" in
|
||||
*) 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"
|
||||
@@ -54,4 +137,13 @@ if [[ -r "$theme_file" ]]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
printf '{"scheme":"%s","kitty":"%s"}\n' "$scheme" "$status_kitty"
|
||||
# Every target reports what actually happened. A helper that says only
|
||||
# "kitty: applied" while silently skipping three other applications is how a
|
||||
# half-applied theme goes unnoticed.
|
||||
jq -cn \
|
||||
--arg scheme "$scheme" \
|
||||
--arg kitty "$status_kitty" \
|
||||
--arg gtk "$status_gtk" \
|
||||
--arg btop "$status_btop" \
|
||||
--arg tmux "$status_tmux" \
|
||||
'{scheme: $scheme, kitty: $kitty, gtk: $gtk, btop: $btop, tmux: $tmux}'
|
||||
|
||||
Executable
+121
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# A QR code for a saved Wi-Fi network, so a guest can join by pointing a camera.
|
||||
#
|
||||
# GNOME's Wi-Fi panel has this and it is the single most-used thing in it.
|
||||
# The payload is the de-facto WIFI: URI that Android and iOS both scan:
|
||||
#
|
||||
# WIFI:T:WPA;S:<ssid>;P:<passphrase>;H:<hidden>;;
|
||||
#
|
||||
# HANDLING THE PASSPHRASE
|
||||
#
|
||||
# This image contains the network password in machine-readable form. Anyone who
|
||||
# can read the file can read the password, so:
|
||||
#
|
||||
# * it is written under XDG_RUNTIME_DIR, which is 0700 and on tmpfs, so it
|
||||
# never reaches disk and disappears at logout -- not /tmp, which is shared;
|
||||
# * it is created with umask 077;
|
||||
# * the passphrase is never printed, never passed as an argument (argv is
|
||||
# world-readable via /proc), and never appears in an error message.
|
||||
#
|
||||
# It is piped to qrencode on stdin for that last reason.
|
||||
#
|
||||
# Usage:
|
||||
# panama-wifi-qr list -> {"networks":[{"name","ssid","shareable"}]}
|
||||
# panama-wifi-qr qr <name> -> {"path":"/run/user/…/….png"}
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
emit_error() {
|
||||
printf '{"networks":[],"path":"","error":%s}\n' "$(jq -Rn --arg e "$1" '$e')"
|
||||
exit 0
|
||||
}
|
||||
|
||||
command -v nmcli >/dev/null 2>&1 || emit_error 'NetworkManager is not available'
|
||||
command -v qrencode >/dev/null 2>&1 || emit_error 'qrencode is not installed, so a Wi-Fi QR code cannot be drawn'
|
||||
|
||||
cmd_list() {
|
||||
local rows=() name ssid psk
|
||||
while IFS= read -r name; do
|
||||
[[ -n "$name" ]] || continue
|
||||
ssid="$(nmcli -g 802-11-wireless.ssid connection show "$name" 2>/dev/null)"
|
||||
[[ -n "$ssid" ]] || ssid="$name"
|
||||
|
||||
# Only networks whose passphrase this user can actually read are
|
||||
# shareable. An enterprise network has no passphrase to share at all,
|
||||
# and a QR code for one would simply not work.
|
||||
psk="$(nmcli -s -g 802-11-wireless-security.psk connection show "$name" 2>/dev/null)"
|
||||
|
||||
rows+=("$(jq -cn --arg name "$name" --arg ssid "$ssid" \
|
||||
--argjson shareable "$([[ -n "$psk" ]] && echo true || echo false)" \
|
||||
'{name: $name, ssid: $ssid, shareable: $shareable}')")
|
||||
done < <(nmcli -t -f NAME,TYPE connection show 2>/dev/null \
|
||||
| awk -F: '$2 == "802-11-wireless" { print $1 }')
|
||||
|
||||
if [[ ${#rows[@]} -eq 0 ]]; then
|
||||
printf '{"networks":[],"path":"","error":"No saved Wi-Fi networks."}\n'
|
||||
return 0
|
||||
fi
|
||||
printf '{"networks":[%s],"path":"","error":""}\n' "$(IFS=,; printf '%s' "${rows[*]}")"
|
||||
}
|
||||
|
||||
# The WIFI: URI reserves \ ; , : and ", each escaped with a backslash. An SSID
|
||||
# containing a semicolon would otherwise terminate the field early and produce a
|
||||
# QR code for a different network entirely.
|
||||
#
|
||||
# Trailing newlines are stripped as well. nmcli terminates every value with one,
|
||||
# and left in place it lands INSIDE the payload -- the code still decodes here,
|
||||
# but a newline in the middle of a WIFI: URI is not something every phone's
|
||||
# scanner tolerates, and the failure would look like "the QR code just does not
|
||||
# work on my phone".
|
||||
escape_field() {
|
||||
sed -e 's/\\/\\\\/g' -e 's/;/\\;/g' -e 's/,/\\,/g' -e 's/:/\\:/g' -e 's/"/\\"/g' \
|
||||
| tr -d '\n'
|
||||
}
|
||||
|
||||
cmd_qr() {
|
||||
local name="${1:-}"
|
||||
[[ -n "$name" ]] || emit_error 'no network named'
|
||||
|
||||
local ssid hidden psk_file payload_file out_dir out_file
|
||||
ssid="$(nmcli -g 802-11-wireless.ssid connection show "$name" 2>/dev/null)"
|
||||
[[ -n "$ssid" ]] || emit_error "There is no saved network called \"$name\"."
|
||||
|
||||
hidden="$(nmcli -g 802-11-wireless.hidden connection show "$name" 2>/dev/null)"
|
||||
[[ "$hidden" == "yes" ]] && hidden=true || hidden=false
|
||||
|
||||
out_dir="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/panama"
|
||||
umask 077
|
||||
mkdir -p "$out_dir" 2>/dev/null || emit_error 'could not create the runtime directory'
|
||||
chmod 700 "$out_dir" 2>/dev/null || true
|
||||
|
||||
# Named after the connection, hashed, so repeated shares reuse one file
|
||||
# instead of accumulating images of the password.
|
||||
out_file="$out_dir/wifi-$(printf '%s' "$name" | sha256sum | cut -c1-16).png"
|
||||
|
||||
# Built in a file rather than a variable that could be echoed, and piped to
|
||||
# qrencode on stdin so the passphrase never appears in argv.
|
||||
payload_file="$(mktemp "$out_dir/payload.XXXXXX")" || emit_error 'could not create a temporary file'
|
||||
trap 'rm -f "$payload_file"' RETURN
|
||||
|
||||
{
|
||||
printf 'WIFI:T:WPA;S:'
|
||||
printf '%s' "$ssid" | escape_field
|
||||
printf ';P:'
|
||||
nmcli -s -g 802-11-wireless-security.psk connection show "$name" 2>/dev/null | escape_field
|
||||
printf ';H:%s;;' "$hidden"
|
||||
} >"$payload_file"
|
||||
|
||||
if ! qrencode -o "$out_file" -s 8 -m 2 -l M <"$payload_file" 2>/dev/null; then
|
||||
emit_error "Could not generate a QR code for \"$name\"."
|
||||
fi
|
||||
chmod 600 "$out_file" 2>/dev/null || true
|
||||
|
||||
jq -cn --arg path "$out_file" '{networks: [], path: $path, error: ""}'
|
||||
}
|
||||
|
||||
case "${1:-list}" in
|
||||
list) cmd_list ;;
|
||||
qr) shift; cmd_qr "${1:-}" ;;
|
||||
*) printf 'usage: panama-wifi-qr [list|qr <name>]\n' >&2; exit 2 ;;
|
||||
esac
|
||||
@@ -78,9 +78,21 @@ Singleton {
|
||||
root.lastError = "";
|
||||
|
||||
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.
|
||||
const gtkTheme = root.dark ? "Adwaita-dark" : "Adwaita";
|
||||
|
||||
// adw-gtk3, not Adwaita. This is the bug that made dark mode look
|
||||
// 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 = [
|
||||
["gsettings", "set", "org.gnome.desktop.interface", "color-scheme", scheme],
|
||||
|
||||
@@ -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,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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,9 @@ Singleton {
|
||||
"idle": "power",
|
||||
"accessibility": "accessibility",
|
||||
"input": "shortcuts",
|
||||
"pointer": "mouse",
|
||||
"touchpad": "mouse",
|
||||
"multitasking": "desktop",
|
||||
"weather": "appearance",
|
||||
"notifications": "notifications",
|
||||
"capture": "screen-intelligence"
|
||||
|
||||
@@ -92,7 +92,7 @@ Singleton {
|
||||
}
|
||||
|
||||
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";
|
||||
DesktopPreferences.set("lastPage", root.settingsPage);
|
||||
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.";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -497,18 +497,35 @@ Singleton {
|
||||
].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)) {
|
||||
root.lastError = "That GNOME Settings panel is not available.";
|
||||
return false;
|
||||
}
|
||||
const command = ["gnome-control-center", panel];
|
||||
if (subpage !== undefined && subpage !== "" && root.isGnomeSubpageAllowed(panel, subpage))
|
||||
command.push(subpage);
|
||||
Quickshell.execDetached({
|
||||
command: ["gnome-control-center", panel],
|
||||
command: command,
|
||||
environment: { "XDG_CURRENT_DESKTOP": "GNOME" }
|
||||
});
|
||||
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 {
|
||||
const commands = {
|
||||
"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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 -g status-bg pink
|
||||
# Tokyo Night Moon color palette
|
||||
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 ""
|
||||
# Colours live in themes/ and are generated into current-theme.conf by
|
||||
# panama-theme-apps, which regenerates it whenever the desktop colour scheme
|
||||
# changes. -q so a fresh checkout without the generated file still starts.
|
||||
source-file -q "~/.config/tmux/current-theme.conf"
|
||||
|
||||
# Increase scrollback buffer
|
||||
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
|
||||
// directly in hypr/keybinds.lua.
|
||||
|
||||
// Vicinae 0.26 selects a theme per system appearance. Both point to Moon so
|
||||
// the launcher never flashes or falls back to its stock palette while the
|
||||
// desktop appearance is being initialized.
|
||||
// Vicinae 0.26 selects a theme per system appearance, and now there is a
|
||||
// theme for each: the launcher follows Panama's light/dark setting instead of
|
||||
// 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": {
|
||||
"light": {
|
||||
"name": "tokyonight-moon",
|
||||
"name": "tokyonight-day",
|
||||
"icon_theme": "auto"
|
||||
},
|
||||
"dark": {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Panama Settings
|
||||
Name=Settings
|
||||
GenericName=System Settings
|
||||
Comment=Configure the Panama Hyprland desktop
|
||||
Comment=Configure this desktop
|
||||
Exec=qs ipc call settings open
|
||||
Icon=panama-settings
|
||||
Terminal=false
|
||||
StartupNotify=false
|
||||
Categories=Settings;
|
||||
Keywords=Panama;Hyprland;Display;Dock;Notifications;Shortcuts;Services;
|
||||
Keywords=Settings;Preferences;Configuration;Display;Sound;Network;Keyboard;Mouse;Privacy;Power;Shortcuts;
|
||||
|
||||
+84
-10
@@ -70,6 +70,73 @@ for dir in "${dirs[@]}"; do
|
||||
log "Linked $PANAMA_DOT/$dir → $CONFIG/$dir"
|
||||
done
|
||||
|
||||
# tmux.conf ends with a source-file of current-theme.conf, generated from the
|
||||
# colour scheme rather than committed. Seed it so a fresh checkout starts themed
|
||||
# -- the source-file is -q, so a missing file is silent, which would leave tmux
|
||||
# unstyled with nothing to explain it.
|
||||
TMUX_THEME="$PANAMA_DOT/tmux/current-theme.conf"
|
||||
if [ -e "$TMUX_THEME" ]; then
|
||||
log "Keeping existing tmux theme at $TMUX_THEME"
|
||||
elif [ -d "$PANAMA_DOT/tmux/themes" ]; then
|
||||
tmux_scheme="dark"
|
||||
tmux_prefs="${XDG_CONFIG_HOME:-$HOME/.config}/panama/settings.json"
|
||||
if [ -r "$tmux_prefs" ]; then
|
||||
tmux_stored="$(jq -r '.colorScheme // "dark"' "$tmux_prefs" 2>/dev/null || echo dark)"
|
||||
[ "$tmux_stored" = "light" ] && tmux_scheme="light"
|
||||
fi
|
||||
[ "$tmux_scheme" = "light" ] && tmux_name="tokyonight-day" || tmux_name="tokyonight-moon"
|
||||
cp "$PANAMA_DOT/tmux/themes/$tmux_name.conf" "$TMUX_THEME"
|
||||
log "Seeded tmux $tmux_scheme theme ($tmux_name) → $TMUX_THEME"
|
||||
fi
|
||||
|
||||
# btop reads themes from its own config directory, but OWNS btop.conf -- it
|
||||
# rewrites that file on exit -- so only the theme files are exposed, per file,
|
||||
# and the config itself is left to btop. panama-theme-apps edits the single
|
||||
# color_theme line in place.
|
||||
BTOP_THEME_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/btop/themes"
|
||||
mkdir -p "$BTOP_THEME_DIR"
|
||||
for btop_theme_src in "$PANAMA_DOT"/btop/themes/*.theme; do
|
||||
[ -e "$btop_theme_src" ] || continue
|
||||
btop_theme_dst="$BTOP_THEME_DIR/$(basename "$btop_theme_src")"
|
||||
if [ -L "$btop_theme_dst" ]; then
|
||||
rm "$btop_theme_dst"
|
||||
fi
|
||||
if [ -e "$btop_theme_dst" ]; then
|
||||
log "Keeping existing btop theme at $btop_theme_dst"
|
||||
else
|
||||
ln -s "$btop_theme_src" "$btop_theme_dst"
|
||||
log "Linked btop theme → $btop_theme_dst"
|
||||
fi
|
||||
done
|
||||
|
||||
# GTK3 has no include mechanism, so its settings.ini is generated whole from a
|
||||
# template rather than layered. Without this, a fresh checkout has a template
|
||||
# and no settings.ini, and GTK3 applications fall back to their built-in theme.
|
||||
# panama-theme-apps rewrites both files on every scheme change after this.
|
||||
for gtk_version in 3.0 4.0; do
|
||||
gtk_template="$PANAMA_DOT/gtk-$gtk_version/settings.ini.template"
|
||||
gtk_settings="$PANAMA_DOT/gtk-$gtk_version/settings.ini"
|
||||
[ -r "$gtk_template" ] || continue
|
||||
if [ -e "$gtk_settings" ]; then
|
||||
log "Keeping existing GTK settings at $gtk_settings"
|
||||
else
|
||||
gtk_scheme="dark"
|
||||
gtk_prefs="${XDG_CONFIG_HOME:-$HOME/.config}/panama/settings.json"
|
||||
if [ -r "$gtk_prefs" ]; then
|
||||
gtk_stored="$(jq -r '.colorScheme // "dark"' "$gtk_prefs" 2>/dev/null || echo dark)"
|
||||
[ "$gtk_stored" = "light" ] && gtk_scheme="light"
|
||||
fi
|
||||
if [ "$gtk_scheme" = "light" ]; then
|
||||
gtk_name="adw-gtk3"; gtk_dark=0
|
||||
else
|
||||
gtk_name="adw-gtk3-dark"; gtk_dark=1
|
||||
fi
|
||||
sed -e "s/@GTK_THEME@/$gtk_name/" -e "s/@PREFER_DARK@/$gtk_dark/" \
|
||||
"$gtk_template" > "$gtk_settings"
|
||||
log "Generated GTK $gtk_version settings ($gtk_name) → $gtk_settings"
|
||||
fi
|
||||
done
|
||||
|
||||
# kitty.conf ends with `include current-theme.conf`, and that file is generated
|
||||
# from the desktop colour scheme rather than committed -- it is machine state.
|
||||
# A fresh checkout therefore has no such file, and kitty starts by complaining
|
||||
@@ -95,19 +162,26 @@ fi
|
||||
# from ~/.config/vicinae. Keep the authored theme in Panama with the rest of
|
||||
# the launcher config and expose only that file at Vicinae's runtime path.
|
||||
VICINAE_THEME_DIR="$HOME/.local/share/vicinae/themes"
|
||||
VICINAE_THEME="$VICINAE_THEME_DIR/tokyonight-moon.toml"
|
||||
mkdir -p "$VICINAE_THEME_DIR"
|
||||
|
||||
if [ -L "$VICINAE_THEME" ]; then
|
||||
rm "$VICINAE_THEME"
|
||||
fi
|
||||
# Every authored theme, not just the dark one: vicinae.json selects a theme per
|
||||
# system appearance, so linking only Moon left the launcher falling back to its
|
||||
# stock palette whenever Panama was in light mode.
|
||||
for theme_src in "$PANAMA_DOT"/vicinae/themes/*.toml; do
|
||||
[ -e "$theme_src" ] || continue
|
||||
theme_dst="$VICINAE_THEME_DIR/$(basename "$theme_src")"
|
||||
|
||||
if [ -e "$VICINAE_THEME" ]; then
|
||||
log "Keeping existing Vicinae theme at $VICINAE_THEME"
|
||||
else
|
||||
ln -s "$PANAMA_DOT/vicinae/themes/tokyonight-moon.toml" "$VICINAE_THEME"
|
||||
log "Linked Tokyo Night Moon theme → $VICINAE_THEME"
|
||||
fi
|
||||
if [ -L "$theme_dst" ]; then
|
||||
rm "$theme_dst"
|
||||
fi
|
||||
|
||||
if [ -e "$theme_dst" ]; then
|
||||
log "Keeping existing Vicinae theme at $theme_dst"
|
||||
else
|
||||
ln -s "$theme_src" "$theme_dst"
|
||||
log "Linked Vicinae theme → $theme_dst"
|
||||
fi
|
||||
done
|
||||
|
||||
# Panama-native applications live in the user data directory so launchers can
|
||||
# discover them alongside system desktop entries. Keep each authored file in
|
||||
|
||||
@@ -34,15 +34,35 @@ mkdir -p "$fixture/drm" "$fixture/dev" "$fixture/bin" "$fixture/i2c"
|
||||
# Two connectors with a monitor, two without. DP-2 answers DDC; DP-3 is
|
||||
# connected but does not implement brightness. HDMI-A-1 and DP-1 are empty and
|
||||
# must never be probed at all.
|
||||
# A connector has an EDID bus (the `ddc` symlink) and, on DisplayPort, an AUX
|
||||
# bus that appears as a child directory. DDC/CI rides the AUX channel where one
|
||||
# exists, and the `ddc` line answers nothing on a DP connector even though it
|
||||
# still resolves -- so the aux argument here is what a real DisplayPort monitor
|
||||
# looks like, and omitting it is what HDMI and DVI look like.
|
||||
make_connector() {
|
||||
local name="$1" bus="$2" status="$3"
|
||||
mkdir -p "$fixture/drm/$name"
|
||||
printf '%s\n' "$status" >"$fixture/drm/$name/status"
|
||||
mkdir -p "$fixture/i2c/i2c-$bus"
|
||||
ln -sfn "$fixture/i2c/i2c-$bus" "$fixture/drm/$name/ddc"
|
||||
local name="$1" ddc_bus="$2" status="$3" aux_bus="${4:-}"
|
||||
local device="$fixture/devices/$name"
|
||||
|
||||
# /sys/class/drm/<connector> is a SYMLINK to the real device directory, and
|
||||
# this fixture mirrors that rather than using a plain directory. It matters:
|
||||
# `find` does not follow the path it is given, so code that searches the
|
||||
# unresolved path finds nothing while appearing to work anywhere the entry
|
||||
# happens to be a real directory.
|
||||
mkdir -p "$device"
|
||||
printf '%s\n' "$status" >"$device/status"
|
||||
mkdir -p "$fixture/i2c/i2c-$ddc_bus"
|
||||
ln -sfn "$fixture/i2c/i2c-$ddc_bus" "$device/ddc"
|
||||
[[ -n "$aux_bus" ]] && mkdir -p "$device/i2c-$aux_bus"
|
||||
|
||||
mkdir -p "$fixture/drm"
|
||||
ln -sfn "$device" "$fixture/drm/$name"
|
||||
return 0
|
||||
}
|
||||
# DP-2 is the DisplayPort case: its EDID line is bus 5, which answers nothing,
|
||||
# and its AUX child is bus 9, which does. Choosing bus 5 here finds no monitor
|
||||
# at all, which is exactly the bug this pins down.
|
||||
make_connector card1-DP-1 4 disconnected
|
||||
make_connector card1-DP-2 5 connected
|
||||
make_connector card1-DP-2 5 connected 9
|
||||
make_connector card1-DP-3 6 connected
|
||||
make_connector card1-HDMI-A-1 7 disconnected
|
||||
|
||||
@@ -75,7 +95,7 @@ for arg in "$@"; do
|
||||
done
|
||||
|
||||
case "$bus" in
|
||||
5) printf 'VCP 10 C 120 200\n'; exit 0 ;;
|
||||
9) printf 'VCP 10 C 120 200\n'; exit 0 ;;
|
||||
*) exit 1 ;;
|
||||
esac
|
||||
STUB
|
||||
@@ -103,8 +123,9 @@ jq -e . >/dev/null 2>&1 <<<"$listing" || fail "list did not emit JSON: $listing"
|
||||
[[ "$(jq -r '.displays[0].connector' <<<"$listing")" == "DP-2" ]] \
|
||||
|| fail "the connector name must match Hyprland's output name: $listing"
|
||||
|
||||
[[ "$(jq -r '.displays[0].bus' <<<"$listing")" == "5" ]] \
|
||||
|| fail "the display was mapped to the wrong I2C bus: $listing"
|
||||
# The AUX bus, not the EDID bus its `ddc` symlink points at.
|
||||
[[ "$(jq -r '.displays[0].bus' <<<"$listing")" == "9" ]] \
|
||||
|| fail "the display was mapped to its EDID bus instead of its DisplayPort AUX bus, where nothing answers: $listing"
|
||||
|
||||
# 120 of a maximum of 200 is 60%.
|
||||
[[ "$(jq -r '.displays[0].value' <<<"$listing")" == "60" ]] \
|
||||
@@ -123,12 +144,12 @@ if grep -qxE '4|7' "$DDCUTIL_PROBE_LOG"; then
|
||||
fi
|
||||
|
||||
# ── Writes scale to the reported maximum ─────────────────────────────────────
|
||||
run_helper set 5 40
|
||||
[[ "$(tail -1 "$DDCUTIL_SET_LOG")" == "set 5 80" ]] \
|
||||
run_helper set 9 40
|
||||
[[ "$(tail -1 "$DDCUTIL_SET_LOG")" == "set 9 80" ]] \
|
||||
|| fail "set did not scale to the display's maximum: $(cat "$DDCUTIL_SET_LOG")"
|
||||
|
||||
run_helper set 5 500
|
||||
[[ "$(tail -1 "$DDCUTIL_SET_LOG")" == "set 5 200" ]] \
|
||||
run_helper set 9 500
|
||||
[[ "$(tail -1 "$DDCUTIL_SET_LOG")" == "set 9 200" ]] \
|
||||
|| fail "an out-of-range percent was not clamped: $(cat "$DDCUTIL_SET_LOG")"
|
||||
|
||||
# ── No I2C access explains itself ────────────────────────────────────────────
|
||||
|
||||
Executable
+95
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Every enum backed by a Hyprland option must offer values that option accepts.
|
||||
#
|
||||
# This exists because of a bug that shipped: followMouse offered 0/1/2 labelled
|
||||
# "Never" / "Click to focus" / "Sloppy focus", while Hyprland's actual mapping
|
||||
# is disabled=0, follow=1, detached=2, separate=3. The desktop was labelled
|
||||
# "Click to focus" and was in fact following the pointer, the way to GET click
|
||||
# to focus was to choose "Never", and value 3 did not exist in the UI at all.
|
||||
#
|
||||
# Nothing detects that. The compositor accepts 1, reads back 1, and verification
|
||||
# passes -- the value is valid, it just means something else entirely. The only
|
||||
# authority on what each number MEANS is the compositor, which publishes it:
|
||||
#
|
||||
# hyprctl descriptions -> { "name": "input:follow_mouse",
|
||||
# "map": [{"separate":3},{"detached":2},...] }
|
||||
#
|
||||
# So this checks the schema's enum values against that map, and against the
|
||||
# min/max range for mapped options that have no named map.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
schema="$repo_dir/config/dot/quickshell/config/PreferenceSchema.qml"
|
||||
|
||||
fail() {
|
||||
printf 'enum hypr map contract: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
command -v hyprctl >/dev/null 2>&1 || { printf 'enum hypr map contract: SKIP (no compositor)\n'; exit 0; }
|
||||
descriptions="$(hyprctl descriptions 2>/dev/null)" || fail 'could not read hyprctl descriptions'
|
||||
jq -e 'type == "array" and length > 0' >/dev/null <<<"$descriptions" \
|
||||
|| fail 'hyprctl descriptions did not return a list'
|
||||
|
||||
# Pull every enum entry that carries a hypr option, as: key<TAB>option<TAB>values
|
||||
entries="$(python3 - "$schema" <<'PY'
|
||||
import re, sys
|
||||
|
||||
text = open(sys.argv[1]).read()
|
||||
# Each schema entry is a brace-delimited block starting with `key:`.
|
||||
for block in re.findall(r'\{\s*\n?\s*key:\s*"([^"]+)"(.*?)\n \}', text, re.S):
|
||||
name, body = block
|
||||
if 'type: "enum"' not in body:
|
||||
continue
|
||||
option = re.search(r'option:\s*"([^"]+)"', body)
|
||||
if not option:
|
||||
continue
|
||||
values = re.findall(r'value:\s*(-?\d+)', body)
|
||||
if not values:
|
||||
continue
|
||||
print(f"{name}\t{option.group(1)}\t{','.join(values)}")
|
||||
PY
|
||||
)"
|
||||
|
||||
[[ -n "$entries" ]] || fail 'found no compositor-backed enums in the schema -- this contract is not reading it correctly'
|
||||
|
||||
checked=0
|
||||
while IFS=$'\t' read -r key option values; do
|
||||
[[ -n "$key" ]] || continue
|
||||
|
||||
entry="$(jq -c --arg name "$option" '.[] | select(.name == $name)' <<<"$descriptions")"
|
||||
[[ -n "$entry" ]] || fail "$key maps to \"$option\", which the compositor does not publish"
|
||||
|
||||
map_values="$(jq -r 'if .map then (.map | map(to_entries[].value) | join(",")) else "" end' <<<"$entry")"
|
||||
|
||||
IFS=',' read -ra wanted <<<"$values"
|
||||
for value in "${wanted[@]}"; do
|
||||
if [[ -n "$map_values" ]]; then
|
||||
grep -qx "$value" <<<"$(tr ',' '\n' <<<"$map_values")" \
|
||||
|| fail "$key offers $value for $option, which the compositor's map does not contain (it publishes: $map_values). A value outside the map is accepted and read back unchanged, so nothing else notices -- it simply means something other than the label says."
|
||||
else
|
||||
min="$(jq -r '.min // empty' <<<"$entry")"
|
||||
max="$(jq -r '.max // empty' <<<"$entry")"
|
||||
if [[ -n "$min" && -n "$max" ]]; then
|
||||
(( value >= min && value <= max )) \
|
||||
|| fail "$key offers $value for $option, outside the compositor's range $min..$max"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
# Every value the compositor names should be offered. A missing one is a
|
||||
# capability the user simply cannot reach -- value 3 was missing here.
|
||||
if [[ -n "$map_values" ]]; then
|
||||
while read -r published; do
|
||||
[[ -n "$published" ]] || continue
|
||||
grep -qx "$published" <<<"$(tr ',' '\n' <<<"$values")" \
|
||||
|| fail "$option publishes value $published but $key does not offer it, so that behaviour is unreachable from Settings"
|
||||
done <<<"$(tr ',' '\n' <<<"$map_values")"
|
||||
fi
|
||||
|
||||
checked=$((checked + 1))
|
||||
done <<<"$entries"
|
||||
|
||||
printf 'enum hypr map contract: PASS (%d mapped enums)\n' "$checked"
|
||||
Executable
+88
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Every GTK theme name Panama sets must be a theme that is actually installed.
|
||||
#
|
||||
# This exists because of a bug that was invisible for weeks. ColorScheme set
|
||||
# gtk-theme to "Adwaita-dark" for dark and "Adwaita" for light. Neither is
|
||||
# installed on Fedora 44 -- only adw-gtk3 and adw-gtk3-dark are -- and GTK
|
||||
# responds to an unknown theme name by silently falling back to its built-in
|
||||
# default, which is LIGHT.
|
||||
#
|
||||
# So light mode appeared to work, dark mode produced light windows, and nothing
|
||||
# anywhere reported an error. Applications that take their cue from the GTK
|
||||
# theme rather than the portal -- Chromium and Electron among them -- were stuck
|
||||
# light with no way to diagnose it from inside the application.
|
||||
#
|
||||
# The failure is silent by construction, so it needs a test rather than a
|
||||
# comment. Checks the compositor-facing setting and the generated GTK config
|
||||
# agree, and that both name something real.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
color_scheme="$repo_dir/config/dot/quickshell/services/ColorScheme.qml"
|
||||
theme_apps="$repo_dir/config/dot/quickshell/scripts/panama-theme-apps"
|
||||
|
||||
fail() {
|
||||
printf 'gtk theme contract: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
theme_installed() {
|
||||
local name="$1" dir
|
||||
for dir in /usr/share/themes "$HOME/.themes" "$HOME/.local/share/themes"; do
|
||||
[[ -d "$dir/$name/gtk-3.0" ]] && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# ── The names ColorScheme sets must exist ────────────────────────────────────
|
||||
names="$(grep -oE 'root\.dark \? "[a-zA-Z0-9-]+" : "[a-zA-Z0-9-]+"' "$color_scheme" \
|
||||
| grep -oE '"[a-zA-Z0-9-]+"' | tr -d '"' | grep -E '^adw-|^Adwaita' | sort -u)"
|
||||
[[ -n "$names" ]] || fail 'could not find the GTK theme names in ColorScheme.qml -- this contract is not reading it correctly'
|
||||
|
||||
while read -r name; do
|
||||
[[ -n "$name" ]] || continue
|
||||
theme_installed "$name" \
|
||||
|| fail "ColorScheme sets gtk-theme to \"$name\", which is not installed. GTK falls back to its light default when a theme is missing, so this produces light windows in dark mode with no error anywhere."
|
||||
done <<<"$names"
|
||||
|
||||
# ── The generated GTK config must agree, in both directions ──────────────────
|
||||
# Generated into a fixture rather than the live config, so running this cannot
|
||||
# retheme the desktop it is running on.
|
||||
fixture="$(mktemp -d /tmp/panama-gtk-theme.XXXXXX)"
|
||||
trap 'rm -rf "$fixture"' EXIT
|
||||
|
||||
for version in 3.0 4.0; do
|
||||
mkdir -p "$fixture/gtk-$version"
|
||||
cp "$repo_dir/config/dot/gtk-$version/settings.ini.template" "$fixture/gtk-$version/" \
|
||||
|| fail "gtk-$version has no settings.ini.template -- the generated file would never be produced"
|
||||
done
|
||||
|
||||
for scheme in dark light; do
|
||||
XDG_CONFIG_HOME="$fixture" "$theme_apps" "$scheme" >/dev/null 2>&1
|
||||
|
||||
for version in 3.0 4.0; do
|
||||
generated="$fixture/gtk-$version/settings.ini"
|
||||
[[ -r "$generated" ]] || fail "gtk-$version settings.ini was not generated for $scheme"
|
||||
|
||||
grep -q '@GTK_THEME@\|@PREFER_DARK@' "$generated" \
|
||||
&& fail "gtk-$version settings.ini still contains an unsubstituted placeholder for $scheme"
|
||||
|
||||
theme="$(sed -n 's/^gtk-theme-name=//p' "$generated")"
|
||||
prefer="$(sed -n 's/^gtk-application-prefer-dark-theme=//p' "$generated")"
|
||||
|
||||
theme_installed "$theme" \
|
||||
|| fail "gtk-$version settings.ini names \"$theme\" for $scheme, which is not installed"
|
||||
|
||||
if [[ "$scheme" == "dark" ]]; then
|
||||
[[ "$prefer" == "1" ]] || fail "gtk-$version asks for prefer-dark=$prefer in dark mode"
|
||||
[[ "$theme" == *dark* ]] || fail "gtk-$version uses \"$theme\" in dark mode, which is not a dark theme"
|
||||
else
|
||||
[[ "$prefer" == "0" ]] || fail "gtk-$version asks for prefer-dark=$prefer in light mode"
|
||||
[[ "$theme" != *dark* ]] || fail "gtk-$version uses \"$theme\" in light mode, which is a dark theme"
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
printf 'gtk theme contract: PASS\n'
|
||||
@@ -60,6 +60,14 @@ rg -Fq 'Health.refresh()' "$settings_dir/HealthPage.qml" \
|
||||
|| fail 'opening System Health does not request a fresh scan'
|
||||
rg -Fq 'SystemSettings.openGnomePanel("network")' "$settings_dir/HealthPage.qml" \
|
||||
|| fail 'Fedora ownership boundary does not open GNOME Settings'
|
||||
rg -Fq 'SystemSettings.openGnomePanel("system", "users")' "$settings_dir/HealthPage.qml" \
|
||||
|| fail 'Fedora ownership boundary lost the Users handoff'
|
||||
rg -Fq 'SystemSettings.openGnomePanel("sharing")' "$settings_dir/HealthPage.qml" \
|
||||
|| fail 'Fedora ownership boundary lost the Sharing handoff'
|
||||
rg -Fq 'SystemSettings.openGnomePanel("color")' "$settings_dir/HealthPage.qml" \
|
||||
|| fail 'Fedora ownership boundary lost the Colour profiles handoff'
|
||||
rg -Fq 'SystemSettings.openGnomePanel("wellbeing")' "$settings_dir/HealthPage.qml" \
|
||||
|| fail 'Fedora ownership boundary lost the Digital wellbeing handoff'
|
||||
rg -Fq 'Health.repair(check.id, false)' "$settings_dir/HealthPage.qml" \
|
||||
|| fail 'Settings repair does not stay inline/non-external'
|
||||
rg -Fq 'ShellState.openSettings(check.action.target)' "$settings_dir/HealthPage.qml" \
|
||||
@@ -278,6 +286,12 @@ jq -e '
|
||||
and (.renderedRows | map(.id) | length) == 6
|
||||
and (.renderedRows | map(.id) | unique | length) == 6
|
||||
and .emptyQuietGroups == ["desktop-foundation"]
|
||||
and .fedoraHandoffs == [
|
||||
{id:"users", label:"Users", action:"Open users"},
|
||||
{id:"sharing", label:"Sharing", action:"Open sharing"},
|
||||
{id:"color", label:"Colour profiles", action:"Open colour"},
|
||||
{id:"wellbeing", label:"Digital wellbeing", action:"Open wellbeing"}
|
||||
]
|
||||
and .summaryHeight == 126
|
||||
and (.rowHeights | length) == 6
|
||||
and (.rowHeights | all(. >= 62))
|
||||
|
||||
@@ -288,18 +288,18 @@ shell_pid="$(qs_for_test list | awk '/Process ID:/ { print $3; exit }')"
|
||||
[[ "$shell_pid" =~ ^[0-9]+$ ]] || fail 'could not identify the branch shell process'
|
||||
for _ in $(seq 1 40); do
|
||||
if /usr/sbin/hyprctl -j clients | jq -e --argjson pid "$shell_pid" \
|
||||
'[.[] | select(.pid == $pid and .title == "Panama Settings" and .floating == false)] | length == 1' >/dev/null; then
|
||||
'[.[] | select(.pid == $pid and .title == "Settings" and .floating == false)] | length == 1' >/dev/null; then
|
||||
break
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
/usr/sbin/hyprctl -j clients | jq -e --argjson pid "$shell_pid" \
|
||||
'[.[] | select(.pid == $pid and .title == "Panama Settings" and .floating == false)] | length == 1' >/dev/null \
|
||||
'[.[] | select(.pid == $pid and .title == "Settings" and .floating == false)] | length == 1' >/dev/null \
|
||||
|| fail 'the branch shell did not own exactly one tiled Panama Settings client'
|
||||
|
||||
if [[ -n "${PANAMA_TEST_SCREENSHOT_PATH:-}" ]]; then
|
||||
geometry="$(/usr/sbin/hyprctl -j clients | jq -r --argjson pid "$shell_pid" \
|
||||
'.[] | select(.pid == $pid and .title == "Panama Settings") | "\(.at[0]),\(.at[1]) \(.size[0])x\(.size[1])"')"
|
||||
'.[] | select(.pid == $pid and .title == "Settings") | "\(.at[0]),\(.at[1]) \(.size[0])x\(.size[1])"')"
|
||||
[[ -n "$geometry" ]] || fail 'could not resolve the Settings client geometry'
|
||||
grim -g "$geometry" "$PANAMA_TEST_SCREENSHOT_PATH"
|
||||
fi
|
||||
|
||||
Executable
+108
@@ -0,0 +1,108 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# panama-keyring reports the login keyring's state, and the Settings page reads
|
||||
# nothing but its JSON.
|
||||
#
|
||||
# The state that matters is LOCKED, and it is also the one that cannot be
|
||||
# rehearsed on a real desktop: locking the login keyring breaks every saved
|
||||
# password on the machine and can only be undone by typing the password into a
|
||||
# dialog. So the secret service is stubbed here instead. Nothing touches the
|
||||
# real keyring -- this contract is safe to run on the daily driver, which is the
|
||||
# entire reason it is written this way.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
helper="$repo_dir/config/dot/quickshell/scripts/panama-keyring"
|
||||
|
||||
fail() {
|
||||
printf 'keyring helper contract: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
stub_dir="$(mktemp -d /tmp/panama-keyring.XXXXXX)"
|
||||
trap 'rm -rf "$stub_dir"' EXIT
|
||||
|
||||
# A stand-in for the `gi` module the helper imports. PANAMA_KEYRING_FAKE decides
|
||||
# what the fake service reports, so one stub covers every case.
|
||||
mkdir -p "$stub_dir/gi/repository"
|
||||
cat >"$stub_dir/gi/__init__.py" <<'STUB'
|
||||
def require_version(*_args, **_kwargs):
|
||||
return None
|
||||
STUB
|
||||
cat >"$stub_dir/gi/repository/__init__.py" <<'STUB'
|
||||
import os
|
||||
|
||||
|
||||
class _Collection:
|
||||
def __init__(self, label, locked):
|
||||
self._label = label
|
||||
self._locked = locked
|
||||
|
||||
def get_label(self):
|
||||
return self._label
|
||||
|
||||
def get_locked(self):
|
||||
return self._locked
|
||||
|
||||
|
||||
class _Service:
|
||||
def get_collections(self):
|
||||
mode = os.environ.get("PANAMA_KEYRING_FAKE", "unlocked")
|
||||
if mode == "nologin":
|
||||
return [_Collection("Some App", False)]
|
||||
return [_Collection("Login", mode == "locked"), _Collection("", False)]
|
||||
|
||||
|
||||
class _ServiceFactory:
|
||||
@staticmethod
|
||||
def get_sync(_flags, _cancellable):
|
||||
if os.environ.get("PANAMA_KEYRING_FAKE") == "unavailable":
|
||||
raise RuntimeError("no secret service")
|
||||
return _Service()
|
||||
|
||||
# unlock_sync is what the `unlock` action calls; record that it was reached.
|
||||
@staticmethod
|
||||
def _noop(*_args, **_kwargs):
|
||||
return None
|
||||
|
||||
|
||||
class Secret:
|
||||
class ServiceFlags:
|
||||
LOAD_COLLECTIONS = 1
|
||||
|
||||
Service = _ServiceFactory
|
||||
STUB
|
||||
|
||||
run() {
|
||||
PYTHONPATH="$stub_dir" PANAMA_KEYRING_FAKE="$1" python3 "$helper" "${2:-status}"
|
||||
}
|
||||
|
||||
# ── Unlocked: the normal state after any sign-in ─────────────────────────────
|
||||
out="$(run unlocked)"
|
||||
jq -e . >/dev/null 2>&1 <<<"$out" || fail "status did not emit JSON: $out"
|
||||
jq -e '.available == true and .locked == false and .hasLogin == true' >/dev/null <<<"$out" \
|
||||
|| fail "an unlocked login keyring was misreported: $out"
|
||||
|
||||
# ── Locked: the state the whole card exists for ──────────────────────────────
|
||||
out="$(run locked)"
|
||||
jq -e '.available == true and .locked == true' >/dev/null <<<"$out" \
|
||||
|| fail "a locked login keyring was not reported as locked: $out"
|
||||
|
||||
# ── No secret service at all is a state, not a crash ─────────────────────────
|
||||
out="$(run unavailable)"
|
||||
jq -e . >/dev/null 2>&1 <<<"$out" \
|
||||
|| fail "a missing secret service produced no JSON, so the page would show nothing: $out"
|
||||
jq -e '.available == false and .error != ""' >/dev/null <<<"$out" \
|
||||
|| fail "a missing secret service must be reported with a reason: $out"
|
||||
|
||||
# ── No login keyring: not locked, because there is nothing to lock ───────────
|
||||
out="$(run nologin)"
|
||||
jq -e '.available == true and .hasLogin == false and .locked == false' >/dev/null <<<"$out" \
|
||||
|| fail "a machine with no login keyring must not report itself locked: $out"
|
||||
|
||||
# ── The daemon origin is reported, since it is the crash diagnostic ──────────
|
||||
jq -e '.daemon | test("^(pam|dbus|none|unknown)$")' >/dev/null <<<"$(run unlocked)" \
|
||||
|| fail "the daemon origin must be one of pam/dbus/none/unknown"
|
||||
|
||||
printf 'keyring helper contract: PASS\n'
|
||||
Executable
+72
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Versioned upgrades for settings.json.
|
||||
#
|
||||
# The schema says what a setting IS; it cannot say what a setting USED to be.
|
||||
# Rename a key, change its units, or split one setting into two, and the stored
|
||||
# value stops being recognised -- and unrecognised keys are deliberately carried
|
||||
# through untouched, so the user's choice silently stops taking effect with
|
||||
# nothing to explain it.
|
||||
#
|
||||
# The list of migrations is empty today, which is exactly why this is tested
|
||||
# now: the first time it runs for real will be against somebody's actual
|
||||
# settings during an upgrade, and that is a poor moment to discover how it
|
||||
# behaves. The harness supplies fixture steps, including one that throws.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
harness="$repo_dir/config/dot/quickshell/migrations-harness.qml"
|
||||
|
||||
fail() {
|
||||
printf 'migrations contract: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
[[ -r "$harness" ]] || fail "the harness is missing: $harness"
|
||||
|
||||
out="$(timeout 60 qs -p "$harness" 2>&1 | grep -o 'PANAMA-MIGRATIONS .*' | sed 's/^PANAMA-MIGRATIONS //')"
|
||||
[[ -n "$out" ]] || fail 'the harness produced no result'
|
||||
jq -e . >/dev/null 2>&1 <<<"$out" || fail "the harness did not emit JSON: $out"
|
||||
|
||||
check() {
|
||||
jq -e "$1" >/dev/null <<<"$out" || fail "$2 -- got $(jq -c "$3" <<<"$out")"
|
||||
}
|
||||
|
||||
# A file written before versioning existed is stamped, NOT migrated. Running
|
||||
# the list against it would apply upgrades designed for schemas it never had.
|
||||
check '.unversioned.v == 1 and .unversioned.migrated == false and .unversioned.untouched == true' \
|
||||
'a file with no schemaVersion must be stamped at the baseline without being migrated' '.unversioned'
|
||||
|
||||
# The stamp has to reach disk. Reported as changed-but-not-migrated, it would
|
||||
# otherwise live only in memory and be redone on every single launch.
|
||||
check '.unversioned.changed == true' \
|
||||
'stamping a pre-versioning file must be reported as a change so it gets written' '.unversioned'
|
||||
|
||||
# A file from the future must not be rewritten at all.
|
||||
check '.future.changed == false' \
|
||||
'a file from a newer version must not be written back' '.future'
|
||||
|
||||
# Every step above the stored version runs, in order.
|
||||
check '.upgrade.v == 3 and .upgrade.b == 2 and .upgrade.c == "three" and .upgrade.count == 2' \
|
||||
'an older file must run each pending step in order and end at the current version' '.upgrade'
|
||||
|
||||
# Already current: nothing runs, nothing is touched.
|
||||
check '.current.migrated == false and .current.kept == true and .current.b == true' \
|
||||
'a file already at the current version must be left alone' '.current'
|
||||
|
||||
# A file from a NEWER Panama is left completely alone. Downgrading keys is not
|
||||
# something this can do correctly, and unknown keys are already preserved.
|
||||
check '.future.v == 9 and .future.migrated == false and .future.kept == true' \
|
||||
'a file from a newer version must not be modified or downgraded' '.future'
|
||||
|
||||
# A failing step stops at the last good version. Skipping past it would lose
|
||||
# the conversion forever; failing the whole load would cost every setting.
|
||||
check '.failure.v == 3 and .failure.count == 2 and .failure.kept == true' \
|
||||
'a failing step must stop at the last good version, keeping the steps that succeeded' '.failure'
|
||||
|
||||
# The promise that makes rollback safe.
|
||||
check '.preserved.kept == true' \
|
||||
'a migration must not discard keys it does not recognise' '.preserved'
|
||||
|
||||
printf 'migrations contract: PASS\n'
|
||||
@@ -26,10 +26,62 @@ printf 'brightnessctl' >>"$OSD_TEST_LOG"
|
||||
printf ' <%s>' "$@" >>"$OSD_TEST_LOG"
|
||||
printf '\n' >>"$OSD_TEST_LOG"
|
||||
if [[ " $* " == *" -m "* && " $* " != *" set "* ]]; then
|
||||
[[ ${BACKLIGHT_AVAILABLE:-true} == true ]] || exit 1
|
||||
printf '%s\n' "${BRIGHTNESS_OUTPUT:-intel_backlight,backlight,500,1000,50%}"
|
||||
fi
|
||||
SH
|
||||
|
||||
cat >"$scratch/bin/panama-brightness" <<'SH'
|
||||
#!/bin/bash
|
||||
printf 'panama-brightness' >>"$OSD_TEST_LOG"
|
||||
printf ' <%s>' "$@" >>"$OSD_TEST_LOG"
|
||||
printf '\n' >>"$OSD_TEST_LOG"
|
||||
|
||||
case "${1:-}" in
|
||||
list)
|
||||
if [[ -n ${DDC_LIST_JSON:-} ]]; then
|
||||
printf '%s\n' "$DDC_LIST_JSON"
|
||||
else
|
||||
printf '%s\n' '{"displays":[],"error":"No displays"}'
|
||||
fi
|
||||
;;
|
||||
get)
|
||||
[[ ${DDC_FAIL_GET_BUS:-} != "${2:-}" ]] || exit 1
|
||||
if [[ -s $OSD_DDC_STATE ]]; then
|
||||
cat "$OSD_DDC_STATE"
|
||||
else
|
||||
printf '%s\n' "${DDC_GET_VALUE:-40}"
|
||||
fi
|
||||
;;
|
||||
set)
|
||||
if [[ -n ${DDC_SET_DELAY:-} ]]; then
|
||||
if ! mkdir "$OSD_DDC_PROBE" 2>/dev/null; then
|
||||
printf 'ddc-overlap\n' >>"$OSD_TEST_LOG"
|
||||
fi
|
||||
sleep "$DDC_SET_DELAY"
|
||||
rmdir "$OSD_DDC_PROBE" 2>/dev/null || true
|
||||
fi
|
||||
printf '%s\n' "${3:-0}" >"$OSD_DDC_STATE"
|
||||
;;
|
||||
*) exit 2 ;;
|
||||
esac
|
||||
SH
|
||||
|
||||
cat >"$scratch/bin/hyprctl" <<'SH'
|
||||
#!/bin/bash
|
||||
printf 'hyprctl' >>"$OSD_TEST_LOG"
|
||||
printf ' <%s>' "$@" >>"$OSD_TEST_LOG"
|
||||
printf '\n' >>"$OSD_TEST_LOG"
|
||||
printf '[{"name":"%s","focused":true}]\n' "${FOCUSED_MONITOR:-DP-2}"
|
||||
SH
|
||||
|
||||
cat >"$scratch/bin/notify-send" <<'SH'
|
||||
#!/bin/bash
|
||||
printf 'notify-send' >>"$OSD_TEST_LOG"
|
||||
printf ' <%s>' "$@" >>"$OSD_TEST_LOG"
|
||||
printf '\n' >>"$OSD_TEST_LOG"
|
||||
SH
|
||||
|
||||
cat >"$scratch/bin/playerctl" <<'SH'
|
||||
#!/bin/bash
|
||||
printf 'playerctl' >>"$OSD_TEST_LOG"
|
||||
@@ -53,9 +105,21 @@ SH
|
||||
chmod +x "$scratch/bin/"*
|
||||
|
||||
run_helper() {
|
||||
local runtime="${OSD_RUNTIME_DIR:-$scratch/runtime-default}"
|
||||
mkdir -p "$runtime"
|
||||
PATH="$scratch/bin:$PATH" OSD_TEST_LOG="$log" \
|
||||
OSD_TEST_FAIL_QS="${OSD_TEST_FAIL_QS:-false}" \
|
||||
PANAMA_OSD_STRICT="${PANAMA_OSD_STRICT:-false}" \
|
||||
PANAMA_OSD_BRIGHTNESS_HELPER="$scratch/bin/panama-brightness" \
|
||||
PANAMA_OSD_RUNTIME_DIR="$runtime" \
|
||||
OSD_DDC_STATE="$runtime/ddc-state" \
|
||||
OSD_DDC_PROBE="$runtime/ddc-probe" \
|
||||
BACKLIGHT_AVAILABLE="${BACKLIGHT_AVAILABLE:-true}" \
|
||||
DDC_LIST_JSON="${DDC_LIST_JSON:-}" \
|
||||
DDC_GET_VALUE="${DDC_GET_VALUE:-40}" \
|
||||
DDC_FAIL_GET_BUS="${DDC_FAIL_GET_BUS:-}" \
|
||||
DDC_SET_DELAY="${DDC_SET_DELAY:-}" \
|
||||
FOCUSED_MONITOR="${FOCUSED_MONITOR:-DP-2}" \
|
||||
"$helper" "$@"
|
||||
}
|
||||
|
||||
@@ -86,9 +150,102 @@ assert_line 'qs <ipc> <call> <osd> <progress> <microphone-muted> <72> <100> <Mut
|
||||
|
||||
: >"$log"
|
||||
run_helper brightness up 5
|
||||
assert_line 'brightnessctl <-e4> <-n2> <set> <5%+>'
|
||||
assert_line 'brightnessctl <-m> <-c> <backlight>'
|
||||
assert_line 'brightnessctl <-e4> <-n2> <-c> <backlight> <set> <5%+>'
|
||||
assert_line 'qs <ipc> <call> <osd> <progress> <brightness> <50> <100> <50%>'
|
||||
if grep -Fq 'panama-brightness' "$log"; then
|
||||
printf 'osd helper contract: DDC fallback ran despite a native backlight\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
: >"$log"
|
||||
OSD_RUNTIME_DIR="$scratch/runtime-ddc" \
|
||||
BACKLIGHT_AVAILABLE=false \
|
||||
DDC_LIST_JSON='{"displays":[{"bus":3,"connector":"HDMI-A-1","value":35},{"bus":5,"connector":"DP-2","value":40}],"error":""}' \
|
||||
run_helper brightness up 5
|
||||
assert_line 'hyprctl <-j> <monitors>'
|
||||
assert_line 'panama-brightness <list>'
|
||||
assert_line 'panama-brightness <get> <5>'
|
||||
assert_line 'panama-brightness <set> <5> <45>'
|
||||
assert_line 'qs <ipc> <call> <osd> <progress> <brightness> <45> <100> <45%>'
|
||||
|
||||
# A cached bus avoids the expensive display scan on subsequent key presses.
|
||||
: >"$log"
|
||||
OSD_RUNTIME_DIR="$scratch/runtime-ddc" \
|
||||
BACKLIGHT_AVAILABLE=false \
|
||||
DDC_LIST_JSON='{"displays":[{"bus":3,"connector":"HDMI-A-1","value":35},{"bus":5,"connector":"DP-2","value":45}],"error":""}' \
|
||||
run_helper brightness down 5
|
||||
assert_line 'panama-brightness <get> <5>'
|
||||
assert_line 'panama-brightness <set> <5> <40>'
|
||||
assert_line 'qs <ipc> <call> <osd> <progress> <brightness> <40> <100> <40%>'
|
||||
if grep -Fq 'panama-brightness <list>' "$log"; then
|
||||
printf 'osd helper contract: cached DDC bus triggered another display scan\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# A disconnected cached monitor is discarded and rediscovered once.
|
||||
mkdir -p "$scratch/runtime-ddc-stale"
|
||||
printf '9\tDP-9\n' >"$scratch/runtime-ddc-stale/brightness-bus"
|
||||
: >"$log"
|
||||
OSD_RUNTIME_DIR="$scratch/runtime-ddc-stale" \
|
||||
BACKLIGHT_AVAILABLE=false \
|
||||
DDC_FAIL_GET_BUS=9 \
|
||||
DDC_LIST_JSON='{"displays":[{"bus":5,"connector":"DP-2","value":40}],"error":""}' \
|
||||
run_helper brightness up 5
|
||||
assert_line 'panama-brightness <get> <9>'
|
||||
assert_line 'panama-brightness <list>'
|
||||
assert_line 'panama-brightness <get> <5>'
|
||||
assert_line 'panama-brightness <set> <5> <45>'
|
||||
|
||||
# If the focused output is not DDC-capable, use the first discovered display.
|
||||
: >"$log"
|
||||
OSD_RUNTIME_DIR="$scratch/runtime-ddc-first" \
|
||||
BACKLIGHT_AVAILABLE=false \
|
||||
FOCUSED_MONITOR='eDP-1' \
|
||||
DDC_GET_VALUE=35 \
|
||||
DDC_LIST_JSON='{"displays":[{"bus":3,"connector":"HDMI-A-1","value":35},{"bus":5,"connector":"DP-2","value":40}],"error":""}' \
|
||||
run_helper brightness down 10
|
||||
assert_line 'panama-brightness <get> <3>'
|
||||
assert_line 'panama-brightness <set> <3> <25>'
|
||||
assert_line 'qs <ipc> <call> <osd> <progress> <brightness> <25> <100> <25%>'
|
||||
|
||||
# Permission and discovery errors must be visible, never masquerade as 0%.
|
||||
: >"$log"
|
||||
OSD_RUNTIME_DIR="$scratch/runtime-ddc-error" \
|
||||
BACKLIGHT_AVAILABLE=false \
|
||||
DDC_LIST_JSON='{"displays":[],"error":"Run sudo udevadm control --reload-rules && sudo udevadm trigger --subsystem-match=i2c-dev --subsystem-match=drm"}' \
|
||||
run_helper brightness up 5
|
||||
assert_line 'qs <ipc> <call> <osd> <message> <dialog-warning-symbolic> <Brightness needs permission>'
|
||||
assert_line 'notify-send <--app-name=Panama> <--icon=display-brightness-symbolic> <Brightness unavailable> <Run sudo udevadm control --reload-rules && sudo udevadm trigger --subsystem-match=i2c-dev --subsystem-match=drm>'
|
||||
if grep -Fq 'osd> <progress> <brightness>' "$log"; then
|
||||
printf 'osd helper contract: unavailable brightness rendered a false percentage\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Separate key-repeat processes must not overlap their DDC transactions.
|
||||
: >"$log"
|
||||
OSD_RUNTIME_DIR="$scratch/runtime-ddc-lock" \
|
||||
BACKLIGHT_AVAILABLE=false \
|
||||
DDC_SET_DELAY=0.15 \
|
||||
DDC_LIST_JSON='{"displays":[{"bus":5,"connector":"DP-2","value":40}],"error":""}' \
|
||||
run_helper brightness up 5 &
|
||||
first_pid=$!
|
||||
OSD_RUNTIME_DIR="$scratch/runtime-ddc-lock" \
|
||||
BACKLIGHT_AVAILABLE=false \
|
||||
DDC_SET_DELAY=0.15 \
|
||||
DDC_LIST_JSON='{"displays":[{"bus":5,"connector":"DP-2","value":40}],"error":""}' \
|
||||
run_helper brightness up 5 &
|
||||
second_pid=$!
|
||||
wait "$first_pid"
|
||||
wait "$second_pid"
|
||||
if grep -Fqx 'ddc-overlap' "$log"; then
|
||||
printf 'osd helper contract: concurrent DDC transactions overlapped\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ $(<"$scratch/runtime-ddc-lock/ddc-state") != 50 ]]; then
|
||||
printf 'osd helper contract: serialized key repeats did not both apply\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
: >"$log"
|
||||
run_helper media next
|
||||
|
||||
Executable
+103
@@ -0,0 +1,103 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# panama-power-profile reads and sets the system power profile.
|
||||
#
|
||||
# Runs against a stubbed busctl. The real daemon is a SYSTEM service shared with
|
||||
# everything else on the machine, and a test that flipped the daily driver into
|
||||
# power-saver and crashed before restoring would leave it there.
|
||||
#
|
||||
# The parsing is the fragile part. busctl renders the Profiles property flat:
|
||||
#
|
||||
# v aa{sv} 3 2 "Profile" s "power-saver" "Driver" s "tuned" 2 "Profile" ...
|
||||
#
|
||||
# so profile names and driver names sit in the same stream. A pattern loose
|
||||
# enough to match both reports the driver as an extra profile -- and on this
|
||||
# machine the driver is literally called "tuned", which reads exactly like a
|
||||
# plausible fourth profile.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
helper="$repo_dir/config/dot/quickshell/scripts/panama-power-profile"
|
||||
|
||||
fail() {
|
||||
printf 'power profile contract: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
stub_dir="$(mktemp -d /tmp/panama-power.XXXXXX)"
|
||||
trap 'rm -rf "$stub_dir"' EXIT
|
||||
|
||||
cat >"$stub_dir/busctl" <<'STUB'
|
||||
#!/usr/bin/env bash
|
||||
# Records set-property calls so the test can assert what was written.
|
||||
case "${1:-}" in
|
||||
status)
|
||||
[[ "${PANAMA_POWER_FAKE:-up}" == "down" ]] && exit 1
|
||||
exit 0 ;;
|
||||
get-property)
|
||||
case "${5:-}" in
|
||||
ActiveProfile) printf 's "performance"\n' ;;
|
||||
PerformanceDegraded) printf 's "%s"\n' "${PANAMA_POWER_DEGRADED:-}" ;;
|
||||
Profiles)
|
||||
if [[ "${PANAMA_POWER_FAKE:-up}" == "empty" ]]; then
|
||||
printf 'v aa{sv} 0\n'
|
||||
else
|
||||
printf 'v aa{sv} 3 2 "Profile" s "power-saver" "Driver" s "tuned" 2 "Profile" s "balanced" "Driver" s "tuned" 2 "Profile" s "performance" "Driver" s "tuned"\n'
|
||||
fi ;;
|
||||
esac
|
||||
exit 0 ;;
|
||||
set-property)
|
||||
printf '%s\n' "${!#}" >>"$PANAMA_POWER_SET_LOG"
|
||||
exit 0 ;;
|
||||
esac
|
||||
exit 0
|
||||
STUB
|
||||
chmod +x "$stub_dir/busctl"
|
||||
|
||||
export PANAMA_POWER_SET_LOG="$stub_dir/sets.log"
|
||||
: >"$PANAMA_POWER_SET_LOG"
|
||||
|
||||
run() { PATH="$stub_dir:$PATH" "$helper" "$@"; }
|
||||
|
||||
# ── Parsing ──────────────────────────────────────────────────────────────────
|
||||
out="$(run list)"
|
||||
jq -e . >/dev/null 2>&1 <<<"$out" || fail "list did not emit JSON: $out"
|
||||
|
||||
[[ "$(jq -r '.profiles | length' <<<"$out")" == "3" ]] \
|
||||
|| fail "expected exactly three profiles; a fourth usually means the Driver value was parsed as one: $out"
|
||||
|
||||
jq -e '.profiles == ["power-saver", "balanced", "performance"]' >/dev/null <<<"$out" \
|
||||
|| fail "profiles were parsed wrongly or reordered: $out"
|
||||
|
||||
jq -e '.profiles | index("tuned") == null' >/dev/null <<<"$out" \
|
||||
|| fail 'the driver name "tuned" was reported as a profile'
|
||||
|
||||
[[ "$(jq -r '.active' <<<"$out")" == "performance" ]] \
|
||||
|| fail "the active profile was not read: $out"
|
||||
|
||||
# ── Degradation is surfaced, since it makes the active profile a lie ─────────
|
||||
out="$(PANAMA_POWER_DEGRADED="lap-detected" run list)"
|
||||
[[ "$(jq -r '.degraded' <<<"$out")" == "lap-detected" ]] \
|
||||
|| fail "a degraded performance state was not reported: $out"
|
||||
|
||||
# ── Setting ──────────────────────────────────────────────────────────────────
|
||||
run set balanced
|
||||
[[ "$(tail -1 "$PANAMA_POWER_SET_LOG")" == "balanced" ]] \
|
||||
|| fail "set did not write the requested profile: $(cat "$PANAMA_POWER_SET_LOG")"
|
||||
|
||||
before="$(wc -l <"$PANAMA_POWER_SET_LOG")"
|
||||
run set 'evil; rm -rf /' 2>/dev/null
|
||||
[[ "$(wc -l <"$PANAMA_POWER_SET_LOG")" == "$before" ]] \
|
||||
|| fail 'a profile name with shell metacharacters reached the system service'
|
||||
|
||||
# ── No daemon, and a daemon with nothing to offer, are both states ───────────
|
||||
out="$(PANAMA_POWER_FAKE=down run list)"
|
||||
jq -e '.profiles == [] and .error != ""' >/dev/null <<<"$out" \
|
||||
|| fail "a missing power daemon must be reported with a reason: $out"
|
||||
|
||||
out="$(PANAMA_POWER_FAKE=empty run list)"
|
||||
jq -e '.profiles == [] and .error != ""' >/dev/null <<<"$out" \
|
||||
|| fail "a daemon offering no profiles must be reported, not shown as an empty card: $out"
|
||||
|
||||
printf 'power profile contract: PASS\n'
|
||||
@@ -61,6 +61,26 @@ restore() {
|
||||
}
|
||||
trap restore EXIT
|
||||
|
||||
# This contract shares its harness file with settings-hyprland-write-contract,
|
||||
# and Quickshell identifies an instance by config path -- so if that run's
|
||||
# instance is still alive, the IPC wait below is satisfied by ITS target. That
|
||||
# direction is the dangerous one: this contract believes the compositor seam is
|
||||
# stubbed, so it would happily drive the DAILY DESKTOP's real compositor while
|
||||
# reporting isolation. Refuse to start rather than find out.
|
||||
harness_instances() {
|
||||
# rg -c prints nothing when there are no matches, so an unguarded
|
||||
# substitution yields "" rather than "0".
|
||||
local count
|
||||
count="$(qs list 2>/dev/null | rg -c "^ Config path: $harness\$" || true)"
|
||||
printf '%s' "${count:-0}"
|
||||
}
|
||||
for _ in $(seq 1 50); do
|
||||
[[ "$(harness_instances)" == "0" ]] && break
|
||||
sleep 0.1
|
||||
done
|
||||
[[ "$(harness_instances)" == "0" ]] \
|
||||
|| fail 'another instance of the settings harness is still running -- this contract would drive it instead of its own isolated one, and that instance may be writing to the real compositor'
|
||||
|
||||
XDG_CONFIG_HOME="$config_home" XDG_STATE_HOME="$state_home" \
|
||||
PANAMA_SETTINGS_TEST_ISOLATE_COMPOSITOR=1 qs -p "$harness" --daemonize >/dev/null
|
||||
for _ in $(seq 1 40); do
|
||||
|
||||
@@ -77,6 +77,32 @@ target_auto_hdr_int=$([[ "$target_auto_hdr" == true ]] && printf 1 || printf 0)
|
||||
target_vrr=$([[ "$original_vrr" == 3 ]] && printf 0 || printf 3)
|
||||
target_direct=$([[ "$original_direct" == 2 ]] && printf 0 || printf 2)
|
||||
|
||||
# settings-commit-reset-contract drives the SAME harness file with
|
||||
# PANAMA_SETTINGS_TEST_ISOLATE_COMPOSITOR=1, where the compositor write seam is
|
||||
# stubbed out. Quickshell identifies an instance by its config path, so if that
|
||||
# run's instance has not fully exited, the `ipc show` wait below is satisfied by
|
||||
# ITS target -- and every write in this contract lands on the isolated instance
|
||||
# and never reaches the compositor. That is exactly what "a typed batch did not
|
||||
# reach the compositor" looks like when this fails in a full suite run but
|
||||
# passes on its own.
|
||||
#
|
||||
# So wait for the harness to be clear first, and say so plainly if it is not,
|
||||
# rather than silently talking to the wrong shell.
|
||||
harness_instances() {
|
||||
# rg -c prints nothing at all when there are no matches, so an unguarded
|
||||
# substitution yields "" rather than "0" and every comparison against a
|
||||
# count fails.
|
||||
local count
|
||||
count="$(qs list 2>/dev/null | rg -c "^ Config path: $harness\$" || true)"
|
||||
printf '%s' "${count:-0}"
|
||||
}
|
||||
for _ in $(seq 1 50); do
|
||||
[[ "$(harness_instances)" == "0" ]] && break
|
||||
sleep 0.1
|
||||
done
|
||||
[[ "$(harness_instances)" == "0" ]] \
|
||||
|| fail 'another instance of the settings harness is still running -- this contract would talk to it instead of its own, and its writes may be deliberately stubbed'
|
||||
|
||||
XDG_CONFIG_HOME="$config_home" qs -p "$harness" --daemonize >/dev/null
|
||||
for _ in $(seq 1 40); do
|
||||
if qs_for_harness ipc show 2>/dev/null | rg -q '^target settings-system-test$'; then
|
||||
@@ -123,8 +149,23 @@ sleep 0.3
|
||||
qs_for_harness ipc call settings-system-test applyJson \
|
||||
'{"windowRounding": 7, "gapsOut": 23, "blurEnabled": false, "inactiveOpacity": 0.85}' >/dev/null
|
||||
|
||||
# 10 seconds, not 4. The write path verifies each option by reading it back off
|
||||
# the compositor and retries a refused batch, so a busy machine legitimately
|
||||
# takes longer than a quick apply -- and this contract runs in a suite alongside
|
||||
# other tests driving the same compositor. Failing at 4 seconds reported a
|
||||
# product bug ("did not reach the compositor") for what was queueing.
|
||||
# Re-issued periodically, because this contract and the LIVE shell both write to
|
||||
# the same compositor. When the running Panama re-applies its own preferences --
|
||||
# which it does on any store change -- it overwrites the values this test just
|
||||
# set, and the read-back below then sees Panama's shipped defaults with the
|
||||
# writer reporting no error at all. That combination is the signature: a
|
||||
# rejected write leaves an error, a clobbered one does not.
|
||||
typed=false
|
||||
for _ in $(seq 1 40); do
|
||||
for attempt in $(seq 1 100); do
|
||||
if (( attempt % 30 == 0 )); then
|
||||
qs_for_harness ipc call settings-system-test applyJson \
|
||||
'{"windowRounding": 7, "gapsOut": 23, "blurEnabled": false, "inactiveOpacity": 0.85}' >/dev/null
|
||||
fi
|
||||
if [[ "$(read_option decoration:rounding)" == "7" \
|
||||
&& "$(hyprctl -j getoption general:gaps_out | jq -r .css | awk '{print $1}')" == "23" \
|
||||
&& "$(hyprctl -j getoption decoration:blur:enabled | jq -r .bool)" == "false" \
|
||||
@@ -135,10 +176,14 @@ for _ in $(seq 1 40); do
|
||||
sleep 0.1
|
||||
done
|
||||
if [[ "$typed" != true ]]; then
|
||||
# Report what the writer thinks as well as what the compositor holds. Those
|
||||
# two disagreeing is a rejected write; both showing defaults is a write that
|
||||
# never happened, and the messages should not look identical.
|
||||
fail "a typed batch did not reach the compositor: rounding=$(read_option decoration:rounding), \
|
||||
gaps=$(hyprctl -j getoption general:gaps_out | jq -r .css), \
|
||||
blur=$(hyprctl -j getoption decoration:blur:enabled | jq -r .bool), \
|
||||
opacity=$(hyprctl -j getoption decoration:inactive_opacity | jq -r .float)"
|
||||
opacity=$(hyprctl -j getoption decoration:inactive_opacity | jq -r .float), \
|
||||
writer-reported error=\"$(qs_for_harness ipc call settings-system-test status | jq -r .lastError)\""
|
||||
fi
|
||||
|
||||
# Verification must recognise those shapes as success, not report them rejected.
|
||||
|
||||
Executable
+89
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Adding a settings page means editing four separate files, and missing one
|
||||
# fails quietly rather than loudly:
|
||||
#
|
||||
# SettingsSidebar.qml the row you click
|
||||
# SettingsShell.qml the case that maps that row to a component, AND the
|
||||
# Component declaration itself
|
||||
# ShellState.qml the allow-list openSettings() checks -- a page missing
|
||||
# here silently redirects to Home, so a deep link or a
|
||||
# search result lands on the wrong page with no error
|
||||
# modules/settings/qmldir the component registration -- without it the page
|
||||
# is "not a type" and the whole settings window fails
|
||||
# to load, taking every other page with it
|
||||
#
|
||||
# Nothing at runtime cross-checks the four. This does, statically.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
settings_dir="$repo_dir/config/dot/quickshell/modules/settings"
|
||||
sidebar="$settings_dir/SettingsSidebar.qml"
|
||||
shell_file="$settings_dir/SettingsShell.qml"
|
||||
qmldir="$settings_dir/qmldir"
|
||||
shell_state="$repo_dir/config/dot/quickshell/services/ShellState.qml"
|
||||
|
||||
fail() {
|
||||
printf 'settings nav contract: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
for required in "$sidebar" "$shell_file" "$qmldir" "$shell_state"; do
|
||||
[[ -r "$required" ]] || fail "cannot read $required"
|
||||
done
|
||||
|
||||
# ── Every sidebar row resolves everywhere ────────────────────────────────────
|
||||
pages="$(grep -oE '\{ page: "[a-z-]+"' "$sidebar" | sed 's/.*"\(.*\)"/\1/')"
|
||||
[[ -n "$pages" ]] || fail 'no pages found in the sidebar -- this contract is not reading it correctly'
|
||||
|
||||
allowed_line="$(grep -m1 'const allowed = \[' "$shell_state")" \
|
||||
|| fail 'could not find the allow-list in ShellState'
|
||||
|
||||
while read -r page; do
|
||||
[[ -n "$page" ]] || continue
|
||||
|
||||
# Home is the switch's default arm rather than a case, since it is also
|
||||
# where an unknown page falls back to.
|
||||
if [[ "$page" != "home" ]]; then
|
||||
grep -qE "case \"$page\": return [a-zA-Z]+;" "$shell_file" \
|
||||
|| fail "the sidebar offers \"$page\" but SettingsShell has no case for it, so clicking it shows Home"
|
||||
fi
|
||||
|
||||
grep -qF "\"$page\"" <<<"$allowed_line" \
|
||||
|| fail "\"$page\" is missing from ShellState's allow-list, so openSettings(\"$page\") silently redirects to Home"
|
||||
done <<<"$pages"
|
||||
|
||||
# ── Every routed component is declared and registered ────────────────────────
|
||||
# The case arms name a Component id; each must have a declaration, and the type
|
||||
# it instantiates must appear in qmldir.
|
||||
while read -r component; do
|
||||
[[ -n "$component" ]] || continue
|
||||
|
||||
declaration="$(grep -oE "Component \{ id: $component; [A-Za-z]+ \{\} \}" "$shell_file")" \
|
||||
|| fail "SettingsShell routes to \"$component\" but never declares it"
|
||||
|
||||
type_name="$(sed -E 's/.*; ([A-Za-z]+) \{\} \}/\1/' <<<"$declaration")"
|
||||
grep -qE "^$type_name [0-9.]+ $type_name\.qml$" "$qmldir" \
|
||||
|| fail "$type_name is not registered in modules/settings/qmldir -- it will fail to load as \"not a type\", and the whole settings window fails with it"
|
||||
|
||||
[[ -r "$settings_dir/$type_name.qml" ]] \
|
||||
|| fail "$type_name is registered in qmldir but $type_name.qml does not exist"
|
||||
done < <({
|
||||
grep -oE 'case "[a-z-]+": return [a-zA-Z]+;' "$shell_file" | sed -E 's/.*return ([a-zA-Z]+);/\1/'
|
||||
grep -oE 'default: return [a-zA-Z]+;' "$shell_file" | sed -E 's/.*return ([a-zA-Z]+);/\1/'
|
||||
} | sort -u)
|
||||
|
||||
# ── Every page file is reachable ─────────────────────────────────────────────
|
||||
# A page nobody can navigate to is dead code that still has to compile. The
|
||||
# scaffold SettingsPage.qml is the one file here that is a base class rather
|
||||
# than a page.
|
||||
while read -r page_file; do
|
||||
type_name="$(basename "$page_file" .qml)"
|
||||
[[ "$type_name" == "SettingsPage" ]] && continue
|
||||
|
||||
grep -qE "; $type_name \{\} \}" "$shell_file" \
|
||||
|| fail "$type_name.qml exists but nothing in SettingsShell instantiates it"
|
||||
done < <(find "$settings_dir" -maxdepth 1 -name '*Page.qml')
|
||||
|
||||
printf 'settings nav contract: PASS\n'
|
||||
@@ -195,14 +195,14 @@ for page in "${pages[@]}"; do
|
||||
done
|
||||
[[ "$(qs_for_test ipc call settings status | jq -r .page)" == "$page" ]] || fail "$page did not route"
|
||||
/usr/sbin/hyprctl -j clients | jq -e --argjson pid "$shell_pid" \
|
||||
'[.[] | select(.pid == $pid and .title == "Panama Settings" and .floating == false)] | length == 1' >/dev/null \
|
||||
'[.[] | select(.pid == $pid and .title == "Settings" and .floating == false)] | length == 1' >/dev/null \
|
||||
|| fail "$page created a missing, floating, or duplicate Settings window"
|
||||
done
|
||||
|
||||
qs_for_test ipc call settings page '__unsupported__' >/dev/null
|
||||
[[ "$(qs_for_test ipc call settings status | jq -r .page)" == "home" ]] || fail 'unsupported page did not fall back to Home'
|
||||
|
||||
/usr/sbin/hyprctl -j binds | jq -e '.[] | select(.description == "Panama Settings" and .key == "I" and .modmask == 64)' >/dev/null \
|
||||
/usr/sbin/hyprctl -j binds | jq -e '.[] | select(.description == "Settings" and .key == "I" and .modmask == 64)' >/dev/null \
|
||||
|| fail 'Super+I is not registered as Panama Settings'
|
||||
/usr/sbin/hyprctl -j binds | jq -e '.[] | select(.description == "Screen Intelligence" and .key == "S" and .modmask == 65)' >/dev/null \
|
||||
|| fail 'Super+Shift+S is not registered as Screen Intelligence'
|
||||
|
||||
@@ -16,14 +16,14 @@ qs ipc show | rg -q '^target settings$' || fail 'settings IPC target is missing'
|
||||
qs ipc call settings open >/dev/null
|
||||
|
||||
for _ in $(seq 1 40); do
|
||||
if hyprctl -j clients | jq -e '[.[] | select(.title == "Panama Settings")] | length == 1' >/dev/null; then
|
||||
if hyprctl -j clients | jq -e '[.[] | select(.title == "Settings")] | length == 1' >/dev/null; then
|
||||
break
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
hyprctl -j clients | jq -e '[.[] | select(.title == "Panama Settings")] | length == 1' >/dev/null \
|
||||
hyprctl -j clients | jq -e '[.[] | select(.title == "Settings")] | length == 1' >/dev/null \
|
||||
|| fail 'exactly one Settings window did not map'
|
||||
hyprctl -j clients | jq -e '.[] | select(.title == "Panama Settings" and .floating == false)' >/dev/null \
|
||||
hyprctl -j clients | jq -e '.[] | select(.title == "Settings" and .floating == false)' >/dev/null \
|
||||
|| fail 'Settings window is not tiled'
|
||||
|
||||
qs ipc call settings page displays >/dev/null
|
||||
@@ -32,7 +32,7 @@ qs ipc call settings page displays >/dev/null
|
||||
qs ipc call settings page desktop >/dev/null
|
||||
[[ "$(qs ipc call settings status | jq -r .page)" == "desktop" ]] || fail 'Desktop page did not route'
|
||||
|
||||
address="$(hyprctl -j clients | jq -r '.[] | select(.title == "Panama Settings") | .address')"
|
||||
address="$(hyprctl -j clients | jq -r '.[] | select(.title == "Settings") | .address')"
|
||||
hyprctl dispatch "hl.dsp.window.close({ window = \"address:$address\" })" >/dev/null
|
||||
for _ in $(seq 1 40); do
|
||||
[[ "$(qs ipc call settings status | jq -r .open)" == "false" ]] && break
|
||||
@@ -42,13 +42,13 @@ done
|
||||
|
||||
qs ipc call settings open >/dev/null
|
||||
for _ in $(seq 1 40); do
|
||||
hyprctl -j clients | jq -e '.[] | select(.title == "Panama Settings")' >/dev/null && break
|
||||
hyprctl -j clients | jq -e '.[] | select(.title == "Settings")' >/dev/null && break
|
||||
sleep 0.1
|
||||
done
|
||||
|
||||
qs ipc call settings close >/dev/null
|
||||
for _ in $(seq 1 40); do
|
||||
if ! hyprctl -j clients | jq -e '.[] | select(.title == "Panama Settings")' >/dev/null; then
|
||||
if ! hyprctl -j clients | jq -e '.[] | select(.title == "Settings")' >/dev/null; then
|
||||
trap - EXIT
|
||||
printf 'settings window contract: PASS\n'
|
||||
exit 0
|
||||
|
||||
Executable
+126
@@ -0,0 +1,126 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# panama-wifi-qr renders a saved network as a QR code a phone can scan.
|
||||
#
|
||||
# The QR contains the network PASSWORD in machine-readable form, so most of what
|
||||
# is worth testing here is about handling that safely rather than about QR
|
||||
# codes. Both nmcli and qrencode are stubbed: the real ones would read this
|
||||
# machine's actual passphrases, and a test that writes the daily driver's Wi-Fi
|
||||
# password into a fixture directory is not one worth having.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
helper="$repo_dir/config/dot/quickshell/scripts/panama-wifi-qr"
|
||||
|
||||
fail() {
|
||||
printf 'wifi qr contract: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
work="$(mktemp -d /tmp/panama-wifiqr.XXXXXX)"
|
||||
trap 'rm -rf "$work"' EXIT
|
||||
mkdir -p "$work/bin" "$work/run"
|
||||
|
||||
readonly SECRET='hunter2-secret'
|
||||
|
||||
cat >"$work/bin/nmcli" <<STUB
|
||||
#!/usr/bin/env bash
|
||||
# -t -f NAME,TYPE connection show
|
||||
if [[ "\$*" == *"-f NAME,TYPE"* ]]; then
|
||||
printf 'home net:802-11-wireless\n'
|
||||
printf 'work-eap:802-11-wireless\n'
|
||||
printf 'Wired connection 1:802-3-ethernet\n'
|
||||
exit 0
|
||||
fi
|
||||
name="\${@: -1}"
|
||||
case "\$*" in
|
||||
*802-11-wireless.ssid*)
|
||||
# An SSID containing reserved characters, to prove they are escaped.
|
||||
case "\$name" in
|
||||
"home net") printf 'home;net\n' ;;
|
||||
"work-eap") printf 'work-eap\n' ;;
|
||||
esac ;;
|
||||
*802-11-wireless.hidden*) printf 'no\n' ;;
|
||||
*802-11-wireless-security.psk*)
|
||||
# work-eap is enterprise: no passphrase exists to share.
|
||||
[[ "\$name" == "home net" ]] && printf '%s\n' "$SECRET" ;;
|
||||
esac
|
||||
exit 0
|
||||
STUB
|
||||
chmod +x "$work/bin/nmcli"
|
||||
|
||||
# Records its argv and its stdin separately, so the test can prove the secret
|
||||
# arrived on stdin and never on the command line -- argv is world-readable
|
||||
# through /proc while a process runs.
|
||||
cat >"$work/bin/qrencode" <<'STUB'
|
||||
#!/usr/bin/env bash
|
||||
printf '%s\n' "$*" >>"$QRENCODE_ARGV_LOG"
|
||||
out=""
|
||||
prev=""
|
||||
for arg in "$@"; do
|
||||
[[ "$prev" == "-o" ]] && out="$arg"
|
||||
prev="$arg"
|
||||
done
|
||||
cat >"$QRENCODE_STDIN_LOG"
|
||||
printf 'fake-png' >"$out"
|
||||
exit 0
|
||||
STUB
|
||||
chmod +x "$work/bin/qrencode"
|
||||
|
||||
export QRENCODE_ARGV_LOG="$work/argv.log"
|
||||
export QRENCODE_STDIN_LOG="$work/stdin.log"
|
||||
: >"$QRENCODE_ARGV_LOG"
|
||||
: >"$QRENCODE_STDIN_LOG"
|
||||
|
||||
run() { PATH="$work/bin:$PATH" XDG_RUNTIME_DIR="$work/run" "$helper" "$@"; }
|
||||
|
||||
# ── Listing distinguishes shareable from not ────────────────────────────────
|
||||
out="$(run list)"
|
||||
jq -e . >/dev/null 2>&1 <<<"$out" || fail "list did not emit JSON: $out"
|
||||
[[ "$(jq -r '.networks | length' <<<"$out")" == "2" ]] \
|
||||
|| fail "only wireless connections belong in the list: $out"
|
||||
jq -e '.networks[] | select(.name == "home net") | .shareable == true' >/dev/null <<<"$out" \
|
||||
|| fail "a network with a passphrase must be shareable: $out"
|
||||
jq -e '.networks[] | select(.name == "work-eap") | .shareable == false' >/dev/null <<<"$out" \
|
||||
|| fail "an enterprise network has no passphrase, so a QR code for it cannot work: $out"
|
||||
|
||||
# ── The payload ─────────────────────────────────────────────────────────────
|
||||
path="$(run qr 'home net' | jq -r .path)"
|
||||
[[ -n "$path" && -e "$path" ]] || fail 'no image was produced'
|
||||
|
||||
payload="$(cat "$QRENCODE_STDIN_LOG")"
|
||||
grep -q "P:$SECRET;" <<<"$payload" \
|
||||
|| fail 'the passphrase did not reach the payload intact'
|
||||
|
||||
# The SSID is "home;net": unescaped, the semicolon ends the S: field early and
|
||||
# the code describes a different network.
|
||||
grep -qF 'S:home\;net;' <<<"$payload" \
|
||||
|| fail "a reserved character in the SSID was not escaped: $payload"
|
||||
|
||||
[[ "$(wc -l <"$QRENCODE_STDIN_LOG")" == "0" ]] \
|
||||
|| fail "the payload contains a newline; nmcli's trailing newline must be stripped: $(cat -A "$QRENCODE_STDIN_LOG")"
|
||||
|
||||
grep -q ';;$' <<<"$payload" || fail "the WIFI: URI must be terminated with ;;: $payload"
|
||||
|
||||
# ── The secret must never appear in argv ────────────────────────────────────
|
||||
grep -q "$SECRET" "$QRENCODE_ARGV_LOG" \
|
||||
&& fail 'the passphrase was passed as a command-line argument, where /proc exposes it to every process on the machine'
|
||||
|
||||
# ── The image and its directory must not be readable by others ──────────────
|
||||
[[ "$(stat -c '%a' "$path")" == "600" ]] \
|
||||
|| fail "the QR image is mode $(stat -c '%a' "$path"); it contains a password"
|
||||
[[ "$(stat -c '%a' "$(dirname "$path")")" == "700" ]] \
|
||||
|| fail "the directory holding QR images is mode $(stat -c '%a' "$(dirname "$path")")"
|
||||
|
||||
# ── No temporary payload files may survive ──────────────────────────────────
|
||||
leftovers="$(find "$work/run" -name 'payload.*' | wc -l)"
|
||||
[[ "$leftovers" == "0" ]] \
|
||||
|| fail "$leftovers temporary payload file(s) containing the passphrase were left behind"
|
||||
|
||||
# ── An unknown network is an error, not an empty image ──────────────────────
|
||||
out="$(run qr 'no-such-network')"
|
||||
jq -e '.path == "" and .error != ""' >/dev/null <<<"$out" \
|
||||
|| fail "an unknown network must be reported: $out"
|
||||
|
||||
printf 'wifi qr contract: PASS\n'
|
||||
Reference in New Issue
Block a user