7 Commits
Author SHA1 Message Date
Gabriel Brown 8cf03d4529 Remove dead shell components and fix stale docs
NotificationCenter.qml, CalendarPopup.qml, and RecordingIndicator.qml
were never instantiated anywhere -- shell.qml builds NotificationList,
DateMenu, and CaptureOverlay in their place. Verified with a repo-wide
grep before deleting; updated the two stale comments in Notifs.qml
that still pointed at NotificationCenter.

DESKTOP-PARITY.md still described Wi-Fi QR sharing as deliberately
omitted, though it was since built. The System Health colour-profile
handoff pointed at GNOME's colour panel as if it worked, but the
daemon that actually loads an ICC profile doesn't run in this session,
so it silently does nothing -- reworded the card to say so. Displays
carried two verify-timer attempt counters that were incremented but
never read anywhere.

Claude-Session: https://claude.ai/code/session_01E6TJUAh41HaP25MVHWkhRZ
2026-08-18 21:23:32 -04:00
Gabriel Brown 2a716dac9e Fix correctness bugs across the helper scripts
panama-osd read the wrong brightnessctl field, showing the hardware
max instead of a percentage on any backlight device. panama-doctor
called three sibling scripts by bare name with nothing on PATH,
making three health checks permanently and falsely report broken; its
repair actions also reused the short probe timeout, so a slow-but-
successful restart was reported as failed. panama-wifi-qr left the
cleartext passphrase temp file behind on its failure path (the RETURN
trap doesn't fire on exit), and its nmcli parsing broke on connection
names containing a colon or backslash -- verified against a real
NetworkManager profile.

panama-power-profile's set command always returned success regardless
of whether the write actually took. panama-keyring's daemon-origin
check picked whichever gnome-keyring-daemon process happened to
enumerate first in /proc, defeating the exact dual-daemon scenario it
exists to detect; it now resolves the PID that actually owns the
Secret Service D-Bus name. gnf aborted before running a firmware
update whenever the metadata was already current (a non-error exit
under set -e), and its flatpak update lacked the -y its own docs
promise.

Claude-Session: https://claude.ai/code/session_01E6TJUAh41HaP25MVHWkhRZ
2026-08-18 21:23:28 -04:00
Gabriel Brown 719ef2f38e Fix a keybind collision and wire two orphaned prefs
SUPER+SHIFT+P was bound to both the colour picker and a window-resize
action; moved the resize bind to SUPER+SHIFT+Comma, next to its
existing N alias, and updated the README's keymap table to match.

workspaceBackAndForth and allowWorkspaceCycles were declared in the
preference schema but read nowhere, so they had no effect regardless
of what a user set them to; wired both into keybinds.lua's workspace
config, which is where they actually apply.

Claude-Session: https://claude.ai/code/session_01E6TJUAh41HaP25MVHWkhRZ
2026-08-18 21:23:19 -04:00
Gabriel Brown 9ba224d776 Finish threading the accent colour through the whole desktop
The recent accent-colour setting only reached part of the desktop.
looks.lua still hardcoded the focused-window border and glow to blue,
so any hyprctl reload -- or every fresh session, for about a second --
reverted a chosen accent; it now reads accentName the same way it
already read colorScheme. The lock screen and terminal stayed blue
regardless of the chosen accent despite the setting's own description
claiming otherwise; panama-lock and panama-theme-apps now resolve and
apply the real accent.

AccentPicker built its swatch model from Theme.accents directly
instead of the schema's own options list, so the two could drift
silently; switched it to read the schema. Its hit target only covered
the swatch, not the name label added specifically for colour-vision
accessibility -- extended to the whole row. Settings search had no
route for the "appearance" group, so searching for the accent or
colour scheme landed on Home.

ColorScheme's hex-to-Hyprland helper assumed 6-digit colours and would
silently corrupt a future translucent one; fixed it to read from the
end of the string instead of the start. An accent-only change no
longer reruns the full colour-scheme pipeline. The gradient it builds
for the focused border now goes through SystemSettings' existing
serialiser instead of a second, under-escaped copy of the same logic.

The settings-ownership contract test enforced the old rule that
ColorScheme must never touch the focused border; updated it to verify
the real, intended rule instead of contradicting the code.

Claude-Session: https://claude.ai/code/session_01E6TJUAh41HaP25MVHWkhRZ
2026-08-18 21:23:16 -04:00
Gabriel Brown 8b59b78d9f Settle process-signal races across the services layer
A Process's exited and streamFinished signals aren't guaranteed to
fire in order, and several services decided an outcome on whichever
fired first: KdeConnect could report a successful file transfer as
failed if exited landed before the real stdout payload; Clipboard
could present a failed history query as an empty-but-healthy one;
Brightness could strand the last queued write of a drag; SoundFeedback
and SystemLocale could drop or misapply a rapid second toggle/click
because re-arming an already-running Process is a no-op. All five now
wait for both signals and let the authoritative one decide, matching
the pattern HomeAssistantConfig.qml already used correctly.

Health's "copy report" never enabled stdin, so it copied nothing
while claiming success. Capture announced every recording as saved
regardless of the recorder's actual exit code. Connectivity never
restarted Bluetooth discovery when the adapter was enabled from an
already-open page. CalendarAgenda left the UI in "loading" forever if
its helper died at startup, and the helper itself could crash
unguarded instead of reporting unavailable. Geocoding silently
dropped a query typed while the previous one was still in flight.
Notifs leaked tracked-but-undisplayed notifications under Do Not
Disturb, and dismissAll() skipped them.

Claude-Session: https://claude.ai/code/session_01E6TJUAh41HaP25MVHWkhRZ
2026-08-18 21:23:07 -04:00
Gabriel Brown e6b4d3c1a1 Stop lists and sliders from losing input under the user
WifiList and the notification toasts built their model from a plain
computed array, so any background property tick (a scan result, an
unrelated notification arriving) reassigned the whole array and the
Repeater destroyed and recreated every delegate -- including one with
an open, focused password field or an in-progress reply. Switched
both to a ScriptModel, which diffs by identity instead of resetting.

ValueSlider had its pointer-to-value mapping offset by 16px (the
hit-area margin was applied with the wrong sign), so 0% was
unreachable and every click landed to the right of where it was
placed -- affects every slider in the shell. SliderRow used -1 as a
sentinel for "nothing pending," which collides with legitimate
negative preference values like pointer sensitivity.

Dock intellihide read the globally focused workspace instead of each
monitor's own, so an empty workspace on one screen could hide the
dock on another; ActivityPanel rebuilt every row once a second during
a recording because the elapsed-time read lived in the model
construction instead of each row's own binding.

Claude-Session: https://claude.ai/code/session_01E6TJUAh41HaP25MVHWkhRZ
2026-08-18 21:22:58 -04:00
Gabriel Brown 70d8d32ee2 Fix the install pipeline and an idle-lock startup race
The initial package list was quoted into a single bogus dnf argument
and every dnf error was discarded, so a fresh install silently skipped
most of it. Two package lists were never wired into the pipeline at
all, and change-settings ran before install-packages, so the vicinae
theme step was permanently skipped. Fixed the ordering, the quoting,
and stopped swallowing errors.

Separately, hypridle could start with its WAYLAND_DISPLAY condition
unmet if it raced the env-publish call, silently never starting --
and it's the only listener for the logind Lock signal. Made the start
wait on the environment synchronously. panama-idle also wrote its
generated config to a fixed temp path with no locking, so concurrent
applies could interleave into a corrupt file; switched to mktemp plus
an atomic mv.

Claude-Session: https://claude.ai/code/session_01E6TJUAh41HaP25MVHWkhRZ
2026-08-18 21:22:52 -04:00
53 changed files with 1012 additions and 785 deletions
+5 -2
View File
@@ -152,11 +152,14 @@ case "$cmd" in
# 2.4) Run flatpak updates (user then system)
flatpak update -y
sudo flatpak update
sudo flatpak update -y
# 2.5) Optional firmware via fwupd
if $firmware; then
sudo fwupdmgr refresh
# fwupdmgr exits non-zero when metadata is already current -- that is
# not an error, but under 'set -e' it would abort the script before
# 'fwupdmgr update' ever runs.
sudo fwupdmgr refresh || true
sudo fwupdmgr update
fi
+1 -1
View File
@@ -77,7 +77,7 @@ than presenting inert Hyprland controls.
| Caffeine | Replaced by a real logind inhibitor in quick settings |
| Blur My Shell / Openbar / User Theme | Replaced by the Prism shell and compositor blur |
| Bluetooth Quick Connect | Replaced by the full Bluetooth picker |
| Wi-Fi QR | Deliberately omitted; it is not useful enough to justify another credential-reading surface |
| Wi-Fi QR | Replaced by an on-demand QR-code sharing flow in Control Center's Wi-Fi panel; the code is generated only while shown and written to tmpfs, never persisted |
| GSConnect | Replaced by capability-aware KDE Connect phone continuity in Control Center; the paired iPhone exposes file, clipboard, and Ring actions when reachable |
| Home Assistant | Replaced by secure favourites in Control Center; explicit private environment values take precedence over the existing GNOME extension and Secret Service setup |
| Custom Hot Corners Extended | No action was configured, so there is no behavior to port |
+1 -1
View File
@@ -247,7 +247,7 @@ The mental model is unchanged from Forge:
| `SUPER + SHIFT + H/J/K/L` | Move window |
| `SUPER + CTRL + H/J/K/L` | Swap window |
| `SUPER + SHIFT + Y/O` · `B/M` | Wider · narrower |
| `SUPER + SHIFT + I/U` · `P/N` | Taller · shorter |
| `SUPER + SHIFT + I/U` · `,/N` | Taller · shorter |
| `SUPER + [` / `]` / `=` | Shrink / expand / reset split |
| `SUPER + Q` | Close |
| `SUPER + U` | Fullscreen |
+10 -2
View File
@@ -23,8 +23,16 @@ hl.on("hyprland.start", function()
hl.exec_cmd("dbus-update-activation-environment --systemd WAYLAND_DISPLAY XDG_CURRENT_DESKTOP=Hyprland")
hl.exec_cmd("systemctl --user start hyprland-session.target")
-- Units: polkit prompts, wallpaper, launcher daemon, idle/lock.
hl.exec_cmd("systemctl --user start hyprpolkitagent.service hyprpaper.service vicinae.service hypridle.service")
-- Units: polkit prompts, wallpaper, launcher daemon, idle/lock. All four
-- carry `ConditionEnvironment=WAYLAND_DISPLAY`, and hl.exec_cmd fires
-- commands without waiting for them to finish, so the dbus-update call
-- above racing this one is not safe to assume complete -- a lost race
-- leaves the Condition unmet and the unit silently never starts (exit 0,
-- no error). hypridle is the only listener for the logind Lock signal,
-- so that failure mode is "lock-session goes to nobody". Re-import
-- synchronously in the same shell invocation first so the Condition
-- always sees it, regardless of how the dbus-update call above scheduled.
hl.exec_cmd("systemctl --user import-environment WAYLAND_DISPLAY XDG_CURRENT_DESKTOP && systemctl --user start hyprpolkitagent.service hyprpaper.service vicinae.service hypridle.service")
-- The shell: bar, dock, overview, quick settings, notifications, capture.
-- No systemd unit ships with quickshell, so it runs as a compositor child.
+15 -1
View File
@@ -192,7 +192,10 @@ bind(mod .. " + SHIFT + B", hl.dsp.window.resize({ x = -step, y = 0, relative =
bind(mod .. " + SHIFT + M", hl.dsp.window.resize({ x = -step, y = 0, relative = true }), { repeating = true, description = "Narrower" })
bind(mod .. " + SHIFT + I", hl.dsp.window.resize({ x = 0, y = step, relative = true }), { repeating = true, description = "Taller" })
bind(mod .. " + SHIFT + U", hl.dsp.window.resize({ x = 0, y = step, relative = true }), { repeating = true, description = "Taller" })
bind(mod .. " + SHIFT + P", hl.dsp.window.resize({ x = 0, y = -step, relative = true }), { repeating = true, description = "Shorter" })
-- SUPER+SHIFT+P was double-bound with the colour picker above; moved to
-- Comma, which continues the bottom-row cluster (B/M/N) this axis already
-- uses rather than landing on an arbitrary free key.
bind(mod .. " + SHIFT + Comma", hl.dsp.window.resize({ x = 0, y = -step, relative = true }), { repeating = true, description = "Shorter" })
bind(mod .. " + SHIFT + N", hl.dsp.window.resize({ x = 0, y = -step, relative = true }), { repeating = true, description = "Shorter" })
-- Window cycling (GNOME: cycle-windows on SUPER+Tab), now with an overlay
@@ -223,6 +226,17 @@ bind(mod .. " + mouse:273", hl.dsp.window.resize(), { mouse = true, description
-- Plain relative selectors ("+1" / "-1") reproduce GNOME's dynamic workspaces:
-- moving right past the last workspace creates a new one, and moving left from
-- the first clamps instead of wrapping.
-- Behaviour for the relative/cyclic binds below. These are Hyprland's own
-- `binds:` options -- not part of general/dwindle -- and have no other home
-- in the config, so they are read here rather than in looks.lua.
hl.config({
binds = {
workspace_back_and_forth = prefs.get("workspaceBackAndForth", false),
allow_workspace_cycles = prefs.get("allowWorkspaceCycles", false),
},
})
bind("ALT + H", hl.dsp.focus({ workspace = "-1" }), { description = "Workspace left" })
bind("ALT + L", hl.dsp.focus({ workspace = "+1" }), { description = "Workspace right" })
bind("ALT + SHIFT + H", hl.dsp.window.move({ workspace = "-1" }), { description = "Move window to workspace left" })
+35 -7
View File
@@ -2,7 +2,7 @@
-- Look and feel -- Tokyo Night Moon
--
-- Colours here must stay in sync with quickshell/config/Theme.qml.
-- accent #82aaff borders / focus
-- accent user-selectable, see `accents` below -- blue (#82aaff) ships
-- bg #222436 base
--
-- Performance note: every animation below is event-driven. Nothing uses the
@@ -14,6 +14,30 @@
local prefs = require("prefs")
-- Mirrors config/Theme.qml's `accents` map. Lua has no import path into a QML
-- singleton, so the hex pairs are restated here -- just the four hex strings
-- each named accent needs, not the labels, which stay UI-only.
local accents = {
blue = { dark = "82aaff", darkSecondary = "b172b0", light = "2e7de9", lightSecondary = "9854f1" },
orchid = { dark = "c099ff", darkSecondary = "fca7ea", light = "7847bd", lightSecondary = "9854f1" },
teal = { dark = "86e1fc", darkSecondary = "82aaff", light = "007197", lightSecondary = "2e7de9" },
green = { dark = "c3e88d", darkSecondary = "86e1fc", light = "587539", lightSecondary = "007197" },
amber = { dark = "ffc777", darkSecondary = "ff966c", light = "8c6c3e", lightSecondary = "b15c00" },
orange = { dark = "ff966c", darkSecondary = "ff757f", light = "b15c00", lightSecondary = "c64343" },
rose = { dark = "ff757f", darkSecondary = "c099ff", light = "f52a65", lightSecondary = "9854f1" },
slate = { dark = "828bb8", darkSecondary = "82aaff", light = "6172b0", lightSecondary = "2e7de9" },
}
-- The accent pair a fresh session or `hyprctl reload` starts from.
-- services/ColorScheme.qml overwrites this live, from the same Theme.accents
-- data, once the shell settles (~1.2s after startup -- see its `settle`
-- Timer). This table exists only so the compositor is never observably blue
-- for a non-blue accent during the gap before that first live apply.
local accentScheme = prefs.get("colorScheme", "dark")
local accentPair = accents[prefs.get("accentName", "blue")] or accents.blue
local accentStart = accentScheme == "light" and accentPair.light or accentPair.dark
local accentEnd = accentScheme == "light" and accentPair.lightSecondary or accentPair.darkSecondary
hl.config({
general = {
gaps_in = prefs.get("gapsIn", 5),
@@ -22,11 +46,13 @@ hl.config({
border_size = prefs.get("borderSize", 2),
col = {
-- The focused accent role: blue leads, orchid follows, on a
-- diagonal so the pair is visible on both a tall and a wide
-- window. ColorScheme never writes this role; a future accent
-- picker can own it without fighting light/dark mode.
active_border = { colors = { "rgba(82aaffee)", "rgba(b172b0ee)" }, angle = 115 },
-- The focused accent role, on a diagonal so the pair is visible
-- on both a tall and a wide window. Driven by the chosen
-- accentName (see the `accents` table above); services/
-- ColorScheme.qml applies the same values live, and restates them
-- from Theme.accent/Theme.accentSecondary on every scheme change
-- too, since each accent carries a separate pair per scheme.
active_border = { colors = { "rgba(" .. accentStart .. "ee)", "rgba(" .. accentEnd .. "ee)" }, angle = 115 },
-- The neutral inactive role follows the colour scheme because a
-- dark neutral disappears against a light desktop.
-- services/ColorScheme.qml applies the same values live; this is
@@ -104,11 +130,13 @@ hl.config({
-- New in 0.56. Kept deliberately faint: in this direction the gradient
-- border is the signature, and a strong halo would compete with it.
-- This is just enough to lift the focused window off the wallpaper.
-- Derived from the same accent as active_border above, not hardcoded,
-- so the halo never disagrees with the border it surrounds.
glow = {
enabled = prefs.get("glowEnabled", true),
range = prefs.get("glowRange", 8),
render_power = 2,
color = "rgba(82aaff33)",
color = "rgba(" .. accentStart .. "33)",
color_inactive = "rgba(00000000)",
},
@@ -25,10 +25,18 @@ PanelWindow {
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
// Deliberately does not read Capture.recordingSeconds (or anything else
// that ticks once a second): this array is a plain JS array, not an
// identity-preserving model, so any dependency that changes every second
// would make the whole thing re-derive every second, and the Repeater
// below would destroy and recreate every row -- including one the user
// might be hovering or about to click. The set of activities should only
// change when an activity actually starts or stops. Elapsed time is
// rendered by each row's own Text binding instead, further down.
readonly property var activities: {
const result = [];
if (PrivacyState.recordingActive)
result.push({ kind: "recording", glyph: "\u{F044A}", label: "Screen recording", detail: "Panama · " + root.elapsed(), tone: "danger", stoppable: true });
result.push({ kind: "recording", glyph: "\u{F044A}", label: "Screen recording", detail: "Panama", tone: "danger", stoppable: true });
if (PrivacyState.screenSharingActive)
result.push({ kind: "screen", glyph: "\u{F0379}", label: "Screen sharing", detail: PrivacyState.screenSharingApp || "Managed by the application", tone: "warn", stoppable: false });
if (PrivacyState.cameraActive)
@@ -38,11 +46,12 @@ PanelWindow {
return result;
}
function elapsed(): string {
const total = Capture.recordingSeconds;
const seconds = String(total % 60).padStart(2, "0");
const minutes = Math.floor(total / 60) % 60;
const hours = Math.floor(total / 3600);
// Pure formatter, no ticking property read here -- callers decide what
// seconds value to pass, and only they take on the per-second dependency.
function formatElapsed(totalSeconds: int): string {
const seconds = String(totalSeconds % 60).padStart(2, "0");
const minutes = Math.floor(totalSeconds / 60) % 60;
const hours = Math.floor(totalSeconds / 3600);
return hours > 0 ? `${hours}:${String(minutes).padStart(2, "0")}:${seconds}` : `${minutes}:${seconds}`;
}
@@ -164,7 +173,11 @@ PanelWindow {
Text {
width: parent.width
text: activityRow.modelData.detail
// Only this Text re-evaluates every second while
// recording -- Capture.recordingSeconds is read
// here, not in the parent `activities` array, so
// the row itself is never torn down for a tick.
text: activityRow.modelData.kind === "recording" ? activityRow.modelData.detail + " · " + root.formatElapsed(Capture.recordingSeconds) : activityRow.modelData.detail
color: Theme.fgDim
elide: Text.ElideRight
font.family: Theme.fontFamily
@@ -1,222 +0,0 @@
// The calendar that drops out of the clock — GNOME's date menu, minus the
// notification list (that lives in its own panel). Month grid, today marked
// with the accent, arrows to page through months, current weather at the foot.
import Quickshell
import QtQuick
import qs.config
import qs.services
import qs.widgets
Popover {
id: root
// Popover's container is a plain Item, which does not derive an implicit
// size from its children, so the window has to be sized from the body.
implicitWidth: body.implicitWidth + contentPadding * 2
implicitHeight: body.implicitHeight + contentPadding * 2
readonly property int cellSize: 34
readonly property int cellHeight: 30
// Hours precision is enough: the only thing that has to change on its own
// is which cell counts as "today", and that only moves at midnight.
SystemClock {
id: clock
precision: SystemClock.Hours
}
readonly property int todayYear: clock.date.getFullYear()
readonly property int todayMonth: clock.date.getMonth()
readonly property int todayDay: clock.date.getDate()
// The month currently on screen. Reset to today every time the popover
// opens, so it never comes back showing wherever you paged off to.
property int viewYear: root.todayYear
property int viewMonth: root.todayMonth
onVisibleChanged: if (visible)
root.showToday()
function showToday(): void {
root.viewYear = root.todayYear;
root.viewMonth = root.todayMonth;
}
function stepMonth(delta: int): void {
const d = new Date(root.viewYear, root.viewMonth + delta, 1);
root.viewYear = d.getFullYear();
root.viewMonth = d.getMonth();
}
// Six weeks of cells, so the grid height never changes as you page through
// months. Days from the neighbouring months fill the edges, dimmed.
readonly property var cells: {
const first = new Date(root.viewYear, root.viewMonth, 1);
const offset = first.getDay(); // 0 = Sunday, matching the header row
const inThisMonth = new Date(root.viewYear, root.viewMonth + 1, 0).getDate();
const inPrevMonth = new Date(root.viewYear, root.viewMonth, 0).getDate();
const out = [];
for (let i = 0; i < 42; i++) {
const n = i - offset + 1;
if (n < 1)
out.push({
day: inPrevMonth + n,
current: false
});
else if (n > inThisMonth)
out.push({
day: n - inThisMonth,
current: false
});
else
out.push({
day: n,
current: true
});
}
return out;
}
Column {
id: body
spacing: Theme.itemSpacing
// ── Month header ────────────────────────────────────────────────────
Item {
width: root.cellSize * 7
height: 28
CalendarArrow {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
glyph: "\u{F0141}" // md-chevron_left
onActivated: root.stepMonth(-1)
}
Text {
anchors.centerIn: parent
text: Qt.formatDateTime(new Date(root.viewYear, root.viewMonth, 1), "MMMM yyyy")
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeLarge
font.weight: Font.DemiBold
color: Theme.fg
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: root.showToday()
}
}
CalendarArrow {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
glyph: "\u{F0142}" // md-chevron_right
onActivated: root.stepMonth(1)
}
}
// ── Weekday header ──────────────────────────────────────────────────
Row {
Repeater {
model: ["S", "M", "T", "W", "T", "F", "S"]
delegate: Text {
required property string modelData
width: root.cellSize
height: 20
text: modelData
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
color: Theme.fgMuted
}
}
}
// ── Day grid ────────────────────────────────────────────────────────
Grid {
columns: 7
Repeater {
model: root.cells
delegate: Item {
id: cell
required property var modelData
readonly property bool isToday: cell.modelData.current && cell.modelData.day === root.todayDay && root.viewMonth === root.todayMonth && root.viewYear === root.todayYear
width: root.cellSize
height: root.cellHeight
Rectangle {
anchors.centerIn: parent
width: root.cellHeight - 2
height: root.cellHeight - 2
radius: width / 2
border.width: 0
visible: cell.isToday
color: Theme.accent
}
Text {
anchors.centerIn: parent
text: cell.modelData.day
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: cell.isToday ? Font.DemiBold : Font.Normal
color: {
if (cell.isToday)
return Theme.bgDark;
return cell.modelData.current ? Theme.fg : Theme.fgMuted;
}
}
}
}
}
// ── Weather ─────────────────────────────────────────────────────────
Rectangle {
width: root.cellSize * 7
height: 1
color: Theme.alpha(Theme.fg, 0.1)
visible: Weather.available
}
Row {
spacing: Theme.itemSpacing
visible: Weather.available
Text {
anchors.verticalCenter: parent.verticalCenter
text: Weather.icon
font.family: Theme.fontMono
font.pixelSize: Theme.fontSizeTitle
color: Theme.accentAlt
}
Column {
Text {
text: Math.round(Weather.temperature) + Weather.unitSuffix
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
color: Theme.fg
}
Text {
text: Weather.description
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
color: Theme.fgDim
}
}
}
}
}
@@ -1,109 +0,0 @@
// The "you are being recorded" pill.
//
// GNOME puts a red dot and a timer in the top bar while a screencast runs and
// clicking it stops the recording. The bar is another module's window, so this
// is its own tiny layer surface parked just below it.
import Quickshell
import Quickshell.Wayland
import QtQuick
import qs.config
import qs.services
PanelWindow {
id: win
visible: Capture.recording
color: "transparent"
// Top only: with neither left nor right anchored, layer-shell centres the
// surface horizontally.
anchors.top: true
margins.top: Theme.barGap * 2 + Theme.barHeight + Theme.barGap
implicitWidth: pill.implicitWidth
implicitHeight: pill.implicitHeight
exclusiveZone: 0
WlrLayershell.namespace: "qs-popover-recording"
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
function elapsed(): string {
const t = Capture.recordingSeconds;
const s = ("0" + (t % 60)).slice(-2);
const m = Math.floor(t / 60) % 60;
const h = Math.floor(t / 3600);
return h > 0 ? h + ":" + ("0" + m).slice(-2) + ":" + s : m + ":" + s;
}
Rectangle {
id: pill
implicitWidth: row.implicitWidth + 12
implicitHeight: 34
radius: Theme.pillRadius
border.width: 0
color: Theme.redDeep
Row {
id: row
anchors.centerIn: parent
spacing: 8
// Static dot, not a blinking one: nothing in this shell repaints
// while idle.
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: 10
height: 10
radius: 5
border.width: 0
color: Theme.fg
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: win.elapsed()
color: Theme.fg
// Tabular figures: the timer must not shuffle as it counts.
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSize
font.weight: Font.Medium
}
Rectangle {
id: stop
anchors.verticalCenter: parent.verticalCenter
width: 26
height: 26
radius: width / 2
border.width: 0
color: stopMouse.containsMouse ? Theme.alpha(Theme.fg, 0.28) : Theme.alpha(Theme.fg, 0.14)
Behavior on color {
ColorAnimation {
duration: Theme.durFast
}
}
Text {
anchors.centerIn: parent
text: "󰓛"
color: Theme.fg
font.family: Theme.fontMono
font.pixelSize: Theme.fontSize
}
MouseArea {
id: stopMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
// SIGINT, so wf-recorder finalises the container.
onClicked: Capture.stopRecording()
}
}
}
}
}
+30 -4
View File
@@ -48,12 +48,22 @@ PanelWindow {
implicitHeight: tooltipSpace + body.implicitHeight + bottomMargin
// ── Intellihide ─────────────────────────────────────────────────────────
// This instance's own monitor, the same lookup Workspaces.qml uses to
// scope a per-screen bar to its own screen. Falls back to null when this
// Dock is created standalone (no `screen` set) rather than via Variants.
readonly property HyprlandMonitor monitor: root.screen ? Hyprland.monitorFor(root.screen) : null
// Hyprland does not expose live toplevel geometry, so exact overlap cannot
// be computed. "Is anything on this workspace at all" is the robust proxy,
// and it is what Dash-to-Dock's all-windows intellihide felt like in
// practice: an empty workspace keeps the dock out.
//
// Deliberately this instance's own monitor's active workspace, not the
// globally-focused one -- with one Dock per screen, keying off the global
// focus would make focusing an empty workspace on monitor A hide the dock
// on monitor B even though B's own workspace is still busy.
readonly property bool workspaceOccupied: {
const ws = Hyprland.focusedWorkspace;
const ws = root.monitor ? root.monitor.activeWorkspace : Hyprland.focusedWorkspace;
return !!ws && ws.toplevels.values.length > 0;
}
@@ -83,15 +93,31 @@ PanelWindow {
onTriggered: root.revealed = false
}
// Other modules (the bar, the capture overlay) read this.
onRevealedChanged: ShellState.dockRevealed = revealed
// Other modules (the bar, the capture overlay) read this. It is one
// shared flag but there is one Dock per monitor, so only the instance on
// the currently-focused monitor is allowed to write it -- otherwise
// whichever instance last changed reveal state would stomp the others,
// and a reader would see an arbitrary monitor's value. This scopes the
// flag to mean "is the dock revealed on the monitor the user is on",
// which is what a capture overlay or the bar actually care about.
// (A true per-monitor flag would need ShellState.dockRevealed itself to
// become keyed by screen, which is out of scope here -- see the report.)
readonly property bool isFocusedMonitorInstance: root.monitor === null || root.monitor === Hyprland.focusedMonitor
onRevealedChanged: root._syncShellState()
onIsFocusedMonitorInstanceChanged: root._syncShellState()
function _syncShellState(): void {
if (root.isFocusedMonitorInstance)
ShellState.dockRevealed = root.revealed;
}
// wantRevealed's first evaluation emits no change signal when it lands on
// false (the default), so the initial state has to be taken explicitly —
// otherwise a shell started on a busy workspace would leave the dock up.
Component.onCompleted: {
revealed = wantRevealed;
ShellState.dockRevealed = revealed;
root._syncShellState();
}
// ── Input region ────────────────────────────────────────────────────────
@@ -1,219 +0,0 @@
// GNOME's message tray: everything that has arrived, grouped by app.
//
// Drops from the top centre because that is where GNOME's tray lives and the
// muscle memory is the whole point. Layer-shell centres a surface on whichever
// axis it is not anchored to, so anchoring only `top` does it.
import QtQuick
import Quickshell
import Quickshell.Wayland
import Quickshell.Hyprland
import Quickshell.Widgets
import qs.config
import qs.services
import qs.modules.quicksettings
import qs.widgets
PanelWindow {
id: root
visible: ShellState.notificationsOpen
color: "transparent"
anchors.top: true
margins.top: Theme.barHeight + Theme.barGap * 2
exclusiveZone: 0
implicitWidth: 440
implicitHeight: surface.implicitHeight
WlrLayershell.namespace: "qs-popover-notifications"
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand
onVisibleChanged: {
if (root.visible)
Notifs.markAllRead();
}
HyprlandFocusGrab {
windows: [root]
active: root.visible
onCleared: ShellState.close()
}
Rectangle {
id: surface
anchors.fill: parent
implicitHeight: content.implicitHeight + Theme.popoverPadding * 2
radius: Theme.popoverRadius
color: Theme.alpha(Theme.bgPopover, Theme.popoverAlpha)
border.width: 1
border.color: Theme.alpha(Theme.fg, 0.08)
// The prism edge — see widgets/PrismEdge.qml. Sits just inside the 1px
// border so the two don't fight for the same row of pixels.
PrismEdge {
anchors.top: parent.top
anchors.topMargin: 1
anchors.left: parent.left
anchors.right: parent.right
inset: parent.radius
}
Item {
anchors.fill: parent
focus: true
Keys.onEscapePressed: ShellState.close()
Column {
id: content
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: Theme.popoverPadding
spacing: Theme.itemSpacing
// ── Header ──────────────────────────────────────────────────
Item {
width: parent.width
height: 32
Text {
anchors.left: parent.left
anchors.leftMargin: 4
anchors.verticalCenter: parent.verticalCenter
text: "Notifications"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeLarge
font.weight: Font.DemiBold
}
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 4
IconButton {
size: 30
iconSize: 16
icon: "notifications-disabled-symbolic"
tint: Notifs.doNotDisturb ? Theme.accent : Theme.fgDim
onClicked: Notifs.doNotDisturb = !Notifs.doNotDisturb
}
Rectangle {
width: clearLabel.implicitWidth + 20
height: 28
radius: Theme.pillRadius
border.width: 0
visible: Notifs.hasNotifications
color: clearMouse.containsMouse ? Theme.alpha(Theme.fg, 0.16) : Theme.alpha(Theme.fg, 0.08)
Behavior on color {
ColorAnimation {
duration: Theme.durFast
}
}
Text {
id: clearLabel
anchors.centerIn: parent
text: "Clear all"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
}
MouseArea {
id: clearMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: Notifs.dismissAll()
}
}
}
}
// ── Empty state ─────────────────────────────────────────────
Item {
width: parent.width
height: 120
visible: !Notifs.hasNotifications
Text {
anchors.centerIn: parent
text: Notifs.doNotDisturb ? "Do Not Disturb is on" : "No notifications"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
}
// ── Grouped history ─────────────────────────────────────────
ScrollColumn {
width: parent.width
maxHeight: 620
spacing: Theme.itemSpacing
visible: Notifs.hasNotifications
Repeater {
model: Notifs.groups
Column {
id: group
required property var modelData
width: parent.width
spacing: 4
Item {
width: parent.width
height: 26
Text {
anchors.left: parent.left
anchors.leftMargin: 4
anchors.verticalCenter: parent.verticalCenter
text: group.modelData.app
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.DemiBold
}
IconButton {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
size: 24
iconSize: 13
tint: Theme.fgDim
icon: "edit-clear-all-symbolic"
iconFallback: "window-close-symbolic"
onClicked: Notifs.dismissApp(group.modelData.app)
}
}
Repeater {
model: group.modelData.items
NotificationCard {
required property var modelData
width: group.width
compact: true
notification: modelData
onDismissed: Notifs.dismiss(modelData)
}
}
}
}
}
}
}
}
}
@@ -1,7 +1,6 @@
// A single banner: a notification card that slides in and times itself out.
import QtQuick
import Quickshell.Services.Notifications
import qs.config
import qs.services
@@ -14,28 +13,25 @@ Item {
// into a keyboard focus request on the layer surface.
signal replyFocusChanged(bool focused)
// Mirrors the signal above so the dismiss timer below can read it.
property bool replyFocused: false
implicitHeight: card.implicitHeight
// Critical notifications stay until dismissed (the setting is 0). An app
// asking for 0 means "never expire" per the freedesktop spec; -1 means
// "server decides", which is our default.
readonly property int timeoutMs: {
if (root.notification.urgency === NotificationUrgency.Critical)
return Settings.notificationTimeoutCriticalMs;
if (root.notification.expireTimeout === 0)
return 0;
if (root.notification.expireTimeout > 0)
return Math.round(root.notification.expireTimeout * 1000);
return Settings.notificationTimeoutMs;
}
// "server decides", which is our default. Shared with Notifs.qml, which
// gives a DND-hidden transient notification the same lifetime.
readonly property int timeoutMs: Notifs.notificationTimeoutMs(root.notification)
HoverHandler {
id: hover
}
Timer {
// Hovering holds the banner open; the countdown restarts on leave.
running: root.timeoutMs > 0 && !hover.hovered
// Hovering, or actively typing a reply, holds the banner open; the
// countdown restarts (from the top, same as hover) once both let go.
running: root.timeoutMs > 0 && !hover.hovered && !root.replyFocused
interval: root.timeoutMs
onTriggered: Notifs.dropPopup(root.notification)
}
@@ -45,7 +41,10 @@ Item {
width: parent.width
notification: root.notification
onDismissed: Notifs.dropPopup(root.notification)
onReplyFocusChanged: focused => root.replyFocusChanged(focused)
onReplyFocusChanged: focused => {
root.replyFocused = focused;
root.replyFocusChanged(focused);
}
// Slide in from the right edge. Runs once, on creation.
NumberAnimation on x {
@@ -45,7 +45,17 @@ PanelWindow {
spacing: Theme.itemSpacing
Repeater {
model: Notifs.popups.slice(0, Settings.maxVisibleToasts)
// Notifs.popups.slice() is a fresh array on every change (a new
// arrival, a dismissal, a sibling toast timing out). Handing that
// straight to Repeater would reset the model and rebuild every
// delegate each time, blowing away whichever toast has a reply
// field mid-typing. ScriptModel diffs by object identity
// (Notification instances are unique QObjects), so only genuinely
// added/removed notifications add/remove delegates — unrelated
// toasts, and their slide-in animations, are untouched.
model: ScriptModel {
values: Notifs.popups.slice(0, Settings.maxVisibleToasts)
}
Toast {
required property var modelData
@@ -137,7 +137,18 @@ Item {
}
Repeater {
model: root.networks
// root.networks is a fresh array on every signal-strength tick (the
// sort comparator reads signalStrength/connected/known, so any of
// those changing on ANY network recomputes the whole list). Handing
// that straight to Repeater would reset the model and rebuild every
// delegate each tick, blowing away whichever row has its password
// Section open and focused. ScriptModel diffs by object identity
// (WifiNetwork instances are unique QObjects) and turns a reorder
// into move operations, so existing delegates -- and their expanded
// state -- survive.
model: ScriptModel {
values: root.networks
}
Column {
id: entry
@@ -20,23 +20,45 @@ Flow {
readonly property string current: DesktopPreferences.get("accentName") || "blue"
// The schema's own option list, not Theme.accents directly -- two lists
// hand-kept in sync is how they drift. This is the same pattern
// ChoiceRow.qml uses for every other enum row.
readonly property var spec: PreferenceSchema.spec("accentName")
readonly property var options: root.spec && root.spec.options ? root.spec.options : []
Repeater {
// Object key order is insertion order here, so the palette's own order
// is what the user sees; blue first because it is what Panama ships.
model: Object.keys(Theme.accents)
// The schema's option order is the palette's order, so blue is first
// because it is what Panama ships.
model: root.options
Column {
id: entry
required property var modelData
readonly property var pair: Theme.accents[entry.modelData]
readonly property bool selected: entry.modelData === root.current
readonly property string name: entry.modelData.value
readonly property var pair: Theme.accents[entry.name]
readonly property bool selected: entry.name === root.current
readonly property color start: Theme.dark ? entry.pair.dark : entry.pair.light
readonly property color end: Theme.dark ? entry.pair.darkSecondary : entry.pair.lightSecondary
spacing: 5
// The hit target is the whole swatch+label unit, not just the
// 46px circle: the label exists specifically so someone with a
// colour vision deficiency can identify an accent without it, and
// a label that cannot itself be tapped defeats that.
HoverHandler {
cursorShape: Qt.PointingHandCursor
}
TapHandler {
onTapped: {
if (!SystemSettings.commitPreference("accentName", entry.name))
console.warn("AccentPicker: commitPreference rejected accent", entry.name);
}
}
Rectangle {
width: 46
height: 46
@@ -59,12 +81,6 @@ Flow {
GradientStop { position: 1.0; color: entry.end }
}
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: SystemSettings.commitPreference("accentName", entry.modelData)
}
}
// Always shown, not a tooltip. Telling swatches apart by colour is
@@ -73,7 +89,7 @@ Flow {
// hiding the names behind a hover would waste that.
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: entry.pair.label
text: entry.modelData.label
color: entry.selected ? Theme.fg : Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
@@ -441,7 +441,7 @@ SettingsPage {
ActionRow {
objectName: "health-fedora-handoff:color"
label: "Colour profiles"
detail: "ICC profiles for displays, printers, and scanners"
detail: "Assigning an ICC profile here has no visible effect in this session: the colord daemon that loads it onto a display isn't running under Hyprland"
action: "Open colour"
onTriggered: SystemSettings.openGnomePanel("color")
}
@@ -96,9 +96,11 @@ reason and an update to `tests/quickshell/settings-ownership-contract.sh`.
Window border colour follows the same ownership rule. The inactive border is a
**scheme-relative role** owned by `ColorScheme.qml`: it changes only to retain
neutral contrast in light and dark modes. The focused Prism border is the
accent role owned by the visual theme (and, eventually, an accent picker).
`ColorScheme.qml` must never write the focused border, so changing schemes
cannot erase a user-selected accent.
**accent role**, driven by the chosen `accentName` and also owned by
`ColorScheme.qml`: each accent carries a separate pair for light and dark, so
`ColorScheme.qml` restates the focused border alongside the inactive one on
every scheme change, rather than leaving a scheme flip to erase a
user-selected accent.
## The rows
@@ -51,9 +51,11 @@ Item {
return typeof value === "number" ? value : root.minimum;
}
// Shown while dragging; -1 means "nothing pending, show what is stored".
property real pending: -1
readonly property real shown: root.pending >= 0 ? root.pending : root.stored
// Shown while dragging; null means "nothing pending, show what is stored".
// Not -1: several schema entries (e.g. pointerSensitivity) are legitimately
// negative, and -1 would be indistinguishable from a real committed value.
property var pending: null
readonly property real shown: root.pending !== null ? root.pending : root.stored
// Below this the label and a usable slider cannot share a line without one
// of them becoming useless.
@@ -176,7 +178,7 @@ Item {
id: commitTimer
interval: 140
onTriggered: {
if (root.pending < 0)
if (root.pending === null)
return;
SystemSettings.commitPreference(root.setting, root.pending);
// Hand the display back to the stored value. If the write was
@@ -188,6 +190,6 @@ Item {
Timer {
id: releaseTimer
interval: 160
onTriggered: root.pending = -1
onTriggered: root.pending = null
}
}
@@ -255,7 +255,11 @@ def watch(start: int, end: int) -> int:
return 1
loop = GLib.MainLoop()
registry = EDataServer.SourceRegistry.new_sync(None)
try:
registry = EDataServer.SourceRegistry.new_sync(None)
except Exception:
_write_snapshot(_unavailable_snapshot())
return 1
debounce_source = 0
subscriptions: list[int] = []
+33 -4
View File
@@ -61,6 +61,16 @@ class DoctorConfig:
runtime_dir: Path
path: str
timeout: float
# Defaults to this file's own directory, where its sibling helpers
# (panama-action, panama-brightness, calendar-agenda) actually live.
scripts_dir: Path = Path(__file__).resolve().parent
# Repair actions (service restarts, the Quickshell restart-shell action)
# can legitimately run longer than a quick health-check probe -- a
# Quickshell restart alone waits for the old process to exit, the new one
# to start, and settle. Reusing `timeout` here would kill a slow-but-
# successful repair and report it as failed even though a following
# health scan would show everything recovered. See run_repair_command.
repair_timeout: float = 15.0
@property
def command_env(self) -> dict[str, str]:
@@ -193,11 +203,25 @@ def config_from_environment() -> DoctorConfig:
state_home = environment_path("PANAMA_DOCTOR_STATE_HOME", Path(os.environ.get("XDG_STATE_HOME", home / ".local/state")))
runtime_dir = environment_path("PANAMA_DOCTOR_RUNTIME_DIR", Path(os.environ.get("XDG_RUNTIME_DIR", "/run/user/0")))
root = environment_path("PANAMA_DOCTOR_ROOT", Path(__file__).resolve().parents[4])
# Sibling helpers (panama-action, panama-brightness, calendar-agenda) live
# next to this file. PATH is not a reliable way to find them -- nothing in
# this repository puts the Quickshell scripts directory on PATH -- so they
# are invoked by resolved path instead, the same way check_hyprlock already
# resolves panama-lock.
scripts_dir = environment_path("PANAMA_DOCTOR_SCRIPTS_DIR", Path(__file__).resolve().parent)
try:
timeout = float(os.environ.get("PANAMA_DOCTOR_TIMEOUT", "3"))
except ValueError:
timeout = 3.0
return DoctorConfig(root, home, config_home, state_home, runtime_dir, os.environ.get("PANAMA_DOCTOR_PATH", os.environ.get("PATH", "")), max(0.05, min(timeout, 15.0)))
timeout = max(0.05, min(timeout, 15.0))
try:
repair_timeout = float(os.environ.get("PANAMA_DOCTOR_REPAIR_TIMEOUT", "15"))
except ValueError:
repair_timeout = 15.0
# Never shorter than the probe timeout, and bounded so a hung repair still
# gives up rather than blocking the caller indefinitely.
repair_timeout = max(timeout, min(repair_timeout, 30.0))
return DoctorConfig(root, home, config_home, state_home, runtime_dir, os.environ.get("PANAMA_DOCTOR_PATH", os.environ.get("PATH", "")), timeout, scripts_dir, repair_timeout)
def run_command(command: tuple[str, ...], config: DoctorConfig, cwd: Path | None = None) -> CommandResult:
@@ -222,7 +246,7 @@ def run_repair_command(command: tuple[str, ...], config: DoctorConfig, cwd: Path
command,
capture_output=True,
text=True,
timeout=config.timeout,
timeout=config.repair_timeout,
check=False,
env=config.command_env,
cwd=cwd,
@@ -357,7 +381,7 @@ def check_hyprlock(config: DoctorConfig) -> Check:
def check_brightness(config: DoctorConfig) -> Check:
result = run_command(("panama-brightness", "list"), config)
result = run_command((str(config.scripts_dir / "panama-brightness"), "list"), config)
instructions = Action("instructions", "View setup instructions", target="ddc-permissions")
if result.state == "timeout":
return Check("input.brightness", "input-media", "External monitor brightness", "warning", "DDC/CI probe timed out.", instructions)
@@ -418,7 +442,7 @@ def check_home_assistant(config: DoctorConfig) -> Check:
def check_calendar(config: DoctorConfig) -> Check:
result = run_command(("calendar-agenda", "probe"), config)
result = run_command((str(config.scripts_dir / "calendar-agenda"), "probe"), config)
action = Action("open", "Open Date & Time", target="datetime")
if result.state == "missing":
return Check("integration.calendar", "integrations", "Calendar", "unconfigured", "Calendar integration is not installed.")
@@ -583,6 +607,11 @@ def snapshot(config: DoctorConfig) -> dict[str, object]:
def repair_authored_command(check_id: str, config: DoctorConfig) -> RepairResult:
command = REPAIR_COMMANDS[check_id]
if check_id == "desktop.quickshell":
# panama-action is a sibling helper script, not a PATH-resolved
# executable; see check_brightness and check_calendar for the same
# resolution against the same bug.
command = (str(config.scripts_dir / command[0]), *command[1:])
exit_code, _ = run_repair_command(command, config)
message = "Repair completed. A fresh health check will verify recovery." if exit_code == 0 \
else "The authored repair command could not be completed."
+15 -2
View File
@@ -25,6 +25,14 @@ generated="$state_dir/hypridle.conf"
dropin_dir="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user/hypridle.service.d"
dropin="$dropin_dir/panama.conf"
# Set by generate() to the mktemp path it is currently writing, so concurrent
# invocations (e.g. rapid settings changes each spawning `apply`) never share
# a tmp file and interleave writes into a corrupt hypridle.conf. Cleared once
# the atomic mv below lands, so this is a no-op on a normal exit.
generated_tmp=""
cleanup() { rm -f "$generated_tmp" 2>/dev/null || true; }
trap cleanup EXIT
read_setting() {
local key="$1" fallback="$2"
[[ -r "$settings" ]] || { printf '%s' "$fallback"; return; }
@@ -53,6 +61,10 @@ generate() {
load
mkdir -p "$state_dir"
# Unique per invocation, in the same directory as the destination so the
# final mv is an atomic same-filesystem rename rather than a copy.
generated_tmp="$(mktemp "$generated.XXXXXX")"
{
printf '# Generated by panama-idle from %s\n' "$settings"
printf '# Do not edit: it is rewritten whenever the idle settings change.\n'
@@ -91,9 +103,10 @@ generate() {
printf ' on-timeout = systemctl suspend\n'
printf '}\n'
fi
} >"$generated.tmp"
} >"$generated_tmp"
mv "$generated.tmp" "$generated"
mv "$generated_tmp" "$generated"
generated_tmp=""
}
install_dropin() {
+66 -16
View File
@@ -39,13 +39,36 @@ import re
import sys
def daemon_origin():
"""Whether the running secrets daemon came from PAM or from D-Bus activation.
def secrets_name_owner_pid():
"""PID currently owning the org.freedesktop.secrets D-Bus name, if any.
A D-Bus-activated daemon is the signature of the crash-and-replace case
above: it is the one that cannot have the login password. PAM's daemon lives
outside the app slice, so the cgroup tells the two apart.
This is the only reliable way to identify which daemon actually answers
Secret Service calls right now.
"""
try:
import gi
gi.require_version("Gio", "2.0")
from gi.repository import Gio, GLib
bus = Gio.bus_get_sync(Gio.BusType.SESSION, None)
result = bus.call_sync(
"org.freedesktop.DBus",
"/org/freedesktop/DBus",
"org.freedesktop.DBus",
"GetConnectionUnixProcessID",
GLib.Variant("(s)", ("org.freedesktop.secrets",)),
GLib.VariantType("(u)"),
Gio.DBusCallFlags.NONE,
-1,
None,
)
return result.unpack()[0]
except Exception: # noqa: BLE001 - no name owner is a legitimate state
return None
def any_keyring_daemon_running():
try:
for pid in os.listdir("/proc"):
if not pid.isdigit():
@@ -55,19 +78,46 @@ def daemon_origin():
cmdline = handle.read().decode("utf-8", "replace")
except OSError:
continue
if "gnome-keyring-daemon" not in cmdline:
continue
try:
with open(f"/proc/{pid}/cgroup", "r") as handle:
cgroup = handle.read()
except OSError:
return "unknown"
if re.search(r"dbus-.*org\.freedesktop\.secrets", cgroup):
return "dbus"
return "pam"
if "gnome-keyring-daemon" in cmdline:
return True
except OSError:
pass
return "none"
return False
def daemon_origin():
"""Whether the running secrets daemon came from PAM or from D-Bus activation.
A D-Bus-activated daemon is the signature of the crash-and-replace case
above: it is the one that cannot have the login password. PAM's daemon lives
outside the app slice, so the cgroup tells the two apart.
A machine can have two gnome-keyring-daemon processes at once -- a
lingering PAM one alongside its D-Bus-activated replacement -- so which
process this reports on matters: it must be the one that actually owns
org.freedesktop.secrets right now, not merely the first one /proc happens
to enumerate.
"""
owner_pid = secrets_name_owner_pid()
if owner_pid is None:
return "unknown" if any_keyring_daemon_running() else "none"
try:
with open(f"/proc/{owner_pid}/cmdline", "rb") as handle:
cmdline = handle.read().decode("utf-8", "replace")
except OSError:
return "unknown"
if "gnome-keyring-daemon" not in cmdline:
return "unknown"
try:
with open(f"/proc/{owner_pid}/cgroup", "r") as handle:
cgroup = handle.read()
except OSError:
return "unknown"
if re.search(r"dbus-.*org\.freedesktop\.secrets", cgroup):
return "dbus"
return "pam"
def load_service():
+42 -7
View File
@@ -1,9 +1,10 @@
#!/usr/bin/env bash
# Generates Panama's hyprlock configuration into the state directory. The
# tracked config is never rewritten and remains the fallback if generation
# fails, so a malformed preference can never leave the session without a
# working locker.
# gitignored fallback config (rendered from the tracked .template at link
# time) is never rewritten by this script and remains the fallback if
# generation fails, so a malformed preference can never leave the session
# without a working locker.
set -euo pipefail
@@ -78,6 +79,31 @@ valid_path() {
[[ "$1" == /* && "$1" != *","* && "$1" != *$'\n'* ]]
}
# Same 8 named accents as config/Theme.qml's `accents` map (kept in sync by
# hand -- there is no shared source between QML and a shell script), primary
# hue per scheme only: the lock screen shows a flat focus ring, not the
# two-stop gradient the window border does. Falls back to blue for an unknown
# name, matching Theme.accentPair's own fallback.
accent_hex() {
local name="$1" scheme="$2"
case "$name" in
orchid) [[ "$scheme" == light ]] && printf '7847bd' || printf 'c099ff' ;;
teal) [[ "$scheme" == light ]] && printf '007197' || printf '86e1fc' ;;
green) [[ "$scheme" == light ]] && printf '587539' || printf 'c3e88d' ;;
amber) [[ "$scheme" == light ]] && printf '8c6c3e' || printf 'ffc777' ;;
orange) [[ "$scheme" == light ]] && printf 'b15c00' || printf 'ff966c' ;;
rose) [[ "$scheme" == light ]] && printf 'f52a65' || printf 'ff757f' ;;
slate) [[ "$scheme" == light ]] && printf '6172b0' || printf '828bb8' ;;
*) [[ "$scheme" == light ]] && printf '2e7de9' || printf '82aaff' ;;
esac
}
# hyprlock wants "R, G, B" decimal, not hex.
hex_to_rgb() {
local hex="$1"
printf '%d, %d, %d' "0x${hex:0:2}" "0x${hex:2:2}" "0x${hex:4:2}"
}
load_preferences() {
if [[ -r "$settings" ]] && jq -e 'type == "object"' "$settings" >/dev/null 2>&1; then
settings_valid=true
@@ -99,6 +125,12 @@ load_preferences() {
color_scheme="$(read_string colorScheme dark)"
[[ "$color_scheme" == dark || "$color_scheme" == light ]] || color_scheme=dark
accent_name="$(read_string accentName blue)"
case "$accent_name" in
blue|orchid|teal|green|amber|orange|rose|slate) ;;
*) accent_name=blue ;;
esac
wallpaper_mode="$(read_string wallpaperMode single)"
[[ "$wallpaper_mode" == single || "$wallpaper_mode" == slideshow || "$wallpaper_mode" == per-monitor ]] \
|| wallpaper_mode=single
@@ -121,8 +153,6 @@ load_preferences() {
background_color='rgba(225, 226, 231, 1.0)'
foreground_color='rgba(55, 96, 191, 1.0)'
dim_color='rgba(97, 114, 176, 1.0)'
accent_color='rgba(46, 125, 233, 1.0)'
accent_ring_color='rgba(46, 125, 233, 0.9)'
error_color='rgba(245, 42, 101, 1.0)'
field_color='rgba(208, 213, 227, 0.85)'
dim_hex='6172b0'
@@ -131,13 +161,18 @@ load_preferences() {
background_color='rgba(34, 36, 54, 1.0)'
foreground_color='rgba(200, 211, 245, 1.0)'
dim_color='rgba(130, 139, 184, 1.0)'
accent_color='rgba(130, 170, 255, 1.0)'
accent_ring_color='rgba(130, 170, 255, 0.9)'
error_color='rgba(255, 117, 127, 1.0)'
field_color='rgba(46, 47, 61, 0.85)'
dim_hex='828bb8'
error_hex='ff757f'
fi
# The focus ring is the accent role, not a scheme-relative one: it follows
# accentName the same way ColorScheme.qml's active_border does, and needs
# both the accent and the scheme since each accent carries a separate pair.
accent_rgb="$(hex_to_rgb "$(accent_hex "$accent_name" "$color_scheme")")"
accent_color="rgba($accent_rgb, 1.0)"
accent_ring_color="rgba($accent_rgb, 0.9)"
}
monitor_names() {
+1 -1
View File
@@ -71,7 +71,7 @@ adjust_microphone() {
brightness_percent() {
local output="$1" percent
percent="$(awk -F, 'NR == 1 { value=$5; gsub(/%/, "", value); print value }' <<<"$output")"
percent="$(awk -F, 'NR == 1 { value=$4; gsub(/%/, "", value); print value }' <<<"$output")"
[[ $percent =~ ^[0-9]+$ ]] || return 1
printf '%s\n' "$percent"
}
@@ -67,15 +67,19 @@ cmd_list() {
}
cmd_set() {
local profile="${1:-}"
local profile="${1:-}" status
# 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
}
# pipefail is set above, so $? here is busctl's real exit status, not
# head's -- a failed write (daemon stopped, profile rejected) must be
# reported rather than always claimed as a success.
busctl set-property "$BUS_NAME" "$OBJECT" "$BUS_NAME" ActiveProfile s "$profile" 2>&1 >/dev/null \
| head -2 >&2
return 0
status=$?
return "$status"
}
case "${1:-list}" in
@@ -16,21 +16,61 @@
# is authoritative for GTK3 applications. Pinned to dark, it contradicted the
# scheme in light mode, so it is generated from a template here instead.
#
# panama-theme-apps dark|light
# panama-theme-apps dark|light [accent]
#
# kitty gets it twice: the generated include file so terminals opened later
# start correct, and a live `set-colors` over its control socket so terminals
# already open change now. Without the second, a scheme change appears to do
# nothing until you open a new window.
#
# kitty's active_border_color and hyprlock's focus ring are also the accent
# role, not just the scheme -- see accent_hex() below, which mirrors the same
# lookup hypr/looks.lua and scripts/panama-lock carry for the same reason.
set -euo pipefail
scheme="${1:-dark}"
case "$scheme" in
dark|light) ;;
*) printf 'usage: panama-theme-apps [dark|light]\n' >&2; exit 2 ;;
*) printf 'usage: panama-theme-apps [dark|light] [accent]\n' >&2; exit 2 ;;
esac
# Falls back to blue for an unknown or omitted name, matching Theme.qml's own
# accentPair fallback -- so a caller that only ever knew about scheme (a stale
# ColorScheme.qml, a manual invocation) still gets a real accent instead of an
# empty string reaching sed.
accent="${2:-blue}"
case "$accent" in
blue|orchid|teal|green|amber|orange|rose|slate) ;;
*) accent=blue ;;
esac
# Same 8 named accents as config/Theme.qml's `accents` map (kept in sync by
# hand -- there is no shared source between QML and a shell script), primary
# hue per scheme only: both consumers below draw a flat colour, not the
# two-stop gradient the window border does.
accent_hex() {
local name="$1" scheme="$2"
case "$name" in
orchid) [[ "$scheme" == light ]] && printf '7847bd' || printf 'c099ff' ;;
teal) [[ "$scheme" == light ]] && printf '007197' || printf '86e1fc' ;;
green) [[ "$scheme" == light ]] && printf '587539' || printf 'c3e88d' ;;
amber) [[ "$scheme" == light ]] && printf '8c6c3e' || printf 'ffc777' ;;
orange) [[ "$scheme" == light ]] && printf 'b15c00' || printf 'ff966c' ;;
rose) [[ "$scheme" == light ]] && printf 'f52a65' || printf 'ff757f' ;;
slate) [[ "$scheme" == light ]] && printf '6172b0' || printf '828bb8' ;;
*) [[ "$scheme" == light ]] && printf '2e7de9' || printf '82aaff' ;;
esac
}
# hyprlock wants "R, G, B" decimal, not hex.
hex_to_rgb() {
local hex="$1"
printf '%d, %d, %d' "0x${hex:0:2}" "0x${hex:2:2}" "0x${hex:4:2}"
}
accent_border_hex="$(accent_hex "$accent" "$scheme")"
# ── hyprlock ─────────────────────────────────────────────────────────────────
# The lock screen. hyprlock is launched fresh on every lock (`pidof hyprlock ||
# hyprlock`), so it reads this file each time and needs no restart.
@@ -45,7 +85,6 @@ lock_template="$lock_dir/hyprlock.conf.template"
if [[ "$scheme" == "light" ]]; then
lock_fg="55, 96, 191" # #3760bf
lock_muted="97, 114, 176" # #6172b0
lock_accent="46, 125, 233" # #2e7de9
lock_error="245, 42, 101" # #f52a65
lock_bg="225, 226, 231" # #e1e2e7
lock_field="208, 213, 227" # #d0d5e3
@@ -54,7 +93,6 @@ if [[ "$scheme" == "light" ]]; then
else
lock_fg="200, 211, 245" # #c8d3f5
lock_muted="130, 139, 184" # #828bb8
lock_accent="130, 170, 255" # #82aaff
lock_error="255, 117, 127" # #ff757f
lock_bg="34, 36, 54" # #222436
lock_field="46, 47, 61" # #2e2f3d
@@ -62,6 +100,10 @@ else
lock_error_hex="ff757f"
fi
# The focus ring is the accent role, not a scheme-relative one -- follows
# accentName the same way scripts/panama-lock's own generator does.
lock_accent="$(hex_to_rgb "$accent_border_hex")"
status_hyprlock="skipped"
if [[ -r "$lock_template" ]]; then
# Written atomically: a lock triggered mid-write would otherwise read a
@@ -169,9 +211,16 @@ theme_file="$kitty_dir/themes/tokyonight-moon.conf"
status_kitty="skipped"
if [[ -r "$theme_file" ]]; then
# Written atomically: kitty may read this while a new window is starting.
# The accent override is appended after the copied theme rather than
# edited into the tracked theme file: kitty applies settings top to
# bottom, so a later active_border_color line wins, and this file is
# itself generated -- the tracked per-scheme theme stays scheme-only.
if cp "$theme_file" "$kitty_dir/current-theme.conf.tmp" 2>/dev/null \
&& printf 'active_border_color #%s\n' "$accent_border_hex" >>"$kitty_dir/current-theme.conf.tmp" \
&& mv "$kitty_dir/current-theme.conf.tmp" "$kitty_dir/current-theme.conf" 2>/dev/null; then
status_kitty="written"
else
rm -f "$kitty_dir/current-theme.conf.tmp"
fi
# Live-apply to running terminals. kitty appends its PID to the socket name
@@ -182,7 +231,8 @@ if [[ -r "$theme_file" ]]; then
applied=0
while read -r socket; do
[[ -n "$socket" ]] || continue
if kitty @ --to "unix:@${socket#@}" set-colors --all --configured "$theme_file" >/dev/null 2>&1; then
if kitty @ --to "unix:@${socket#@}" set-colors --all --configured "$theme_file" >/dev/null 2>&1 \
&& kitty @ --to "unix:@${socket#@}" set-colors --override "active_border_color=#$accent_border_hex" >/dev/null 2>&1; then
applied=$((applied + 1))
fi
done < <(ss -xl 2>/dev/null | grep -oE '@mykitty[^[:space:]]*' | sort -u)
+35 -10
View File
@@ -26,6 +26,16 @@
set -uo pipefail
# The payload file (created in cmd_qr) is tracked at script scope so it can be
# removed no matter how the script exits -- success, an emit_error exit 0, or a
# signal -- rather than only on a clean function return.
payload_file=""
cleanup_payload() {
[[ -n $payload_file ]] && rm -f -- "$payload_file"
}
trap cleanup_payload EXIT
emit_error() {
printf '{"networks":[],"path":"","error":%s}\n' "$(jq -Rn --arg e "$1" '$e')"
exit 0
@@ -35,22 +45,33 @@ 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
local rows=() name type ssid psk
while IFS= read -r name; do
[[ -n "$name" ]] || continue
ssid="$(nmcli -g 802-11-wireless.ssid connection show "$name" 2>/dev/null)"
# nmcli's terse mode backslash-escapes ':' and '\' WITHIN a field so a
# combined NAME,TYPE line stays splittable -- but a plain awk -F:
# doesn't know that, so a name containing either character (e.g.
# "Cafe: Guest") gets split in the wrong place and TYPE no longer
# lines up, silently dropping the connection from this list. Querying
# one field at a time with escaping turned off (-e no) sidesteps the
# problem entirely: there's nothing to split, so each value comes
# back exactly as stored.
type="$(nmcli -e no -g connection.type connection show "$name" 2>/dev/null)"
[[ "$type" == "802-11-wireless" ]] || continue
ssid="$(nmcli -e no -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)"
psk="$(nmcli -e no -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 }')
done < <(nmcli -e no -t -f NAME connection show 2>/dev/null)
if [[ ${#rows[@]} -eq 0 ]]; then
printf '{"networks":[],"path":"","error":"No saved Wi-Fi networks."}\n'
@@ -77,11 +98,16 @@ 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)"
local ssid hidden psk_file out_dir out_file
# -e no here too: $name is the literal connection name (list emits it
# un-escaped -- see cmd_list), and nmcli's terse escaping is a one-way
# transform on VALUES, not something connection-show lookups expect on
# their NAME argument. Escaping $ssid/$psk here would feed escape_field
# an already-escaped value below and double-escape it.
ssid="$(nmcli -e no -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="$(nmcli -e no -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"
@@ -96,13 +122,12 @@ cmd_qr() {
# 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
nmcli -e no -s -g 802-11-wireless-security.psk connection show "$name" 2>/dev/null | escape_field
printf ';H:%s;;' "$hidden"
} >"$payload_file"
+38 -10
View File
@@ -138,6 +138,9 @@ Singleton {
Process {
id: writer
onExited: {
reader.exited = false;
reader.streamFinished = false;
reader.settled = false;
reader.command = [root.helperPath, "get", String(root.writingBus)];
reader.running = true;
}
@@ -145,19 +148,44 @@ Singleton {
Process {
id: reader
property string outputText: ""
property bool exited: false
property bool streamFinished: false
property bool settled: false
stdout: StdioCollector {
onStreamFinished: {
const actual = parseInt(this.text.trim());
if (!isNaN(actual)) {
root.displays = root.displays.map(display =>
display.bus === root.writingBus
? Object.assign({}, display, { value: actual })
: display);
}
root.writingBus = -1;
// Anything queued while this write was in flight goes now.
root.pump();
reader.outputText = this.text;
reader.streamFinished = true;
root.settleReader();
}
}
// `running` can still read true at the moment onStreamFinished fires --
// the same exited/streamFinished ordering hazard HomeAssistantConfig.qml
// guards against -- so pump() must not be re-entered from here directly.
// Settle on whichever of exited/streamFinished arrives last instead.
onExited: (code, status) => {
reader.exited = true;
root.settleReader();
}
}
function settleReader(): void {
if (reader.settled || !reader.exited || !reader.streamFinished)
return;
reader.settled = true;
const actual = parseInt(reader.outputText.trim());
if (!isNaN(actual)) {
root.displays = root.displays.map(display =>
display.bus === root.writingBus
? Object.assign({}, display, { value: actual })
: display);
}
root.writingBus = -1;
// Anything queued while this write was in flight goes now.
root.pump();
}
}
@@ -32,6 +32,13 @@ Singleton {
property int fixtureNow: 0
property bool watchWanted: false
// Counts watch attempts that exit without ever emitting a snapshot, so a
// helper that is missing or crashes on launch (e.g. no evolution-data-server)
// is reported instead of retried forever in silence.
property int consecutiveWatchFailures: 0
property bool watchProducedSnapshot: false
readonly property int maxConsecutiveWatchFailures: 3
readonly property int nowEpoch: fixtureNow > 0 ? fixtureNow : Math.floor(clock.date.getTime() / 1000)
readonly property var nextEvent: {
const candidates = root.events.filter(event => !event.allDay && Number(event.end) > root.nowEpoch);
@@ -135,14 +142,20 @@ Singleton {
function _restartWatch(): void {
if (root.fixtureMode || root.rangeStart <= 0 || root.rangeEnd <= root.rangeStart)
return;
root.phase = root.events.length > 0 ? root.phase : "loading";
// Once the helper has been declared unavailable, don't flash back to
// "loading" on every retry -- only a real snapshot should clear it.
if (root.phase !== "unavailable")
root.phase = root.events.length > 0 ? root.phase : "loading";
root.watchWanted = false;
root.watchProducedSnapshot = false;
startTimer.restart();
}
function consumeSnapshot(data: string): void {
if (root.fixtureMode || data.trim() === "")
return;
root.watchProducedSnapshot = true;
root.consecutiveWatchFailures = 0;
try {
const snapshot = JSON.parse(data);
if (snapshot.ok !== true) {
@@ -263,6 +276,7 @@ Singleton {
root.errors = [];
root.selectedDate = new Date();
root.phase = "loading";
root.consecutiveWatchFailures = 0;
root.setVisibleMonth(root.selectedDate.getFullYear(), root.selectedDate.getMonth());
root._restartWatch();
}
@@ -275,6 +289,16 @@ Singleton {
onRead: data => root.consumeSnapshot(data)
}
onExited: (code, status) => {
if (!root.watchProducedSnapshot) {
root.consecutiveWatchFailures += 1;
// The helper died before ever emitting a snapshot, repeatedly --
// stop pretending this is still loading and surface the same
// "unavailable" state probe()/collect_snapshot() report.
if (root.consecutiveWatchFailures >= root.maxConsecutiveWatchFailures) {
root.phase = "unavailable";
root.errors = [{ code: "eds-unavailable" }];
}
}
if (root.watchWanted && !root.fixtureMode)
restartTimer.restart();
}
+28 -13
View File
@@ -402,20 +402,35 @@ esac'
id: recProc
onExited: (code, status) => {
recTimer.stop();
const path = root.recordingPath;
// SIGINT gives exit code 2 (or 130 through a shell); both mean the
// user pressed stop and the file was finalised normally.
if (root.recordingPath !== "") {
StatusEvents.publish({
key: "capture-recording",
glyph: "\u{F044A}",
title: "Screen recording saved",
detail: root.recordingPath.split("/").pop(),
tone: "ok",
priority: StatusEvents.importantPriority,
actionId: "open-path",
actionData: root.recordingPath
});
Quickshell.execDetached(["sh", "-c", root._recDoneScript, "qs-capture", root.recordingPath]);
// user pressed stop and the file was finalised normally. Any other
// code means wf-recorder died or errored before finalising, so the
// file may be missing or truncated -- never report success then.
const clean = code === 2 || code === 130;
if (path !== "") {
if (clean) {
StatusEvents.publish({
key: "capture-recording",
glyph: "\u{F044A}",
title: "Screen recording saved",
detail: path.split("/").pop(),
tone: "ok",
priority: StatusEvents.importantPriority,
actionId: "open-path",
actionData: path
});
Quickshell.execDetached(["sh", "-c", root._recDoneScript, "qs-capture", path]);
} else {
StatusEvents.publish({
key: "capture-recording",
glyph: "\u{F044A}",
title: "Screen recording failed",
detail: "wf-recorder exited unexpectedly (code " + code + ")",
tone: "warn",
priority: StatusEvents.importantPriority
});
}
}
root.recordingPath = "";
root.recordingSeconds = 0;
+45 -6
View File
@@ -55,6 +55,18 @@ Singleton {
// installed. Distinct from an empty history.
property bool available: true
// query's `exited` and its stdout `streamFinished` are not guaranteed to
// fire in a particular order (the same signal-ordering hazard
// HomeAssistantConfig.qml's settle-both pattern guards against). These
// track which of the two have arrived for the query currently in flight
// so the result is only finalized once both are known -- otherwise a
// failed query's empty stdout could be parsed as "empty history" before
// the non-zero exit code is seen, or vice versa.
property bool _queryExited: false
property int _queryExitCode: 0
property bool _queryStdoutDone: false
property string _queryStdoutText: ""
// Wall-clock seconds at the moment `entries` was filled. Relative times are
// rendered against this rather than against a live clock, so a row's label
// cannot change while the user is reading it and nothing has to tick.
@@ -69,6 +81,8 @@ Singleton {
if (query.running)
return;
root.loading = true;
root._queryExited = false;
root._queryStdoutDone = false;
query.running = true;
}
@@ -146,16 +160,18 @@ Singleton {
command: ["sqlite3", "-readonly", "-json", root.dbPath, root.sql]
stdout: StdioCollector {
onStreamFinished: root._parse(this.text)
onStreamFinished: {
root._queryStdoutDone = true;
root._queryStdoutText = this.text;
root._settleQuery();
}
}
onExited: exitCode => {
root.loading = false;
if (exitCode !== 0) {
root.available = false;
root.entries = [];
root.refreshed();
}
root._queryExited = true;
root._queryExitCode = exitCode;
root._settleQuery();
}
}
@@ -169,6 +185,29 @@ Singleton {
}
}
// Called from both query.onExited and its stdout streamFinished. Only
// finalizes once both signals have arrived, since their firing order is
// not guaranteed -- see the _queryExited / _queryStdoutDone comment
// above. A non-zero exit always means the history is unavailable,
// regardless of what (if anything) stdout produced; only a clean exit
// reaches _parse, where empty stdout is legitimately "no history yet".
function _settleQuery(): void {
if (!root._queryExited || !root._queryStdoutDone)
return;
const exitCode = root._queryExitCode;
const text = root._queryStdoutText;
root._queryExited = false;
root._queryStdoutDone = false;
if (exitCode !== 0) {
root.available = false;
root.entries = [];
root.refreshed();
return;
}
root._parse(text);
}
function _parse(text: string): void {
root.available = true;
root.queriedAt = Date.now() / 1000;
+90 -45
View File
@@ -34,13 +34,23 @@ Singleton {
// for the Prism gradient; Theme owns which colours, this owns the form the
// compositor wants them in.
function hyprColor(value: var): string {
// Qt gives "#rrggbb"; Hyprland wants rgba(rrggbbaa).
return "rgba(" + String(value).replace("#", "").slice(0, 6) + "ee)";
// Qt gives "#rrggbb" -- or "#aarrggbb" if the colour ever carries an
// alpha channel. slice(-6) keeps the trailing rrggbb either way;
// slice(0, 6) would instead grab "aarrgg" out of an 8-digit string and
// call it RGB. Hyprland wants rgba(rrggbbaa).
return "rgba(" + String(value).replace("#", "").slice(-6) + "ee)";
}
readonly property string accentBorderStart: root.hyprColor(Theme.accent)
readonly property string accentBorderEnd: root.hyprColor(Theme.accentSecondary)
property string lastError: ""
// What the last push actually sent, so apply() can tell an accent-only
// change apart from a scheme change and skip the steps that do not depend
// on whichever did not move. Their starting values do not matter: the
// first apply() always runs with force set, which ignores both.
property bool appliedDark: false
property string appliedAccentName: ""
// Applied one command at a time: Process runs a single command, and several
// of these are separate programs.
property var pending: []
@@ -75,7 +85,7 @@ Singleton {
Timer {
id: settle
interval: 1200
onTriggered: root.apply()
onTriggered: root.apply(true)
}
Connections {
@@ -86,57 +96,92 @@ Singleton {
Timer {
id: coalesce
interval: 250
onTriggered: root.apply()
onTriggered: root.apply(false)
}
function apply(): void {
// `force` pushes every step regardless of what moved -- startup needs
// that, because gsettings and the compositor keep their own state and
// have no way to know a previous Quickshell session already told them the
// answer. Everywhere else this reacts to ANY preference changing (see
// onRevisionChanged above), so most calls have nothing to do with either
// scheme or accent; only the steps whose inputs actually moved since the
// last push run, which keeps sampling accent swatches from also rewriting
// gsettings and re-running the whole app-theming script on every sample.
function apply(force: bool): void {
root.lastError = "";
const scheme = root.dark ? "prefer-dark" : "prefer-light";
const accentName = DesktopPreferences.get("accentName") || "blue";
const schemeChanged = force || root.dark !== root.appliedDark;
const accentChanged = force || accentName !== root.appliedAccentName;
// adw-gtk3, not Adwaita. This is the bug that made dark mode look
// broken while light mode looked fine:
//
// Neither "Adwaita" nor "Adwaita-dark" is an installed theme on Fedora
// 44 -- only adw-gtk3 and adw-gtk3-dark are. Naming a theme that does
// not exist makes GTK fall back to its built-in default, which is
// LIGHT. So asking for light accidentally worked, asking for dark
// silently produced light, and applications that take their cue from
// the GTK theme rather than the portal -- Chromium and Electron, when
// built against GTK -- stayed light no matter what the portal said.
//
// gtk-theme-contract asserts these names are actually installed,
// because the failure mode is silent in exactly this way.
const gtkTheme = root.dark ? "adw-gtk3-dark" : "adw-gtk3";
if (!schemeChanged && !accentChanged)
return;
const commands = [
["gsettings", "set", "org.gnome.desktop.interface", "color-scheme", scheme],
["gsettings", "set", "org.gnome.desktop.interface", "gtk-theme", gtkTheme]
];
const commands = [];
// Unfocused window borders need scheme-relative contrast, and stay this
// service's to own. The FOCUSED border is the accent role and belongs to
// the theme -- see modules/settings/README.md -- so it is written from
// the chosen accent rather than from the scheme.
//
// Both are pushed together because both change when the scheme flips:
// each accent carries a separate pair for light and dark, so switching
// schemes must restate the focused border too, not only the neutral one.
commands.push(["hyprctl", "eval",
`hl.config({ general = { col = { inactive_border = "${root.inactiveBorder}" } } })`]);
if (schemeChanged) {
const scheme = root.dark ? "prefer-dark" : "prefer-light";
// A two-stop gradient at the shipped angle. Written as a Lua TABLE: the
// string form of a gradient carries only one stop, and passing
// "rgba(a) rgba(b) 115deg" as a string is accepted and silently keeps
// the previous value.
commands.push(["hyprctl", "eval",
`hl.config({ general = { col = { active_border = { colors = { "${root.accentBorderStart}", "${root.accentBorderEnd}" }, angle = 115 } } } })`]);
// adw-gtk3, not Adwaita. This is the bug that made dark mode look
// broken while light mode looked fine:
//
// Neither "Adwaita" nor "Adwaita-dark" is an installed theme on Fedora
// 44 -- only adw-gtk3 and adw-gtk3-dark are. Naming a theme that does
// not exist makes GTK fall back to its built-in default, which is
// LIGHT. So asking for light accidentally worked, asking for dark
// silently produced light, and applications that take their cue from
// the GTK theme rather than the portal -- Chromium and Electron, when
// built against GTK -- stayed light no matter what the portal said.
//
// gtk-theme-contract asserts these names are actually installed,
// because the failure mode is silent in exactly this way.
const gtkTheme = root.dark ? "adw-gtk3-dark" : "adw-gtk3";
// Applications that predate org.freedesktop.appearance and carry their
// own palettes -- terminals, chiefly. Everything that reads the portal
// (GTK4, Qt6, Chromium, Electron) is already handled by the gsettings
// write above and needs nothing here.
commands.push([root.appThemePath, root.dark ? "dark" : "light"]);
commands.push(["gsettings", "set", "org.gnome.desktop.interface", "color-scheme", scheme]);
commands.push(["gsettings", "set", "org.gnome.desktop.interface", "gtk-theme", gtkTheme]);
// Unfocused window borders need scheme-relative contrast, and stay
// this service's to own. The FOCUSED border below is the accent
// role and belongs to the theme -- see modules/settings/README.md.
// Unlike that role, this one does not depend on the accent, so an
// accent-only change never needs to restate it.
commands.push(["hyprctl", "eval",
`hl.config({ general = { col = { inactive_border = "${root.inactiveBorder}" } } })`]);
}
if (schemeChanged || accentChanged) {
// Each accent carries a separate pair for light and dark, so this
// runs on either kind of change: a scheme flip restates the same
// accent's other pair, and an accent change restates the same
// scheme's other colours.
//
// A two-stop gradient at the shipped angle. Written as a Lua TABLE:
// the string form of a gradient carries only one stop, and passing
// "rgba(a) rgba(b) 115deg" as a string is accepted and silently
// keeps the previous value. Multi-stop must be the table form --
// built by the same serialiseValue() SystemSettings uses for every
// other gradient, rather than a second hand-rolled copy of that
// escaping here.
const activeBorder = SystemSettings.serialiseValue({
colors: [root.accentBorderStart, root.accentBorderEnd],
angle: 115
});
commands.push(["hyprctl", "eval",
`hl.config({ general = { col = { active_border = ${activeBorder} } } })`]);
// Applications that predate org.freedesktop.appearance and carry
// their own palettes -- terminals, chiefly. Everything that reads
// the portal (GTK4, Qt6, Chromium, Electron) is already handled by
// the gsettings write above and needs nothing here. The accent is
// passed through too: kitty's border and the hyprlock fallback
// template are also the accent role, not just the scheme -- so
// this still has to run on an accent-only change, even though the
// scheme-relative work it also does is then redundant.
commands.push([root.appThemePath, root.dark ? "dark" : "light", accentName]);
}
root.appliedDark = root.dark;
root.appliedAccentName = accentName;
root.enqueue(commands);
}
@@ -146,4 +146,11 @@ Singleton {
onWifiDeviceChanged: root.syncScanners()
onWifiEnabledChanged: root.syncScanners()
onAdapterChanged: root.syncScanners()
// adapter.enabled has no property on root to bind onXChanged to, so it
// needs its own Connections -- the Bluetooth equivalent of onWifiEnabledChanged.
Connections {
target: root.adapter
function onEnabledChanged(): void { root.syncScanners(); }
}
}
+2 -8
View File
@@ -95,7 +95,6 @@ Singleton {
root.revertWithMessage("The display rejected that change and Panama restored the previous setting.");
return;
}
verifyTimer.attempts = 0;
verifyTimer.ticks = 0;
verifyTimer.restart();
}
@@ -107,7 +106,6 @@ Singleton {
// Exit status is advisory only. Hyprland's Lua bridge can report
// success without applying a value, so exact readback decides.
root.revertVerificationActive = true;
revertVerifyTimer.attempts = 0;
revertVerifyTimer.ticks = 0;
revertVerifyTimer.restart();
}
@@ -542,7 +540,6 @@ Singleton {
Timer {
id: verifyTimer
property int attempts: 0
property int ticks: 0
interval: 120
repeat: true
@@ -552,14 +549,12 @@ Singleton {
root.verificationTimedOut();
return;
}
if (root.refresh())
attempts++;
root.refresh();
}
}
Timer {
id: revertVerifyTimer
property int attempts: 0
property int ticks: 0
interval: 120
repeat: true
@@ -569,8 +564,7 @@ Singleton {
root.revertVerificationTimedOut();
return;
}
if (root.refresh())
attempts++;
root.refresh();
}
}
@@ -40,6 +40,12 @@ Singleton {
root.searching = false;
if (exitCode !== 0)
root.lastError = "Could not reach the location service.";
// Typing kept going while this fetch was in flight -- rather than
// leaving the newer query stranded until another keystroke, go
// fetch it now. run() no-ops if pending is now too short.
if (root.pending !== root.lastQuery)
root.run();
}
}
+14 -1
View File
@@ -95,7 +95,16 @@ Singleton {
property string payload: ""
onStarted: copyProcess.write(copyProcess.payload)
stdinEnabled: true
onStarted: {
copyProcess.write(copyProcess.payload);
// wl-copy reads stdin until EOF before it exits; leaving the
// channel open (Process.write alone never closes it) would hang
// it forever waiting for more input. Disabling stdin closes the
// write side -- see HomeAssistantConfig.qml's writeProc for the
// same stdinEnabled pattern.
copyProcess.stdinEnabled = false;
}
onExited: (exitCode, exitStatus) => {
root.lastCopyResult = exitCode === 0
? "Report copied."
@@ -339,6 +348,10 @@ Singleton {
return false;
copyProcess.payload = JSON.stringify(root.snapshot, null, 2);
root.lastCopyResult = "";
// Re-arm stdin: the previous run closed it (see copyProcess.onStarted)
// and a disabled channel stays closed even after being set back to
// true mid-run, so each new run needs it explicitly re-enabled.
copyProcess.stdinEnabled = true;
copyProcess.exec(["wl-copy"]);
return true;
}
+37 -3
View File
@@ -25,6 +25,16 @@ Singleton {
property string actionKind: ""
property string actionPath: ""
// actionProc's `exited` and its stdout `streamFinished` are not guaranteed
// to fire in a particular order (same hazard HomeAssistantConfig.qml's
// settle-both pattern guards against). These track which of the two have
// been observed for the action currently in flight so finishAction() is
// only ever called once both have arrived, with the real stdout JSON as
// the authoritative result.
property bool actionExited: false
property bool actionStdoutDone: false
property string actionStdoutText: ""
readonly property var preferredPhone: {
const phones = root.devices.filter(device => device.type === "phone" && device.paired);
return phones.find(device => device.reachable) ?? phones[0] ?? null;
@@ -66,6 +76,8 @@ Singleton {
root.transferActive = true;
root.transferFileName = path.split("/").pop();
root.transferDeviceName = root.preferredPhone.name;
root.actionExited = false;
root.actionStdoutDone = false;
actionProc.command = [root.helperPath, "send-file", root.preferredPhone.id, path];
actionProc.running = true;
}
@@ -83,6 +95,8 @@ Singleton {
return;
root.actionKind = kind;
root.actionPath = "";
root.actionExited = false;
root.actionStdoutDone = false;
actionProc.command = [root.helperPath, command, root.preferredPhone.id];
actionProc.running = true;
}
@@ -95,6 +109,22 @@ Singleton {
root.transferActive = false;
root.transferFileName = "";
root.transferDeviceName = "";
root.actionExited = false;
root.actionStdoutDone = false;
}
// Called from both actionProc.onExited and its stdout streamFinished.
// Only finalizes once both signals have arrived for the in-flight action,
// since their firing order is not guaranteed -- see the actionExited /
// actionStdoutDone comment above.
function settleAction(): void {
if (root.actionKind === "")
return;
if (!root.actionExited || !root.actionStdoutDone)
return;
root.actionExited = false;
root.actionStdoutDone = false;
root.finishAction(root.actionStdoutText);
}
function finishAction(text: string): void {
@@ -198,11 +228,15 @@ Singleton {
Process {
id: actionProc
stdout: StdioCollector {
onStreamFinished: root.finishAction(this.text)
onStreamFinished: {
root.actionStdoutDone = true;
root.actionStdoutText = this.text;
root.settleAction();
}
}
onExited: (code, status) => {
if (root.actionKind !== "")
root.finishAction("");
root.actionExited = true;
root.settleAction();
}
}
+64 -7
View File
@@ -4,7 +4,7 @@ pragma Singleton
// The freedesktop notification server, plus the two lists the UI renders:
//
// popups — what Toasts.qml is currently showing (transient, timed)
// history — GNOME's message tray, what NotificationCenter.qml shows
// history — GNOME's message tray, what NotificationList.qml shows
//
// A notification lives exactly as long as `tracked` is true, so history holds
// the *live* objects rather than copies: that keeps actions and inline replies
@@ -80,6 +80,21 @@ Singleton {
return at ? Qt.formatDateTime(at, Settings.use24Hour ? "HH:mm" : "h:mm AP") : "";
}
// Freedesktop timeout resolution, shared by the toast countdown (Toast.qml)
// and the no-display expiry a transient notification gets while Do Not
// Disturb is on (below). Critical urgency and an explicit expireTimeout
// override policy; -1 ("server decides") falls back to it. 0 means "never
// auto-expire" per spec.
function notificationTimeoutMs(notification: var): int {
if (notification.urgency === NotificationUrgency.Critical)
return Settings.notificationTimeoutCriticalMs;
if (notification.expireTimeout === 0)
return 0;
if (notification.expireTimeout > 0)
return Math.round(notification.expireTimeout * 1000);
return Settings.notificationTimeoutMs;
}
readonly property bool hasNotifications: root.history.length > 0
function notificationAppId(notification: var): string {
@@ -156,7 +171,7 @@ Singleton {
}
// history grouped by app, in most-recent-app-first order — the shape
// NotificationCenter.qml renders directly.
// NotificationList.qml renders directly.
readonly property var groups: {
const out = [];
const byApp = {};
@@ -219,8 +234,44 @@ Singleton {
root.unreadCount += 1;
}
if (!root.doNotDisturb)
if (!root.doNotDisturb) {
root.popups = [notification].concat(root.popups);
} else if (notification.transient) {
// Never shown, and (being transient) never filed in history
// either — nothing will otherwise dismiss() it, so schedule
// the same release its popup timeout would have given it.
root.scheduleTransientExpiry(notification);
}
}
// Runs a DND-hidden transient notification through the same lifetime it
// would have gotten as a visible popup (Toast.qml's countdown), just
// without ever showing it, so it still gets released instead of staying
// tracked forever.
function scheduleTransientExpiry(notification: var): void {
const ms = root.notificationTimeoutMs(notification);
if (ms <= 0)
return;
const timer = Qt.createQmlObject("import QtQuick; Timer { repeat: false }", root);
timer.interval = ms;
timer.triggered.connect(() => {
timer.destroy();
root.releaseTransient(notification);
});
// Closed some other way first (app-side close, dismissAll()) — cancel
// the pending timer instead of firing a stale dismiss() later.
notification.closed.connect(() => timer.destroy());
timer.running = true;
}
// Transient notifications are never filed in history, so nothing else
// holds a reference once their lifetime ends — release tracked state
// directly. Shared by the DND-hidden expiry above and dismissAll() below.
function releaseTransient(n: var): void {
if (n.transient)
n.dismiss();
}
// ── Mutation ────────────────────────────────────────────────────────────
@@ -243,10 +294,7 @@ Singleton {
if (next.length === root.popups.length)
return;
root.popups = next;
// Transients were never in history, so nothing else holds them.
if (n.transient)
n.dismiss();
root.releaseTransient(n);
}
function dismiss(n: Notification): void {
@@ -257,10 +305,19 @@ Singleton {
// Copy first: dismiss() re-enters through forget() and rewrites both
// lists while we iterate.
const all = root.history.slice();
// Transient notifications are never filed in history — whether
// currently shown as a popup or hidden by Do Not Disturb with a
// pending scheduleTransientExpiry() timer — so releasing them needs
// its own pass over the server's full tracked set.
const transients = root.active.values.filter(n => n.transient);
root.history = [];
root.popups = [];
for (const n of all)
n.dismiss();
for (const n of transients)
root.releaseTransient(n);
root.unreadCount = 0;
}
@@ -26,6 +26,7 @@ Singleton {
// entry here still appears in results and routes to Home rather than being
// dropped, so adding a group can never make a setting unreachable.
readonly property var groupPages: ({
"appearance": "appearance",
"clock": "appearance",
"vitals": "appearance",
"typography": "appearance",
@@ -35,13 +35,32 @@ Singleton {
function setEventSounds(enabled: bool): void {
root.eventSounds = enabled;
eventWrite.command = ["gsettings", "set", "org.gnome.desktop.sound", "event-sounds", String(enabled)];
eventWrite.running = true;
root._writeEventSounds();
}
function setInputFeedback(enabled: bool): void {
root.inputFeedback = enabled;
inputWrite.command = ["gsettings", "set", "org.gnome.desktop.sound", "input-feedback-sounds", String(enabled)];
root._writeInputFeedback();
}
// Assigning `running = true` to a Process that is already running is a
// no-op, not a queue -- so a toggle that lands mid-write would otherwise
// be dropped silently. eventWrite.onExited re-checks root.eventSounds
// against what was actually written and calls this again if they still
// disagree.
function _writeEventSounds(): void {
if (eventWrite.running)
return;
eventWrite.writtenValue = root.eventSounds;
eventWrite.command = ["gsettings", "set", "org.gnome.desktop.sound", "event-sounds", String(root.eventSounds)];
eventWrite.running = true;
}
function _writeInputFeedback(): void {
if (inputWrite.running)
return;
inputWrite.writtenValue = root.inputFeedback;
inputWrite.command = ["gsettings", "set", "org.gnome.desktop.sound", "input-feedback-sounds", String(root.inputFeedback)];
inputWrite.running = true;
}
@@ -71,6 +90,7 @@ Singleton {
Process {
id: eventWrite
property bool writtenValue: true
onExited: (code, status) => {
if (code !== 0) {
root.lastError = "Event sound preferences could not be changed.";
@@ -78,11 +98,14 @@ Singleton {
} else {
root.lastError = "";
}
if (root.eventSounds !== eventWrite.writtenValue)
root._writeEventSounds();
}
}
Process {
id: inputWrite
property bool writtenValue: false
onExited: (code, status) => {
if (code !== 0) {
root.lastError = "Input feedback preferences could not be changed.";
@@ -90,6 +113,8 @@ Singleton {
} else {
root.lastError = "";
}
if (root.inputFeedback !== inputWrite.writtenValue)
root._writeInputFeedback();
}
}
@@ -46,9 +46,25 @@ Singleton {
list.running = true;
}
// A picker click while a change is still applying is queued rather than
// fired directly: assigning `running = true` to an already-running
// Process is a no-op, so a second set() here would otherwise be silently
// dropped and apply.onExited would then adopt the second value as if it
// had actually been applied. requestedValue always holds the latest
// request; apply.onExited re-fires with it once the in-flight apply
// settles, mirroring Brightness.qml's pending-write queue.
property string requestedValue: ""
function set(value: string): void {
if (value === root.current)
return;
root.requestedValue = value;
if (!apply.running)
root._applyValue(value);
}
function _applyValue(value: string): void {
root.requestedValue = "";
apply.command = [root.helperPath, "set", value];
apply.pendingValue = value;
apply.running = true;
@@ -94,6 +110,16 @@ Singleton {
} else {
root.lastError = "The system did not accept that language. It may have needed a password.";
}
// A set() call that arrived while this apply was running only
// queued itself in requestedValue (see the comment above). Fire
// it now if it still names something other than what was just
// applied, so the UI never settles on a locale the system was
// never actually asked for.
if (root.requestedValue !== "" && root.requestedValue !== apply.pendingValue)
root._applyValue(root.requestedValue);
else
root.requestedValue = "";
}
}
}
@@ -103,7 +103,12 @@ Item {
cursorShape: Qt.PointingHandCursor
function apply(mouseX) {
const ratio = Math.max(0, Math.min(1, (mouseX + 8) / track.width));
// mouseX is local to this MouseArea, whose anchors.margins: -8
// pushes its own origin 8px before the track's left edge. So
// MouseArea-local x=8 is track-local x=0 -- subtract the
// margin to land back in the track's own coordinate space
// before turning it into a fraction.
const ratio = Math.max(0, Math.min(1, (mouseX - 8) / track.width));
root.moved(ratio);
}
+8 -2
View File
@@ -49,10 +49,16 @@ gsettings set org.gnome.desktop.session idle-delay 0 2>/dev/null || true
# still run: a missing optional package should not stop the dotfiles being
# linked. The summary at the end is what decides whether the install worked,
# because a failure scrolled past twenty minutes ago is a failure nobody saw.
#
# Explicit order, not glob order: change-settings runs `vicinae theme set`,
# which needs both vicinae itself (installed by install-packages) and the
# theme files it selects among (symlinked into place by link-dotfiles). New
# scripts must be added here explicitly, or they will not run at all.
STAGES=(install-packages link-dotfiles change-settings link-vicinae-scripts)
failed=()
for script in "$PANAMA_PATH"/setup/scripts/*; do
for stage in "${STAGES[@]}"; do
script="$PANAMA_PATH/setup/scripts/$stage"
[[ -x "$script" ]] || continue
stage="$(basename "$script")"
printf '\n=== %s ===\n' "$stage"
if ! "$script"; then
failed+=("$stage")
+1
View File
@@ -32,6 +32,7 @@ mokutil
mozilla-openh264
nautilus-extensions
nautilus-python
nextcloud-client
openssl-devel
opus-devel
python3-dnf-plugin-versionlock
+2
View File
@@ -2,3 +2,5 @@ org.gtk.Gtk3theme.adw-gtk3-dark
io.github.Foldex.AdwSteamGtk
com.bitwarden.desktop
app.bluebubbles.BlueBubbles
org.mozilla.thunderbird_esr
io.missioncenter.MissionCenter
+1
View File
@@ -8,6 +8,7 @@ gpu-screen-recorder
grim
grimblast
gtk-update-icon-cache
helium-browser-bin
hypridle
hyprland
hyprland-guiutils
+48 -14
View File
@@ -1,5 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
# --- Helper functions ---
log() { echo -e "\033[1;34m[INFO]\033[0m $*"; }
exists() { command -v "$1" >/dev/null 2>&1; }
@@ -10,33 +12,37 @@ LOCAL_BIN_PATH="$HOME/.local/bin"
echo -e "\n--- Installing Repositories ---"
log "Installing RPM Fusion Free and Nonfree Repositories"
sudo dnf install -y https://mirrors.rpmfusion.org/free/fedora/rpmfusion-free-release-$(rpm -E %fedora).noarch.rpm https://mirrors.rpmfusion.org/nonfree/fedora/rpmfusion-nonfree-release-$(rpm -E %fedora).noarch.rpm > /dev/null 2>&1
sudo dnf install -y https://mirrors.rpmfusion.org/free/fedora/rpmfusion-free-release-$(rpm -E %fedora).noarch.rpm https://mirrors.rpmfusion.org/nonfree/fedora/rpmfusion-nonfree-release-$(rpm -E %fedora).noarch.rpm > /dev/null
log "Enabling Fedora Cisco OpenH264 Repository"
sudo dnf config-manager setopt fedora-cisco-openh264.enabled=1
log "Installing RPM Fusion AppStream Metadata"
sudo dnf update @core -y > /dev/null 2>&1
sudo dnf install -y rpmfusion-\*-appstream-data > /dev/null 2>&1
sudo dnf update @core -y > /dev/null
sudo dnf install -y rpmfusion-\*-appstream-data > /dev/null
log "Installing Terra Repository"
sudo dnf install -y --nogpgcheck --repofrompath 'terra,https://repos.fyralabs.com/terra$releasever' terra-release > /dev/null 2>&1
sudo dnf install -y --nogpgcheck --repofrompath 'terra,https://repos.fyralabs.com/terra$releasever' terra-release > /dev/null
echo -e "\n--- Installing relevant packages ---"
log "Updating all packages. This may take a while"
sudo dnf update -y --refresh > /dev/null 2>&1
sudo dnf update -y --refresh > /dev/null
log "Updating core, multimedia, and sound-and-video groups"
# A trailing `&& sync` here previously meant a failing groupupdate was exempt
# from set -e (bash does not apply -e to the left side of a && list), so the
# failure went unreported. Sync unconditionally on its own line instead.
sudo dnf4 groupupdate -y 'core' 'multimedia' 'sound-and-video' \
--setop='install_weak_deps=False' \
--exclude='PackageKit-gstreamer-plugin' \
--allowerasing && sync > /dev/null 2>&1
--allowerasing > /dev/null
sync
log "Swapping ffmpeg-free for ffmpeg"
sudo dnf swap -y 'ffmpeg-free' 'ffmpeg' --allowerasing > /dev/null 2>&1
sudo dnf swap -y 'ffmpeg-free' 'ffmpeg' --allowerasing > /dev/null
log "Swapping mesa-va-drivers for mesa-va-drivers-freeworld"
sudo dnf swap -y mesa-va-drivers mesa-va-drivers-freeworld > /dev/null 2>&1
sudo dnf swap -y mesa-va-drivers mesa-va-drivers-freeworld > /dev/null
log "Upgrading Multimedia group with optional packages"
sudo dnf4 group upgrade -y --with-optional Multimedia > /dev/null 2>&1
sudo dnf4 group upgrade -y --with-optional Multimedia > /dev/null
log "Installing GStreamer plugins (bad, good, base)"
sudo dnf install -y gstreamer1-plugins-{bad-\*,good-\*,base} \
--exclude=gstreamer1-plugins-bad-free-devel > /dev/null 2>&1
--exclude=gstreamer1-plugins-bad-free-devel > /dev/null
# --- Install all initial packages ---
PACKAGES_FILE="$PANAMA_PATH/setup/packages/initial-packages"
@@ -45,12 +51,25 @@ if [[ -f "$PACKAGES_FILE" ]]; then
log "Installing Initial Packages"
echo -e "Includes the following packages:"
echo -e "$(<"$PACKAGES_FILE")"
sudo dnf install -y "$INITIAL_PACKAGES" > /dev/null 2>&1
sudo dnf install -y $INITIAL_PACKAGES > /dev/null
log "Initial packages installed!"
else
log "Package list was not in specified path: $PACKAGES_FILE"
fi
# --- Install Desktop Packages ---
DESKTOP_FILE="$PANAMA_PATH/setup/packages/desktop-packages"
if [[ -f "$DESKTOP_FILE" ]]; then
DESKTOP_PACKAGES=$(tr "\n" " " <"$DESKTOP_FILE")
log "Installing Desktop Packages"
echo -e "Includes the following packages:"
echo -e "$(<"$DESKTOP_FILE")"
sudo dnf install -y $DESKTOP_PACKAGES > /dev/null
log "Desktop packages installed!"
else
log "Package list was not in specified path: $DESKTOP_FILE"
fi
# --- Install oh-my-posh if not already installed. ---
mkdir -p "$LOCAL_BIN_PATH"
if [[ -x "$LOCAL_BIN_PATH/oh-my-posh" ]]; then
@@ -67,7 +86,7 @@ if [[ -f "$DEV_FILE" ]]; then
log "Installing Development Packages. Mostly for Neovim."
echo -e "Includes the following packages:"
echo -e "$(<"$DEV_FILE")"
sudo dnf install -y $DEV_PACKAGES > /dev/null 2>&1
sudo dnf install -y $DEV_PACKAGES > /dev/null
log "Development packages installed!"
else
log "Package list was not in specified path: $DEV_FILE"
@@ -78,12 +97,12 @@ fi
HYPR_FILE="$PANAMA_PATH/setup/packages/hyprland-packages"
if [[ -f "$HYPR_FILE" ]]; then
log "Enabling Hyprland COPR"
sudo dnf copr enable -y lionheartp/Hyprland > /dev/null 2>&1
sudo dnf copr enable -y lionheartp/Hyprland > /dev/null
HYPR_PACKAGES=$(tr "\n" " " <"$HYPR_FILE")
log "Installing Hyprland desktop packages"
echo -e "Includes the following packages:"
echo -e "$(<"$HYPR_FILE")"
sudo dnf install -y --setopt=install_weak_deps=False $HYPR_PACKAGES > /dev/null 2>&1
sudo dnf install -y --setopt=install_weak_deps=False $HYPR_PACKAGES > /dev/null
log "Hyprland packages installed!"
else
log "Package list was not in specified path: $HYPR_FILE"
@@ -96,3 +115,18 @@ else
log "Installing Bun via curl..."
curl -fsSL https://bun.sh/install | bash > /dev/null 2>&1
fi
# --- Install Flatpak Packages ---
FLATPAK_FILE="$PANAMA_PATH/setup/packages/flatpak-packages"
if [[ -f "$FLATPAK_FILE" ]]; then
FLATPAK_PACKAGES=$(tr "\n" " " <"$FLATPAK_FILE")
log "Adding Flathub remote"
sudo flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo > /dev/null
log "Installing Flatpak Packages"
echo -e "Includes the following packages:"
echo -e "$(<"$FLATPAK_FILE")"
sudo flatpak install -y flathub $FLATPAK_PACKAGES > /dev/null
log "Flatpak packages installed!"
else
log "Package list was not in specified path: $FLATPAK_FILE"
fi
+6
View File
@@ -63,6 +63,12 @@ for dir in "${dirs[@]}"; do
if [ -d "$CONFIG/$dir" ]; then
mv "$CONFIG/$dir" "$PANAMA_OLD/$dir"
log "Moved existing $dir config to $PANAMA_OLD/$dir"
# A regular file at this path (not a directory, not a symlink) is missed by
# the guards above, so `ln -s` below would fail with "File exists" -- back
# it up the same way.
elif [ -f "$CONFIG/$dir" ]; then
mv "$CONFIG/$dir" "$PANAMA_OLD/$dir"
log "Moved existing $dir file to $PANAMA_OLD/$dir"
fi
# Create symlink
@@ -69,7 +69,7 @@ assert_schema_entry() {
}
[[ -x "$helper" ]] || fail 'panama-lock helper is missing or not executable'
for key in lockBackgroundMode lockBlurLevel lockShowClock lockShowDate lockShowUser lockFadeOnEmpty; do
for key in lockBackgroundMode lockBlurLevel lockShowClock lockShowDate lockShowUser lockFadeOnEmpty accentName; do
assert_schema_entry "$key"
done
@@ -112,6 +112,25 @@ if rg -q '^label \{' "$generated"; then
fail 'hidden clock, date, and user labels were still generated'
fi
# ── The focus ring follows the chosen accent, not just the scheme ───────────
# A pinned blue literal only ever proves the DEFAULT accent renders correctly,
# not that the helper actually reads accentName. Orchid is picked because its
# hex is nowhere close to blue's in either scheme, so a helper that quietly
# ignored accentName and kept emitting blue would be caught here.
write_settings '{"accentName":"orchid","colorScheme":"dark"}'
run_helper generate
rg -Fq 'outer_color = rgba(192, 153, 255, 0.9)' "$generated" || fail 'dark orchid accent did not drive the focus ring'
rg -Fq 'check_color = rgba(192, 153, 255, 1.0)' "$generated" || fail 'dark orchid accent did not drive the success colour'
write_settings '{"accentName":"orchid","colorScheme":"light"}'
run_helper generate
rg -Fq 'outer_color = rgba(120, 71, 189, 0.9)' "$generated" || fail 'light orchid accent did not drive the focus ring'
rg -Fq 'check_color = rgba(120, 71, 189, 1.0)' "$generated" || fail 'light orchid accent did not drive the success colour'
write_settings '{"accentName":"not-a-real-accent","colorScheme":"dark"}'
run_helper generate
rg -Fq 'outer_color = rgba(130, 170, 255, 0.9)' "$generated" || fail 'an unknown accent name did not fall back to blue'
write_settings '{
"lockBackgroundMode":"wallpaper",
"wallpaperMode":"per-monitor",
+1 -1
View File
@@ -27,7 +27,7 @@ printf ' <%s>' "$@" >>"$OSD_TEST_LOG"
printf '\n' >>"$OSD_TEST_LOG"
if [[ " $* " == *" -m "* && " $* " != *" set "* ]]; then
[[ ${BACKLIGHT_AVAILABLE:-true} == true ]] || exit 1
printf '%s\n' "${BRIGHTNESS_OUTPUT:-intel_backlight,backlight,500,1000,50%}"
printf '%s\n' "${BRIGHTNESS_OUTPUT:-intel_backlight,backlight,500,50%,1000}"
fi
SH
@@ -109,6 +109,7 @@ run_doctor() {
PANAMA_DOCTOR_STATE_HOME="$state_home" \
PANAMA_DOCTOR_RUNTIME_DIR="$runtime_dir" \
PANAMA_DOCTOR_PATH="$bin_dir" \
PANAMA_DOCTOR_SCRIPTS_DIR="$bin_dir" \
PANAMA_DOCTOR_TIMEOUT="${PANAMA_DOCTOR_TIMEOUT:-0.2}" \
/usr/bin/python3 "$doctor" "$@"
}
@@ -375,6 +376,7 @@ run_repair() {
PANAMA_DOCTOR_STATE_HOME="$state_home" \
PANAMA_DOCTOR_RUNTIME_DIR="$runtime_dir" \
PANAMA_DOCTOR_PATH="$bin_dir:/usr/bin" \
PANAMA_DOCTOR_SCRIPTS_DIR="$bin_dir" \
PANAMA_DOCTOR_TIMEOUT=0.2 \
/usr/bin/python3 "$doctor" "$@"
}
@@ -2,7 +2,9 @@
# A setting has one schema-routed owner. A second page may mirror it only when
# this contract names the exact owner and mirror set. The same ownership rule
# keeps colour scheme propagation away from the focused Prism border.
# says who writes each half of the window border: ColorScheme.qml owns the
# neutral inactive role AND the accent-derived focused role, restating both
# together on every scheme or accent change.
set -euo pipefail
@@ -124,8 +126,28 @@ if dark.group(1) not in looks or light.group(1) not in looks:
raise SystemExit("Hyprland startup values disagree with the live scheme roles")
without_comments = re.sub(r"//.*", "", scheme)
if re.search(r"(?<![A-Za-z_])active_border\b", without_comments):
raise SystemExit("ColorScheme writes the focused border")
# The focused border is the accent role, and ColorScheme.qml owns it too:
# each named accent carries a separate pair per scheme, so a scheme change
# must restate the focused border, not just the neutral one, or a chosen
# accent goes stale the moment light/dark flips.
start = re.search(r'property string accentBorderStart:\s*root\.hyprColor\(Theme\.accent\)', scheme)
end = re.search(r'property string accentBorderEnd:\s*root\.hyprColor\(Theme\.accentSecondary\)', scheme)
if not start or not end:
raise SystemExit("ColorScheme does not derive the focused border from the chosen accent")
# Written as a Lua TABLE, not a string: the string form of a Hyprland gradient
# carries only one stop, so writing it that way is accepted and silently
# keeps whatever the previous accent left behind. Built through the same
# serialiseValue() SystemSettings.qml uses for every other gradient, rather
# than a second hand-rolled (and unescaped) copy of that table syntax here.
if 'SystemSettings.serialiseValue({' not in without_comments:
raise SystemExit("ColorScheme does not build the focused border through the shared gradient serialiser")
if 'colors: [root.accentBorderStart, root.accentBorderEnd]' not in without_comments:
raise SystemExit("ColorScheme does not derive the focused-border gradient from the chosen accent")
if 'active_border = ${activeBorder}' not in without_comments:
raise SystemExit("ColorScheme does not write the focused border as the serialised gradient table")
if 'inactive_border = "${root.inactiveBorder}"' not in scheme:
raise SystemExit("ColorScheme does not apply its effective inactive role")
PY
+29 -6
View File
@@ -26,20 +26,29 @@ 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'
# -e no -t -f NAME connection show: one name per line, no field to split, so
# a name containing ':' or '\\' (real nmcli would otherwise backslash-escape
# both) comes back byte-for-byte.
if [[ "\$*" == *"-f NAME connection show"* ]]; then
printf 'home net\n'
printf 'work-eap\n'
printf 'Cafe: Guest\n'
printf 'Wired connection 1\n'
exit 0
fi
name="\${@: -1}"
case "\$*" in
*connection.type*)
case "\$name" in
"home net"|"work-eap"|"Cafe: Guest") printf '802-11-wireless\n' ;;
"Wired connection 1") printf '802-3-ethernet\n' ;;
esac ;;
*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' ;;
"Cafe: Guest") printf 'Cafe: Guest\n' ;;
esac ;;
*802-11-wireless.hidden*) printf 'no\n' ;;
*802-11-wireless-security.psk*)
@@ -78,13 +87,20 @@ 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" ]] \
[[ "$(jq -r '.networks | length' <<<"$out")" == "3" ]] \
|| 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"
# A name containing a colon must survive intact: nmcli's terse mode would
# backslash-escape it (real nmcli escapes ':' and '\' in terse/-g output), and
# a naive colon-split parser truncates the name and misaligns the next field,
# dropping the network from the list entirely.
jq -e '.networks[] | select(.name == "Cafe: Guest")' >/dev/null <<<"$out" \
|| fail "a network name containing a colon was mangled or dropped: $out"
# ── The payload ─────────────────────────────────────────────────────────────
path="$(run qr 'home net' | jq -r .path)"
[[ -n "$path" && -e "$path" ]] || fail 'no image was produced'
@@ -123,4 +139,11 @@ out="$(run qr 'no-such-network')"
jq -e '.path == "" and .error != ""' >/dev/null <<<"$out" \
|| fail "an unknown network must be reported: $out"
# ── A name with a colon round-trips from list into qr ───────────────────────
# The name `list` emits must be exactly what `qr` needs to look the network
# back up; escaping it either direction breaks this lookup.
out="$(run qr 'Cafe: Guest')"
[[ "$(jq -r '.path' <<<"$out")" != "" ]] \
|| fail "a saved network name containing a colon could not be looked back up: $out"
printf 'wifi qr contract: PASS\n'