Compare commits
5
Commits
5562323eb8
...
27af4fd443
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27af4fd443 | ||
|
|
b16834fea6 | ||
|
|
4279da999a | ||
|
|
54b8a82803 | ||
|
|
2e8292599a |
@@ -20,3 +20,4 @@ __pycache__/
|
||||
# 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
|
||||
|
||||
@@ -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.
|
||||
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 };
|
||||
}
|
||||
}
|
||||
@@ -489,6 +489,42 @@ Singleton {
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -37,20 +37,49 @@ SettingsPage {
|
||||
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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -14,9 +14,43 @@ 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
|
||||
|
||||
+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 ;;
|
||||
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 --
|
||||
@@ -89,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
|
||||
@@ -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 -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
|
||||
|
||||
@@ -70,6 +70,45 @@ 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.
|
||||
|
||||
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