Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27af4fd443 | ||
|
|
b16834fea6 | ||
|
|
4279da999a | ||
|
|
54b8a82803 | ||
|
|
2e8292599a |
@@ -20,3 +20,4 @@ __pycache__/
|
|||||||
# Generated from the colour scheme; machine state, not configuration.
|
# Generated from the colour scheme; machine state, not configuration.
|
||||||
/config/dot/gtk-3.0/settings.ini
|
/config/dot/gtk-3.0/settings.ini
|
||||||
/config/dot/gtk-4.0/settings.ini
|
/config/dot/gtk-4.0/settings.ini
|
||||||
|
/config/dot/tmux/current-theme.conf
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -98,7 +98,26 @@ Singleton {
|
|||||||
// back to shipped defaults and let the next write replace it.
|
// back to shipped defaults and let the next write replace it.
|
||||||
parsed = {};
|
parsed = {};
|
||||||
}
|
}
|
||||||
root.values = (parsed && typeof parsed === "object") ? parsed : {};
|
const raw = (parsed && typeof parsed === "object") ? parsed : {};
|
||||||
|
|
||||||
|
// Upgrade before anything reads a value. A stored key the current
|
||||||
|
// schema no longer recognises is carried through untouched and silently
|
||||||
|
// stops taking effect, so the conversion has to happen here rather than
|
||||||
|
// being noticed later by whoever owns that setting.
|
||||||
|
const result = Migrations.apply(raw);
|
||||||
|
root.values = result.values;
|
||||||
|
|
||||||
|
if (result.migrated)
|
||||||
|
console.info("Settings migrated from version", result.from, "to", result.to + ":",
|
||||||
|
result.applied.join("; "));
|
||||||
|
|
||||||
|
// Write whenever the version moved, which includes stamping a file
|
||||||
|
// written before versioning existed. Left unwritten, the stamp lives
|
||||||
|
// only in memory and is redone on every launch, and a migration that is
|
||||||
|
// not idempotent would compound.
|
||||||
|
if (result.changed)
|
||||||
|
persistTimer.restart();
|
||||||
|
|
||||||
root.revision++;
|
root.revision++;
|
||||||
root.loaded = true;
|
root.loaded = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
pragma Singleton
|
||||||
|
|
||||||
|
// Versioned upgrades for the settings file.
|
||||||
|
//
|
||||||
|
// The schema is the single source of truth for what a setting IS, but it cannot
|
||||||
|
// describe what a setting USED to be. Renaming a key, changing its units, or
|
||||||
|
// splitting one setting into two all leave a stored value that the new schema
|
||||||
|
// does not recognise -- and an unrecognised key is silently carried through
|
||||||
|
// untouched, so the user's choice simply stops taking effect with nothing to
|
||||||
|
// say why. That is the failure this exists to prevent.
|
||||||
|
//
|
||||||
|
// HOW IT WORKS
|
||||||
|
//
|
||||||
|
// settings.json carries a schemaVersion. On load, every migration with a
|
||||||
|
// version ABOVE the stored one runs in order, then the file is stamped with
|
||||||
|
// `current`. A file with no schemaVersion at all is a file written before this
|
||||||
|
// existed; it is stamped at `baseline` and NOT migrated, because those
|
||||||
|
// migrations were never written for it.
|
||||||
|
//
|
||||||
|
// WRITING ONE
|
||||||
|
//
|
||||||
|
// { version: 2, describe: "rename dockDelay to dockHideDelayMs",
|
||||||
|
// migrate: values => { ... return values; } }
|
||||||
|
//
|
||||||
|
// Rules that make this safe to run against a real user's file:
|
||||||
|
//
|
||||||
|
// * migrate() receives the whole values object and returns it. Mutating and
|
||||||
|
// returning the same object is fine.
|
||||||
|
// * NEVER delete a key you are not replacing. Unknown keys are deliberately
|
||||||
|
// preserved so that rolling back to an older Panama does not discard a
|
||||||
|
// newer version's settings, and a migration is the one place that promise
|
||||||
|
// could quietly be broken.
|
||||||
|
// * A migration must tolerate its input being absent or the wrong type. It
|
||||||
|
// runs against files written by every previous version, including ones
|
||||||
|
// that were hand-edited.
|
||||||
|
// * Migrations never run twice: the stored version only moves forward.
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
|
||||||
|
QtObject {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
// What a file written today is stamped with. Bump this when adding a
|
||||||
|
// migration, to the version of the migration you added.
|
||||||
|
readonly property int current: 1
|
||||||
|
|
||||||
|
// Files predating versioning are stamped here without being migrated.
|
||||||
|
readonly property int baseline: 1
|
||||||
|
|
||||||
|
readonly property string versionKey: "schemaVersion"
|
||||||
|
|
||||||
|
// Ordered by version. Empty is the correct state until the first breaking
|
||||||
|
// schema change -- this exists so that change is a routine edit rather than
|
||||||
|
// an emergency.
|
||||||
|
readonly property var steps: []
|
||||||
|
|
||||||
|
function storedVersion(values: var): int {
|
||||||
|
const raw = values ? values[root.versionKey] : undefined;
|
||||||
|
return (typeof raw === "number" && isFinite(raw)) ? Math.floor(raw) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns { values, migrated, changed, from, to, applied }.
|
||||||
|
//
|
||||||
|
// `applied` names each step that ran, so the caller can log something
|
||||||
|
// meaningful rather than "settings changed somehow". `changed` is the one
|
||||||
|
// the caller should write on: stamping a pre-versioning file changes it
|
||||||
|
// without running any step, and left unwritten the stamp would live only in
|
||||||
|
// memory and be redone on every launch.
|
||||||
|
function apply(values: var): var {
|
||||||
|
return root.applyWith(values, root.steps, root.current, root.baseline);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The same logic with the step list injected, so the machinery can be
|
||||||
|
// tested against fixture migrations. The real list is empty until the first
|
||||||
|
// breaking schema change, and a mechanism that has never run against a
|
||||||
|
// failing step is not one to find out about during an upgrade.
|
||||||
|
function applyWith(values: var, steps: var, current: int, baseline: int): var {
|
||||||
|
const safe = (values && typeof values === "object") ? values : {};
|
||||||
|
const from = root.storedVersion(safe);
|
||||||
|
|
||||||
|
// No version: written before versioning existed. Stamp it and stop.
|
||||||
|
// Running the migration list against it would apply upgrades designed
|
||||||
|
// for schemas this file never had.
|
||||||
|
if (from === 0) {
|
||||||
|
safe[root.versionKey] = baseline;
|
||||||
|
return { values: safe, migrated: false, changed: true, from: 0, to: baseline, applied: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// A file from a NEWER Panama. Left completely alone: downgrading its
|
||||||
|
// keys is not something this can do correctly, and unknown keys are
|
||||||
|
// already preserved, so the older build simply ignores what it does not
|
||||||
|
// understand.
|
||||||
|
if (from > current)
|
||||||
|
return { values: safe, migrated: false, changed: false, from: from, to: from, applied: [] };
|
||||||
|
|
||||||
|
const applied = [];
|
||||||
|
let working = safe;
|
||||||
|
for (const step of steps) {
|
||||||
|
if (step.version <= from || step.version > current)
|
||||||
|
continue;
|
||||||
|
try {
|
||||||
|
const result = step.migrate(working);
|
||||||
|
if (result && typeof result === "object")
|
||||||
|
working = result;
|
||||||
|
applied.push(step.version + ": " + step.describe);
|
||||||
|
} catch (error) {
|
||||||
|
// One bad migration must not cost the user every setting. Stop
|
||||||
|
// at the last good version so the next launch retries from
|
||||||
|
// here rather than skipping the failed step forever.
|
||||||
|
console.warn("Migrations: step", step.version, "failed:", error);
|
||||||
|
working[root.versionKey] = step.version - 1;
|
||||||
|
return { values: working, migrated: applied.length > 0, changed: applied.length > 0,
|
||||||
|
from: from, to: step.version - 1, applied: applied };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
working[root.versionKey] = current;
|
||||||
|
return { values: working, migrated: applied.length > 0, changed: from !== current,
|
||||||
|
from: from, to: current, applied: applied };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -489,6 +489,42 @@ Singleton {
|
|||||||
hypr: { path: ["misc", "mouse_move_focuses_monitor"], option: "misc:mouse_move_focuses_monitor", readAs: "bool" }
|
hypr: { path: ["misc", "mouse_move_focuses_monitor"], option: "misc:mouse_move_focuses_monitor", readAs: "bool" }
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── Accessibility ───────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Only what Hyprland can actually deliver. GNOME's sticky keys, slow
|
||||||
|
// keys, bounce keys and mouse keys are AccessX, an X11 server feature:
|
||||||
|
// XKB under Wayland has no accessx option group at all (verified
|
||||||
|
// against evdev.lst), and Hyprland does not implement it. The
|
||||||
|
// compositor will happily STORE "accessx:enable" as a keyboard option
|
||||||
|
// and nothing will ever act on it, which is exactly the kind of switch
|
||||||
|
// this app refuses to ship.
|
||||||
|
{
|
||||||
|
key: "magnifierFactor", type: "real", def: 1.0, min: 1.0, max: 5.0, step: 0.1,
|
||||||
|
group: "accessibility",
|
||||||
|
label: "Magnifier",
|
||||||
|
detail: "Magnifies the screen around the pointer. 1.0 is off",
|
||||||
|
hypr: { path: ["cursor", "zoom_factor"], option: "cursor:zoom_factor", readAs: "float" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "magnifierRigid", type: "bool", def: false, group: "accessibility",
|
||||||
|
label: "Magnifier follows in steps",
|
||||||
|
detail: "Moves the magnified view in increments rather than gliding with the pointer",
|
||||||
|
hypr: { path: ["cursor", "zoom_rigid"], option: "cursor:zoom_rigid", readAs: "bool" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "dimInactive", type: "bool", def: false, group: "accessibility",
|
||||||
|
label: "Dim inactive windows",
|
||||||
|
detail: "Darkens every window except the focused one, so the active window is unmistakable",
|
||||||
|
hypr: { path: ["decoration", "dim_inactive"], option: "decoration:dim_inactive", readAs: "bool" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "dimStrength", type: "real", def: 0.5, min: 0.05, max: 0.9, step: 0.05,
|
||||||
|
group: "accessibility",
|
||||||
|
label: "Dim amount",
|
||||||
|
detail: "How much darker unfocused windows are",
|
||||||
|
hypr: { path: ["decoration", "dim_strength"], option: "decoration:dim_strength", readAs: "float" }
|
||||||
|
},
|
||||||
|
|
||||||
// ── Night light ─────────────────────────────────────────────────────
|
// ── Night light ─────────────────────────────────────────────────────
|
||||||
{
|
{
|
||||||
key: "nightLightEnabled", type: "bool", def: false, group: "nightLight",
|
key: "nightLightEnabled", type: "bool", def: false, group: "nightLight",
|
||||||
|
|||||||
@@ -4,3 +4,4 @@ singleton PreferenceSchema 1.0 PreferenceSchema.qml
|
|||||||
singleton HomePreferences 1.0 HomePreferences.qml
|
singleton HomePreferences 1.0 HomePreferences.qml
|
||||||
singleton Settings 1.0 Settings.qml
|
singleton Settings 1.0 Settings.qml
|
||||||
singleton Theme 1.0 Theme.qml
|
singleton Theme 1.0 Theme.qml
|
||||||
|
singleton Migrations 1.0 Migrations.qml
|
||||||
|
|||||||
@@ -0,0 +1,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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,20 +37,49 @@ SettingsPage {
|
|||||||
ToggleRow { setting: "animationsEnabled"; divider: false }
|
ToggleRow { setting: "animationsEnabled"; divider: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Zoom, done by the compositor rather than handed to GNOME. Hyprland has a
|
||||||
|
// real magnifier (cursor:zoom_factor) that follows the pointer, so there is
|
||||||
|
// no reason to send someone to another application for it.
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Contrast"
|
title: "Magnifier"
|
||||||
subtitle: "Unfocused windows can be faded to make the focused one obvious, or left at full strength if that is harder to read."
|
subtitle: "Magnifies the screen around the pointer. Set the magnification to 1× to turn it off."
|
||||||
|
|
||||||
SliderRow { setting: "inactiveOpacity"; divider: false }
|
SliderRow { setting: "magnifierFactor"; zeroLabel: "Off" }
|
||||||
|
ToggleRow { setting: "magnifierRigid"; divider: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "System accessibility"
|
title: "Contrast"
|
||||||
subtitle: "Screen reader, zoom, and on-screen keyboard are provided by GNOME's accessibility stack."
|
subtitle: "Unfocused windows can be faded or darkened to make the focused one obvious, or left alone if that is harder to read."
|
||||||
|
|
||||||
|
SliderRow { setting: "inactiveOpacity" }
|
||||||
|
ToggleRow { setting: "dimInactive" }
|
||||||
|
SliderRow { setting: "dimStrength"; divider: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
// What this session genuinely cannot do, said plainly.
|
||||||
|
//
|
||||||
|
// Sticky keys, slow keys, bounce keys and mouse keys are AccessX, which is
|
||||||
|
// an X11 SERVER feature. XKB under Wayland has no accessx option group at
|
||||||
|
// all, and Hyprland does not implement one. The compositor will accept
|
||||||
|
// "accessx:enable" as a keyboard option and store it, and nothing will ever
|
||||||
|
// act on it -- so there is no switch here, and pointing at GNOME's panel
|
||||||
|
// would be no better, since the daemon that applies those keys is not
|
||||||
|
// running either.
|
||||||
|
SettingsCard {
|
||||||
|
title: "Keyboard accessibility"
|
||||||
|
subtitle: "Sticky, slow and bounce keys are an X11 feature with no Wayland equivalent, so they are unavailable in this session. Offering them here would store a preference that nothing acts on."
|
||||||
|
|
||||||
|
ActionRow {
|
||||||
|
label: "Screen reader"
|
||||||
|
detail: "Orca reads the screen aloud and works over the accessibility bus, which does run here"
|
||||||
|
action: "Start Orca"
|
||||||
|
onTriggered: SystemSettings.openApplication("orca")
|
||||||
|
}
|
||||||
|
|
||||||
ActionRow {
|
ActionRow {
|
||||||
label: "GNOME accessibility settings"
|
label: "GNOME accessibility settings"
|
||||||
detail: "Opens in GNOME Settings"
|
detail: "For the parts GNOME's own stack still owns"
|
||||||
action: "Open"
|
action: "Open"
|
||||||
divider: false
|
divider: false
|
||||||
onTriggered: SystemSettings.openGnomePanel("universal-access")
|
onTriggered: SystemSettings.openGnomePanel("universal-access")
|
||||||
|
|||||||
@@ -19,13 +19,18 @@ import qs.services
|
|||||||
SettingsPage {
|
SettingsPage {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
|
|
||||||
title: "Network & Devices"
|
title: "Network & Devices"
|
||||||
lede: Connectivity.activeNetwork
|
lede: Connectivity.activeNetwork
|
||||||
? "Connected to " + Connectivity.activeNetwork.name
|
? "Connected to " + Connectivity.activeNetwork.name
|
||||||
: "Wi-Fi, Bluetooth, and the things Fedora owns."
|
: "Wi-Fi, Bluetooth, and the things Fedora owns."
|
||||||
|
|
||||||
// Drive the scanners only while this page is the one being shown.
|
// Drive the scanners only while this page is the one being shown.
|
||||||
Component.onCompleted: Connectivity.active = true
|
Component.onCompleted: {
|
||||||
|
Connectivity.active = true;
|
||||||
|
if (!WifiShare.scanned)
|
||||||
|
WifiShare.refresh();
|
||||||
|
}
|
||||||
Component.onDestruction: Connectivity.active = false
|
Component.onDestruction: Connectivity.active = false
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
@@ -68,6 +73,69 @@ SettingsPage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sharing a network by QR, the way GNOME's Wi-Fi panel does. The
|
||||||
|
// alternative is reading a passphrase out loud.
|
||||||
|
//
|
||||||
|
// The image holds the password in machine-readable form, so it is generated
|
||||||
|
// on demand rather than up front, and the helper writes it to tmpfs under
|
||||||
|
// XDG_RUNTIME_DIR instead of anywhere persistent.
|
||||||
|
SettingsCard {
|
||||||
|
visible: Connectivity.wifiDevice !== null && WifiShare.shareable.length > 0
|
||||||
|
title: "Share a network"
|
||||||
|
subtitle: WifiShare.sharing !== ""
|
||||||
|
? "Point a phone's camera at the code to join " + WifiShare.sharing + "."
|
||||||
|
: "Shows a QR code a phone can scan to join, without reading the password out."
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: WifiShare.shareable
|
||||||
|
|
||||||
|
ActionRow {
|
||||||
|
id: shareRow
|
||||||
|
required property var modelData
|
||||||
|
required property int index
|
||||||
|
|
||||||
|
label: shareRow.modelData.ssid
|
||||||
|
detail: WifiShare.sharing === shareRow.modelData.name
|
||||||
|
? "Showing a code below — anyone who can see the screen can join"
|
||||||
|
: "Saved network"
|
||||||
|
action: WifiShare.sharing === shareRow.modelData.name ? "Hide" : "Show code"
|
||||||
|
divider: shareRow.index < WifiShare.shareable.length - 1 || WifiShare.sharing !== ""
|
||||||
|
onTriggered: WifiShare.sharing === shareRow.modelData.name
|
||||||
|
? WifiShare.stopSharing()
|
||||||
|
: WifiShare.share(shareRow.modelData.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drawn at its natural size on a white plate: a QR code inverted or
|
||||||
|
// tinted to match a dark theme is unreliable to scan, and this one has
|
||||||
|
// exactly one job.
|
||||||
|
Item {
|
||||||
|
width: parent.width
|
||||||
|
visible: WifiShare.sharing !== "" && WifiShare.imagePath !== ""
|
||||||
|
implicitHeight: visible ? plate.height + 20 : 0
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: plate
|
||||||
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
|
y: 10
|
||||||
|
width: 208
|
||||||
|
height: 208
|
||||||
|
radius: 10
|
||||||
|
color: "white"
|
||||||
|
|
||||||
|
Image {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
width: 184
|
||||||
|
height: 184
|
||||||
|
smooth: false
|
||||||
|
fillMode: Image.PreserveAspectFit
|
||||||
|
cache: false
|
||||||
|
source: WifiShare.imagePath !== "" ? "file://" + WifiShare.imagePath : ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Bluetooth"
|
title: "Bluetooth"
|
||||||
visible: Connectivity.adapter !== null
|
visible: Connectivity.adapter !== null
|
||||||
|
|||||||
@@ -14,9 +14,43 @@ import qs.services
|
|||||||
SettingsPage {
|
SettingsPage {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
|
// Probing the power daemon is a D-Bus round trip, so it happens when the
|
||||||
|
// page opens rather than at shell startup.
|
||||||
|
Component.onCompleted: if (!PowerProfiles.scanned) PowerProfiles.refresh()
|
||||||
|
|
||||||
title: "Power & Lock"
|
title: "Power & Lock"
|
||||||
lede: "When the screen turns off, when the session locks, and whether it ever sleeps."
|
lede: "When the screen turns off, when the session locks, and whether it ever sleeps."
|
||||||
|
|
||||||
|
// The same profiles GNOME's Power panel offers. Not a stored preference --
|
||||||
|
// the daemon owns it, it survives Panama restarts, and anything else on the
|
||||||
|
// system can change it, so a copy here would drift.
|
||||||
|
SettingsCard {
|
||||||
|
visible: PowerProfiles.available || PowerProfiles.lastError !== ""
|
||||||
|
title: "Power profile"
|
||||||
|
subtitle: PowerProfiles.degraded !== ""
|
||||||
|
? "Performance is limited right now: " + PowerProfiles.degraded
|
||||||
|
: (PowerProfiles.available
|
||||||
|
? "Applies to the whole system and persists across sessions."
|
||||||
|
: PowerProfiles.lastError)
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: PowerProfiles.profiles
|
||||||
|
|
||||||
|
SettingRow {
|
||||||
|
id: profileRow
|
||||||
|
required property var modelData
|
||||||
|
required property int index
|
||||||
|
|
||||||
|
label: PowerProfiles.label(profileRow.modelData)
|
||||||
|
detail: PowerProfiles.detail(profileRow.modelData)
|
||||||
|
value: profileRow.modelData === PowerProfiles.active ? "Active" : ""
|
||||||
|
divider: profileRow.index < PowerProfiles.profiles.length - 1
|
||||||
|
activatable: profileRow.modelData !== PowerProfiles.active && !PowerProfiles.busy
|
||||||
|
onActivated: PowerProfiles.set(profileRow.modelData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Idle behaviour"
|
title: "Idle behaviour"
|
||||||
subtitle: IdleLock.managed
|
subtitle: IdleLock.managed
|
||||||
|
|||||||
+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
|
||||||
@@ -31,6 +31,54 @@ case "$scheme" in
|
|||||||
*) printf 'usage: panama-theme-apps [dark|light]\n' >&2; exit 2 ;;
|
*) printf 'usage: panama-theme-apps [dark|light]\n' >&2; exit 2 ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
|
# ── tmux ─────────────────────────────────────────────────────────────────────
|
||||||
|
# Generated like kitty's: tmux.conf sources current-theme.conf, and that file is
|
||||||
|
# machine state rather than configuration. Running servers are re-sourced so an
|
||||||
|
# open session changes now instead of at next launch -- tmux applies a
|
||||||
|
# source-file to every attached client immediately.
|
||||||
|
tmux_dir="${XDG_CONFIG_HOME:-$HOME/.config}/tmux"
|
||||||
|
tmux_theme="$tmux_dir/themes/tokyonight-moon.conf"
|
||||||
|
[[ "$scheme" == "light" ]] && tmux_theme="$tmux_dir/themes/tokyonight-day.conf"
|
||||||
|
|
||||||
|
status_tmux="skipped"
|
||||||
|
if [[ -r "$tmux_theme" ]]; then
|
||||||
|
if cp "$tmux_theme" "$tmux_dir/current-theme.conf.tmp" 2>/dev/null \
|
||||||
|
&& mv "$tmux_dir/current-theme.conf.tmp" "$tmux_dir/current-theme.conf" 2>/dev/null; then
|
||||||
|
status_tmux="written"
|
||||||
|
# Only if a server is actually running; `tmux source-file` would
|
||||||
|
# otherwise start one just to theme it.
|
||||||
|
if command -v tmux >/dev/null 2>&1 && tmux has-session 2>/dev/null; then
|
||||||
|
tmux source-file "$tmux_dir/current-theme.conf" 2>/dev/null \
|
||||||
|
&& status_tmux="applied to running sessions"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
rm -f "$tmux_dir/current-theme.conf.tmp"
|
||||||
|
status_tmux="failed"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── btop ─────────────────────────────────────────────────────────────────────
|
||||||
|
# Only the color_theme line is rewritten, in place. btop OWNS btop.conf -- it
|
||||||
|
# rewrites the whole file on exit -- so the config is not symlinked into Panama
|
||||||
|
# and not replaced wholesale here; just this one value is edited, and btop keeps
|
||||||
|
# it on the next write.
|
||||||
|
#
|
||||||
|
# btop reads its theme once at startup, so a running instance keeps the old
|
||||||
|
# colours until it is restarted. That is acceptable for a monitor you open when
|
||||||
|
# you want it, and forcing a restart would kill a process the user is watching.
|
||||||
|
btop_conf="${XDG_CONFIG_HOME:-$HOME/.config}/btop/btop.conf"
|
||||||
|
btop_theme="tokyonight-moon"
|
||||||
|
[[ "$scheme" == "light" ]] && btop_theme="tokyonight-day"
|
||||||
|
|
||||||
|
status_btop="skipped"
|
||||||
|
if [[ -w "$btop_conf" ]]; then
|
||||||
|
if sed -i "s|^color_theme *=.*|color_theme = \"$btop_theme\"|" "$btop_conf" 2>/dev/null; then
|
||||||
|
status_btop="written"
|
||||||
|
else
|
||||||
|
status_btop="failed"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
# ── GTK ──────────────────────────────────────────────────────────────────────
|
# ── GTK ──────────────────────────────────────────────────────────────────────
|
||||||
# adw-gtk3, not Adwaita: no Adwaita GTK theme is installed on Fedora 44, and
|
# 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 --
|
# naming a theme that does not exist makes GTK fall back to its light default --
|
||||||
@@ -89,4 +137,13 @@ if [[ -r "$theme_file" ]]; then
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
printf '{"scheme":"%s","kitty":"%s"}\n' "$scheme" "$status_kitty"
|
# Every target reports what actually happened. A helper that says only
|
||||||
|
# "kitty: applied" while silently skipping three other applications is how a
|
||||||
|
# half-applied theme goes unnoticed.
|
||||||
|
jq -cn \
|
||||||
|
--arg scheme "$scheme" \
|
||||||
|
--arg kitty "$status_kitty" \
|
||||||
|
--arg gtk "$status_gtk" \
|
||||||
|
--arg btop "$status_btop" \
|
||||||
|
--arg tmux "$status_tmux" \
|
||||||
|
'{scheme: $scheme, kitty: $kitty, gtk: $gtk, btop: $btop, tmux: $tmux}'
|
||||||
|
|||||||
Executable
+121
@@ -0,0 +1,121 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# A QR code for a saved Wi-Fi network, so a guest can join by pointing a camera.
|
||||||
|
#
|
||||||
|
# GNOME's Wi-Fi panel has this and it is the single most-used thing in it.
|
||||||
|
# The payload is the de-facto WIFI: URI that Android and iOS both scan:
|
||||||
|
#
|
||||||
|
# WIFI:T:WPA;S:<ssid>;P:<passphrase>;H:<hidden>;;
|
||||||
|
#
|
||||||
|
# HANDLING THE PASSPHRASE
|
||||||
|
#
|
||||||
|
# This image contains the network password in machine-readable form. Anyone who
|
||||||
|
# can read the file can read the password, so:
|
||||||
|
#
|
||||||
|
# * it is written under XDG_RUNTIME_DIR, which is 0700 and on tmpfs, so it
|
||||||
|
# never reaches disk and disappears at logout -- not /tmp, which is shared;
|
||||||
|
# * it is created with umask 077;
|
||||||
|
# * the passphrase is never printed, never passed as an argument (argv is
|
||||||
|
# world-readable via /proc), and never appears in an error message.
|
||||||
|
#
|
||||||
|
# It is piped to qrencode on stdin for that last reason.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# panama-wifi-qr list -> {"networks":[{"name","ssid","shareable"}]}
|
||||||
|
# panama-wifi-qr qr <name> -> {"path":"/run/user/…/….png"}
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
emit_error() {
|
||||||
|
printf '{"networks":[],"path":"","error":%s}\n' "$(jq -Rn --arg e "$1" '$e')"
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
command -v nmcli >/dev/null 2>&1 || emit_error 'NetworkManager is not available'
|
||||||
|
command -v qrencode >/dev/null 2>&1 || emit_error 'qrencode is not installed, so a Wi-Fi QR code cannot be drawn'
|
||||||
|
|
||||||
|
cmd_list() {
|
||||||
|
local rows=() name ssid psk
|
||||||
|
while IFS= read -r name; do
|
||||||
|
[[ -n "$name" ]] || continue
|
||||||
|
ssid="$(nmcli -g 802-11-wireless.ssid connection show "$name" 2>/dev/null)"
|
||||||
|
[[ -n "$ssid" ]] || ssid="$name"
|
||||||
|
|
||||||
|
# Only networks whose passphrase this user can actually read are
|
||||||
|
# shareable. An enterprise network has no passphrase to share at all,
|
||||||
|
# and a QR code for one would simply not work.
|
||||||
|
psk="$(nmcli -s -g 802-11-wireless-security.psk connection show "$name" 2>/dev/null)"
|
||||||
|
|
||||||
|
rows+=("$(jq -cn --arg name "$name" --arg ssid "$ssid" \
|
||||||
|
--argjson shareable "$([[ -n "$psk" ]] && echo true || echo false)" \
|
||||||
|
'{name: $name, ssid: $ssid, shareable: $shareable}')")
|
||||||
|
done < <(nmcli -t -f NAME,TYPE connection show 2>/dev/null \
|
||||||
|
| awk -F: '$2 == "802-11-wireless" { print $1 }')
|
||||||
|
|
||||||
|
if [[ ${#rows[@]} -eq 0 ]]; then
|
||||||
|
printf '{"networks":[],"path":"","error":"No saved Wi-Fi networks."}\n'
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
printf '{"networks":[%s],"path":"","error":""}\n' "$(IFS=,; printf '%s' "${rows[*]}")"
|
||||||
|
}
|
||||||
|
|
||||||
|
# The WIFI: URI reserves \ ; , : and ", each escaped with a backslash. An SSID
|
||||||
|
# containing a semicolon would otherwise terminate the field early and produce a
|
||||||
|
# QR code for a different network entirely.
|
||||||
|
#
|
||||||
|
# Trailing newlines are stripped as well. nmcli terminates every value with one,
|
||||||
|
# and left in place it lands INSIDE the payload -- the code still decodes here,
|
||||||
|
# but a newline in the middle of a WIFI: URI is not something every phone's
|
||||||
|
# scanner tolerates, and the failure would look like "the QR code just does not
|
||||||
|
# work on my phone".
|
||||||
|
escape_field() {
|
||||||
|
sed -e 's/\\/\\\\/g' -e 's/;/\\;/g' -e 's/,/\\,/g' -e 's/:/\\:/g' -e 's/"/\\"/g' \
|
||||||
|
| tr -d '\n'
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_qr() {
|
||||||
|
local name="${1:-}"
|
||||||
|
[[ -n "$name" ]] || emit_error 'no network named'
|
||||||
|
|
||||||
|
local ssid hidden psk_file payload_file out_dir out_file
|
||||||
|
ssid="$(nmcli -g 802-11-wireless.ssid connection show "$name" 2>/dev/null)"
|
||||||
|
[[ -n "$ssid" ]] || emit_error "There is no saved network called \"$name\"."
|
||||||
|
|
||||||
|
hidden="$(nmcli -g 802-11-wireless.hidden connection show "$name" 2>/dev/null)"
|
||||||
|
[[ "$hidden" == "yes" ]] && hidden=true || hidden=false
|
||||||
|
|
||||||
|
out_dir="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/panama"
|
||||||
|
umask 077
|
||||||
|
mkdir -p "$out_dir" 2>/dev/null || emit_error 'could not create the runtime directory'
|
||||||
|
chmod 700 "$out_dir" 2>/dev/null || true
|
||||||
|
|
||||||
|
# Named after the connection, hashed, so repeated shares reuse one file
|
||||||
|
# instead of accumulating images of the password.
|
||||||
|
out_file="$out_dir/wifi-$(printf '%s' "$name" | sha256sum | cut -c1-16).png"
|
||||||
|
|
||||||
|
# Built in a file rather than a variable that could be echoed, and piped to
|
||||||
|
# qrencode on stdin so the passphrase never appears in argv.
|
||||||
|
payload_file="$(mktemp "$out_dir/payload.XXXXXX")" || emit_error 'could not create a temporary file'
|
||||||
|
trap 'rm -f "$payload_file"' RETURN
|
||||||
|
|
||||||
|
{
|
||||||
|
printf 'WIFI:T:WPA;S:'
|
||||||
|
printf '%s' "$ssid" | escape_field
|
||||||
|
printf ';P:'
|
||||||
|
nmcli -s -g 802-11-wireless-security.psk connection show "$name" 2>/dev/null | escape_field
|
||||||
|
printf ';H:%s;;' "$hidden"
|
||||||
|
} >"$payload_file"
|
||||||
|
|
||||||
|
if ! qrencode -o "$out_file" -s 8 -m 2 -l M <"$payload_file" 2>/dev/null; then
|
||||||
|
emit_error "Could not generate a QR code for \"$name\"."
|
||||||
|
fi
|
||||||
|
chmod 600 "$out_file" 2>/dev/null || true
|
||||||
|
|
||||||
|
jq -cn --arg path "$out_file" '{networks: [], path: $path, error: ""}'
|
||||||
|
}
|
||||||
|
|
||||||
|
case "${1:-list}" in
|
||||||
|
list) cmd_list ;;
|
||||||
|
qr) shift; cmd_qr "${1:-}" ;;
|
||||||
|
*) printf 'usage: panama-wifi-qr [list|qr <name>]\n' >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 status bar
|
||||||
#set -g status-bg pink
|
#set -g status-bg pink
|
||||||
# Tokyo Night Moon color palette
|
# Tokyo Night Moon color palette
|
||||||
set -g mode-style "fg=#82aaff,bg=#3b4261"
|
# Colours live in themes/ and are generated into current-theme.conf by
|
||||||
|
# panama-theme-apps, which regenerates it whenever the desktop colour scheme
|
||||||
set -g message-style "fg=#82aaff,bg=#3b4261"
|
# changes. -q so a fresh checkout without the generated file still starts.
|
||||||
set -g message-command-style "fg=#82aaff,bg=#3b4261"
|
source-file -q "~/.config/tmux/current-theme.conf"
|
||||||
|
|
||||||
set -g pane-border-style "fg=#82aaff"
|
|
||||||
set -g pane-active-border-style "fg=#b172b0"
|
|
||||||
|
|
||||||
set -g status-style "fg=#b172b0,bg=#3b4261"
|
|
||||||
set -g status-bg "#222436"
|
|
||||||
|
|
||||||
set -g status-left "#[fg=#1b1d2b,bg=#b172b0,bold] #S #[fg=#1b1d2b,bg=#b172b0,nobold,nounderscore,noitalics]"
|
|
||||||
set -g status-right "#[fg=#1b1d2b,bg=#82aaff,nobold,nounderscore,noitalics]#[fg=#b172b0,bg=#3b4261] #{prefix_highlight} #[fg=#3b4261,bg=#3b4261]#[fg=#b172b0,bg=#3b4261] %Y/%m/%d %I:%M %p #[fg=#82aaff,bg=#3b4261,nobold,nounderscore,noitalics]#[fg=#1b1d2b,bg=#b172b0,bold,italics] #h "
|
|
||||||
|
|
||||||
setw -g window-status-format "#[fg=#1b1d2b,bg=#3b4261,nobold,nounderscore,noitalics]#[fg=#82aaff,bg=#3b4261] #I #W #F #[fg=#1b1d2b,bg=#3b4261,nobold,nounderscore,noitalics]"
|
|
||||||
setw -g window-status-current-format "#[fg=#1b1d2b,bg=#3b4261,nobold,nounderscore,noitalics]#[fg=#b172b0,bg=#3b4261,bold] #I #W #F #[fg=#3b4261,bg=#3b4261,nobold,nounderscore,noitalics]"
|
|
||||||
|
|
||||||
setw -g window-status-separator ""
|
|
||||||
|
|
||||||
# Increase scrollback buffer
|
# Increase scrollback buffer
|
||||||
set -g history-limit 50000
|
set -g history-limit 50000
|
||||||
|
|||||||
@@ -70,6 +70,45 @@ for dir in "${dirs[@]}"; do
|
|||||||
log "Linked $PANAMA_DOT/$dir → $CONFIG/$dir"
|
log "Linked $PANAMA_DOT/$dir → $CONFIG/$dir"
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# tmux.conf ends with a source-file of current-theme.conf, generated from the
|
||||||
|
# colour scheme rather than committed. Seed it so a fresh checkout starts themed
|
||||||
|
# -- the source-file is -q, so a missing file is silent, which would leave tmux
|
||||||
|
# unstyled with nothing to explain it.
|
||||||
|
TMUX_THEME="$PANAMA_DOT/tmux/current-theme.conf"
|
||||||
|
if [ -e "$TMUX_THEME" ]; then
|
||||||
|
log "Keeping existing tmux theme at $TMUX_THEME"
|
||||||
|
elif [ -d "$PANAMA_DOT/tmux/themes" ]; then
|
||||||
|
tmux_scheme="dark"
|
||||||
|
tmux_prefs="${XDG_CONFIG_HOME:-$HOME/.config}/panama/settings.json"
|
||||||
|
if [ -r "$tmux_prefs" ]; then
|
||||||
|
tmux_stored="$(jq -r '.colorScheme // "dark"' "$tmux_prefs" 2>/dev/null || echo dark)"
|
||||||
|
[ "$tmux_stored" = "light" ] && tmux_scheme="light"
|
||||||
|
fi
|
||||||
|
[ "$tmux_scheme" = "light" ] && tmux_name="tokyonight-day" || tmux_name="tokyonight-moon"
|
||||||
|
cp "$PANAMA_DOT/tmux/themes/$tmux_name.conf" "$TMUX_THEME"
|
||||||
|
log "Seeded tmux $tmux_scheme theme ($tmux_name) → $TMUX_THEME"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# btop reads themes from its own config directory, but OWNS btop.conf -- it
|
||||||
|
# rewrites that file on exit -- so only the theme files are exposed, per file,
|
||||||
|
# and the config itself is left to btop. panama-theme-apps edits the single
|
||||||
|
# color_theme line in place.
|
||||||
|
BTOP_THEME_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/btop/themes"
|
||||||
|
mkdir -p "$BTOP_THEME_DIR"
|
||||||
|
for btop_theme_src in "$PANAMA_DOT"/btop/themes/*.theme; do
|
||||||
|
[ -e "$btop_theme_src" ] || continue
|
||||||
|
btop_theme_dst="$BTOP_THEME_DIR/$(basename "$btop_theme_src")"
|
||||||
|
if [ -L "$btop_theme_dst" ]; then
|
||||||
|
rm "$btop_theme_dst"
|
||||||
|
fi
|
||||||
|
if [ -e "$btop_theme_dst" ]; then
|
||||||
|
log "Keeping existing btop theme at $btop_theme_dst"
|
||||||
|
else
|
||||||
|
ln -s "$btop_theme_src" "$btop_theme_dst"
|
||||||
|
log "Linked btop theme → $btop_theme_dst"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
# GTK3 has no include mechanism, so its settings.ini is generated whole from a
|
# 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
|
# 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.
|
# and no settings.ini, and GTK3 applications fall back to their built-in theme.
|
||||||
|
|||||||
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'
|
||||||
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'
|
||||||
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