47 Commits
Author SHA1 Message Date
Gabriel Brown 3b01f1e020 Let the Dock choose an edge, choose its screens, and be dragged into order
Three things that were parked, and the reasons they were parked turned out to be
the useful part of doing them.

The Dock can sit on the left or the right as well as the bottom. Everything that
assumed the bottom edge is now asked which edge it is on: the anchors, the axis
that gets an implicit size, the sliver of input region that survives hiding, the
direction the body slides away in, and which side a tooltip opens towards. The
body was a Row and is a Grid, because one declaration then serves both
orientations -- Row and Column would each need their own children, and the
cross-axis anchors that centre items in a Row are the wrong axis in a Column.

Bottom is unchanged in every particular, and the settings default to it, so a
hot reload in the middle of this work left the running dock exactly where it
was.

One bug worth recording because static review would never have found it: a dock
spans the edge it lives on, which means anchoring BOTH ends of that edge. The
first side dock anchored top and left only, was free to collapse to its implicit
height, and came out one pixel tall. It parsed, it loaded, and it rendered
nothing. The contract measures the geometry rather than reading the source for
that reason, and was verified by putting the single-ended anchor back.

Per-screen is a list of names where empty means every screen, because a list is
what goes stale when a display is unplugged and "all" should not be spelled as
one. Turning off the last screen collapses to "all" rather than leaving no dock
anywhere and no obvious way back.

Pins can be dragged by a grip. The objection this file recorded for a long time
was real -- dragging inside a Flickable inside a scrolling page fails in a way
that reads as breakage -- and the answer is preventStealing on the grip, so the
page cannot claim a gesture that started there. The arrow buttons stay: they are
the keyboard-reachable path and a grip is not. The order is held locally during
the drag and written once on release, rather than rewriting settings.json for
every slot crossed.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-20 14:10:22 -04:00
Gabriel Brown 23141673a2 Hang the Control Center where every other popover hangs
Five surfaces sat 12 pixels under the bar -- the date menu, the activity panel,
notification toasts, signal glass and the clipboard, all using barGap * 2. The
Control Center sat at 2, through a constant of its own, which left the widest
surface in the shell hanging ten pixels higher than the date menu beside it.

That constant arrived with the original Control Center and carried no reason,
while barGap directly above it explains itself. The clipboard even cites
QuickSettings in a comment for how it derived its own margin, and still landed
on 12. It reads as an early value nothing else converged on rather than a
decision, which is why it is going rather than being documented and kept.

The contract that guarded it pinned the literal, and that same file already
records where pinning a literal led: it once asserted the buggy margin
expression, so the code and the test agreed and a 38-pixel gap was invisible to
both. Replacing one number with another would have repeated it. It now reads the
top margin out of the Control Center and out of the date menu and requires them
to match, so drift in either direction fails -- verified by moving each one in
turn and watching it break.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-20 13:41:12 -04:00
Gabriel Brown 495fb9b41b Give each online account its provider's icon
Four accounts distinguished only by a line of small grey text are four rows that
have to be read rather than recognised. GOA already knows what each one is.

It hands back a serialised GThemedIcon -- ". GThemedIcon goa-account-google
goa-account goa …" -- a preference-ordered fallback chain. The helper passes the
whole chain on rather than resolving it, because which of those names exists is
a property of the icon theme in use and not something a python script talking to
D-Bus should be deciding.

The page walks the chain and takes the first name the active theme actually has.
Both simpler readings were wrong and looked right: taking the first name blindly
assumes it resolves, and taking the last as a fallback assumes the most generic
name is the most likely to exist. On Adwaita the tails of these very chains --
"mail", "goa-symbolic" -- do not exist at all, so a miss would have drawn
nothing. Checked by asking Quickshell.iconPath directly, which returns empty for
a name the theme lacks; the fallback is avatar-default-symbolic, which is
present.

SettingsCard grew an optional icon for this. It is empty by default and the
header lays out exactly as before when unset, so no other card moves.

Last-sync is not here because there is nothing to show: GOA exposes no
sync-related property at all, on any of these accounts. Better to say so than to
invent a timestamp from when the page last refreshed.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-20 13:28:05 -04:00
Gabriel Brown 3b657e0a56 Make the resize keys do what the fingers expect
Audited against the real Forge configuration rather than its defaults: the
bindings are still in dconf, and the extension is still installed, so both the
keys and the code behind them could be read directly.

The port was faithful. Forge's resize() grows the window for a positive amount
in every direction -- the edge only decides which side moves -- so
"increase" meant grow and "decrease" meant shrink, and Y/O/I/U mapped to Wider
and Taller exactly as they should have.

What did not survive is direction. Forge resized one named EDGE: Y pulled the
left edge leftward, O pushed the right edge rightward, and the window opened
from the side you asked for. Hyprland resizes along an axis and lets the layout
pick the border, so eight distinct behaviours collapse onto four and the
direction is not expressible at all in dwindle. Correct on paper, wrong under
the hands: the keys that used to pull a window open from one side now push it
from the other, which reads as the pair being swapped.

So the sizes are inverted from Forge's naming on purpose. Y and O shrink, B and
M grow, I and U shorten, N and Comma lengthen. Faithfulness to a mapping nobody
can feel is worth less than keys that behave the way their owner reaches for
them, and the reason is written where the next person will find it.

Also fixes the Home Assistant accessories dropdown, which looked glitchy for a
reason that was not performance. The resting card hid the instant the section
opened -- a Column skips invisible children, so everything above snapped up
while the expanded grid was still sliding open underneath. It now collapses on
the same curve and duration as the section replacing it. This was the only
control with a resting and an expanded form that swap, which is why it was the
only one that looked wrong.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-20 13:10:17 -04:00
Gabriel Brown c148bae4ac Let Ethernet be switched off and on
Wi-Fi had a switch and wired did not, which left no way to take the cable down
without reaching for nmcli -- and this machine has two interfaces on one subnet,
so turning one off is a genuinely useful thing to be able to do.

Both halves are needed to turn it off. Disconnecting alone survives the session
but not a carrier event or a reboot, because NetworkManager brings an
autoconnecting device straight back; the switch sets the device's autoconnect
alongside it. The device property rather than the connection profile, so a
toggle here does not quietly rewrite a saved connection somebody expects to come
up at boot.

The first version built a trap door. It decided whether the switch was usable
from hasLink, which reads false while a device is merely disconnected even
though NetworkManager still reports the carrier as on -- so turning Ethernet off
made the switch disable itself, blame the cable, and offer no way back. A wired
device that exists can always be asked to come up; if there is really no cable
the attempt fails and says so, which is the honest failure. The word "off" is
used where the old text guessed "no cable", because nothing available here can
tell those apart.

Verified as a round trip against the real device, including that off stays off
through eight seconds rather than reconnecting a moment later, and that the
mechanism tested is the one the code uses -- the first test drove the connection
profile while the code drives the device, which are different things.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-20 12:13:49 -04:00
Gabriel Brown 4cbab01ae2 Show what has actually been installed
Automatic updates leave no other trace. The Flatpak that sat here as "1 update
available" installed itself at 00:14 this morning and nothing on the machine
would have said so.

Both sources are asked in their own machine-readable form and merged on time, so
the answer reads as one history rather than two lists to interleave by eye.

Two parsing traps worth recording next to the code. flatpak's --json prints
timestamps as "Aug 20 08:07:46" with no year in them, so the year is inferred
and a date that would land in the future is read as last year's. And dnf5's
start_time is epoch UTC while its own history table prints that same value as
though it were local -- checked against rpm, and the local rendering here is the
correct one.

The contract asserts entries are newest first, that none is dated in the future,
and that both sources parse; it was verified to fail by breaking the year
inference so every flatpak entry landed tomorrow.

Loaded on demand rather than with the page, because it reads both full
transaction logs.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-20 11:30:31 -04:00
Gabriel Brown de45f205ad Carry settings between machines by allow-list, not by stripping
panama-settings-backup already snapshots this machine so it can be put back
exactly as it was, arrangement and all. This is the other thing: an export meant
to travel, carrying the preferences that describe taste rather than hardware.

The export is an allow-list read from the preference schema rather than a
deny-list of things to remove. A key added later that happens to hold a token
cannot leak into a file somebody emails to themselves; being wrong in this
direction loses a setting, being wrong the other way publishes a secret. It
earned that immediately -- this machine's store holds an orphaned shadowOffset
from a setting that no longer exists anywhere in the source, and it was left
behind without anyone having to know about it.

Three settings stay: the display arrangement, which is keyed by output names
that mean nothing elsewhere; the last page opened, which is session noise; and
schemaVersion, which belongs to the store rather than to a person. Import is a
merge, so settings a file does not mention are left alone, and it is idempotent.

Two bugs made and caught here, in opposite directions. Validation missed 36
settings because "real" was spelled "float" and enums fell through entirely, so
an out-of-range or nonsense value would have been written straight into the
store. Correcting that then broke numeric enums -- vrrPolicy is an enum of 0..3
and the options were read with a regex that only matched quoted values, so those
settings had no known choices, were declared unverifiable and were refused:
valid settings dropped silently in transit.

The contract could not see the second one. It checked only that bad values are
refused, and when numeric enums were unreadable they never reached the bundle at
all, so every "did it arrive" assertion was satisfied by their absence. It now
requires the export to carry what it should as well as withhold what it should
not, and was verified to fail in both directions.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-20 11:02:42 -04:00
Gabriel Brown 52e2a83a78 Add an SSH Keys page, and refuse the one control that would lie
The page shows which keys exist, what the agent is holding, and the hosts this
machine has met, with a two-press forget for a host whose key has changed.

Nothing here reads private key material. Fingerprints and comments come from the
.pub file, and "does this key need a passphrase" is answered by asking
ssh-keygen to derive the public half with an empty one -- it succeeds for an
unencrypted key and fails for an encrypted one, and either way the only thing it
can emit is public. The contract checks that against the payload that actually
reaches the page rather than against the source, because what the code intends
and what it ships are different claims.

Unloading a key from the agent is refused, with its reason. On this desktop
`ssh-add -d` prints "Identity removed" and the key is still offered a second
later: gnome-keyring's agent lists every key it finds in ~/.ssh, so a removed
one comes straight back off disk. That was measured rather than assumed -- a
plain ssh-agent removes durably, this one does not -- and a button reporting
success while changing nothing is worse than no button. The page says so and
names the thing that does work: move the file out of ~/.ssh.

SSH_AUTH_SOCK is not set in a normal shell here, so a naive check reports "no
agent" while one is plainly running. The helper falls back to the keyring
socket, and an agent started by hand still wins. That gap is the same one that
made reaching these servers awkward in the first place.

Generating a key is deliberately absent. A passphrase cannot reach ssh-keygen
without going somewhere it should not -- -N puts it in argv, which every process
on the machine can read -- and driving the prompt over a pty did not work.
Offering to generate an unencrypted key instead would be a downgrade dressed as
a feature, so the page does not offer to generate at all.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-20 10:17:33 -04:00
Gabriel Brown 79b3d5cb85 Close the sweep's last blind spot, and stop shortcuts silently colliding
gapsIn and gapsOut were the only two compositor settings the write sweep had
never verified: Hyprland answers for them in CSS shorthand, "5 5 5 5", and the
sweep had no way to compare that. The preference behind each is a single int
that Hyprland expands to four sides, so a uniform reading compares exactly. A
non-uniform one is not something the preference can express, and is skipped
rather than collapsed to a number it never wrote. 63 of 63 verified live now,
none skipped.

Wallpaper thumbnails are cached. The report that five of them sat at "Loading…"
was a screenshot taken 1.1 seconds after the page opened -- decoding one of
these at tile size takes between 1.2 and 2.6 seconds and about ten start at
once, which the code already said. Measuring it did turn up something real
though: without a cache, scrolling back up pays that decode again for every
tile. The tradeoff is a wallpaper replaced in place showing a stale thumbnail
until restart, which is worth it for a directory of files that are added rather
than edited.

A chord already in use is now named rather than taken: "Super+Q is already
Terminal". Two actions on one chord means whichever Hyprland reads last wins,
which is not a thing to find out later by pressing it. Rebinding a shortcut to
the chord it already holds is correctly not a conflict.

Also: Open Appearance lands on the Windows tab now that the page has tabs,
Storage points at reclaimable container space, and a dock row shows its desktop
id only when two pinned applications share a name -- it is developer text, and
repeating it under fifteen recognisable names made the list harder to scan.

Written down because it cost the shell: QML has no default parameter values, and
`function openSettings(page: string, section: string = "")` fails the entire
configuration rather than the one function -- so the bar and dock went with it,
and 43 contracts failed at once pointing at the same line. qmllint --bare passes
that, which is why the usual check before touching the running shell did not
catch it. openSettingsSection exists as a separate function for that reason.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-20 09:56:02 -04:00
Gabriel Brown 1b845c0126 Rebuild Home around what this desktop already knows
Home led with a diagram of the attached monitor -- DP-2, 4500 x 3000, 1.13x
scale, XRGB2101010 -- which is Displays-page data, was the largest thing on the
screen, and has never been needed there. Under it sat a permanently open search
field for a weather location that is set about once a year, and then roughly
half a page of nothing.

It now opens with the two or three things somebody would open Home to do:
starting or ending a focus session, Do Not Disturb, and what is running. The Do
Not Disturb switch is disabled and says why while a mode is holding it, so it
cannot appear to be a control that is being ignored.

Findings sit above a reassurance line, the same shape the Firewall and
Containers pages use. Each finding names the page that can resolve it, because a
home page reporting a problem it cannot help with is only an alarm. On this
machine that is one finding today -- PostgreSQL and Redis reachable from the
network -- above "27 health checks pass, 522 GB free, snapshots ran at 02:00".
"Do next" appears only when there is something in it.

Weather stays, as the greeting's second line rather than a card with a search
box open, and its picker is collapsed behind the current location.

FocusModes was missing `import qs.config`, so DesktopPreferences was undefined
and the modes list came back undefined with it -- shipped two commits ago with
nothing noticing, because until Home referenced the service no isolated shell
had ever instantiated it. Focus modes would have quietly had no modes. Pulling
these services together on one page is what surfaced it.

LocationPicker gained a picked signal, the same way DisplayModePicker did, so a
container can put the search away without reaching into it.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-20 09:15:01 -04:00
Gabriel Brown f53ca16392 Make the focus exception list real, or it was a page telling a lie
The mode data model shipped with an allow list and nothing that read it. The
summary would say "2 apps may interrupt" while notification delivery never
consulted the list and no editor could set it. That is the dead row this work
has spent its time removing, introduced by the work itself.

The banner gate consults the mode in force now, and the list can be edited from
the applications that have actually sent a notification -- an exception for
something that never notifies is not a choice worth offering.

Exceptions belong to a mode. allowedApps is empty whenever no mode is active, so
a Do Not Disturb switched on by hand stays absolute and nothing can leak into
it. That scoping is asserted, not just written.

Verifying this took three attempts, and the second was a real defect in the
guard rather than in the code. The contract grep for FocusModes.allows matched
the comment that explains it, so the check passed with the enforcement deleted.
It matches the gate expression now. A guard a comment can satisfy is not a
guard, and this is the third time prose has satisfied one here.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-20 02:23:30 -04:00
Gabriel Brown 6dc606b872 Give focus modes conditions rather than alarms, and let Gaming hand over
A mode is on because something is true right now: a game is running, a window is
fullscreen on a given display, a workspace is focused, the clock is inside a
window. That is asked again rather than fired once, and it is the whole reason
schedules could be included here without the usual failure modes. A machine
asleep at 23:30, rebooted at 02:00, or opened at 08:00 into a window that has
already passed all reach the right answer by being asked again; an alarm gets
all three wrong.

The midnight-crossing rule is the part worth being careful about: a window
belongs to the day it STARTS on, so a Friday-only 23:30-07:00 covers Saturday
morning and must not cover Saturday night. That arithmetic was tested as pure
logic before anything was built on it, including every malformed input failing
closed -- silencing someone because a time string was wrong is the worst way
this could fail.

This does not take over the manual timed session. FocusSession already owns
that, with its capsule, shortcut, Quick Settings entry and contracts, so modes
defer entirely while one runs. Two writers of Do Not Disturb would each restore
whatever the other happened to leave behind.

Gaming hands over rather than being duplicated. The hook was silencing
notifications itself, which would have made exactly those two owners -- and
Gaming.active only polls while its settings page is open, so a mode could not
have seen a game reliably in any case. The hook reports the game over IPC now
and the mode decides what that means, the Gaming page points at it, and
gamingSilenceNotifications is retired from the schema, since a setting nothing
reads is the dead row this work keeps removing.

Sleep ships disabled. A desktop that starts silencing someone on first boot has
overstepped, whatever the default hour.

Three contracts moved with it. gaming-contract asserted the hook uses setDnd,
which was right before and wrong now; the shell-side assertions that setDnd and
dndState exist stay, because a toggle would flip an already-silent machine back
on. The new contract is proven to fail by breaking the midnight rule and by
letting modes run alongside a manual session.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-20 02:06:50 -04:00
Gabriel Brown 32fab59d24 Let a sound device be heard, and the Dock's icons be sized
Nine outputs named after their chipsets cannot be told apart by reading, so each
one gets a Test button that plays a short sample out of that device. Targeted by
node name rather than by making it the default first, because finding out which
is which should not move where everything else is playing.

That belongs in its own service rather than in AudioDevices. sound-page-contract
forbids Process, pactl and wpctl in the files that own device state, and it is
right to: shelling out there races the PipeWire service that owns those same
objects. Playback is a different thing -- pw-play opens its own stream and
mutates no device, so there is nothing to race -- but the rule's letter covered
it, and weakening a guard to fit a new case is how guards stop meaning anything.
SoundTest exists so AudioDevices stays native bindings only.

Worth recording next to the call: pw-play falls back to the default output for a
target it cannot find, rather than failing. A stale node name would play from
the wrong device and look exactly like a successful test, which is why the name
is taken straight from the live node.

The Dock's icon size was a constant in Theme. It goes through the preference
schema like everything else, so validation, search, the generated docs and the
write sweep all pick it up without being told about it separately -- and two
contracts duly failed until docs/settings.md and the per-page commands were
regenerated.

Dock position is deliberately not here. It is not a setting but a rework: the
dock is anchored bottom, and the reveal strip, tooltip placement, intellihide
and the qs-dock rule in hypr/rules.lua all assume that. Doing it properly means
changing compositor rules on a machine somebody uses daily, which is not
something to start as a side effect of adding a slider.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-20 00:03:21 -04:00
Gabriel Brown f6b970da21 Turn the rows that only reported things into controls
Autostart entries showed "Enabled" or "Disabled" as plain text. The row did
toggle on click the whole time, so this is an affordance rather than a missing
capability -- but a control that reads as static text is one nobody knows they
have. It is a switch now, with removal alongside it behind a confirmation:
disabling writes Hidden=true and can be undone, deleting the file cannot.

remove-autostart is confined to files the autostart directory owns. It resolves
the path and compares the parent, so a name like "../../.bashrc" cannot escape,
and it refuses symlinks rather than following them -- deleting through one would
remove whatever it points at, which is somewhere else and not ours. Each refusal
was tested against a fixture directory, including a symlink aimed at
/etc/hostname, which survived.

Sharing says who is signed in from another machine: user, origin and since when.
An empty list on this machine proves nothing, so the parser was checked against
sample `who` output -- it picks out remote sessions and leaves out local seats
and the :0 display, which would otherwise report the person at the keyboard as a
remote login.

Media sharing was "Available" and nothing else: rygel installed, rygel.service
disabled, no way to change that from here. It is a switch now, and it says what
it does before you touch it rather than afterwards -- turning it on publishes
media folders to every device on the network with no password in front of them.

Per-application camera and microphone permissions come from the portal's
permission store, which is where an application that asked through the portal
has its answer recorded. The page states the limit plainly instead of implying a
protection that does not exist: a program installed outside the portal opens the
device directly and nothing here stands in its way. Anything that is not an
explicit "yes" is treated as withheld, because guessing generously about a
camera is the wrong way to be wrong.

The first version of the write silently did nothing -- SetPermission takes an
array of strings and was being handed one string -- and the test did not notice,
because it discarded the helper's output and only checked that state was
unchanged afterwards, which was trivially true. The contract now requires the
value to move, and was proven to fail by putting that exact bug back.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 23:10:48 -04:00
Gabriel Brown 7e1c85b094 Group the long pages by what you are trying to do
Notifications repeated both lock-screen switch labels for every application, so
twenty apps meant sixty rows of the same two sentences and the page could not be
scanned at all. Each app is one row now, carrying what its switches add up to --
"On, lock screen shows the sender only", "On, hidden on the lock screen",
"Notifications off" -- with the switches behind it, one app open at a time. The
identifier only appears while an app is open, which is the only time it
disambiguates anything, and the content switch dims when the app cannot reach
the lock screen at all, because there it means nothing.

Shortcuts were already grouped; the problem was that "Windows" caught focus,
movement, splitting, resizing and window state alike and held 43 of the 93
binds. A section that long is a list, not a grouping. They are separated by
intent now -- Focus, Move & split, Size, Window state -- and the split was
checked against the binds this machine actually has rather than trusted from the
keywords. Order matters in two places worth naming: "Next window splits down" is
about splitting rather than focus, and "Focus session" is quiet mode bound to a
workspace rather than window focus, so both are settled before the general
checks.

Refresh rate gets its own row. That need was created by collapsing the
resolution list: the rates for a resolution were only ever reachable by opening
it, so changing nothing but the rate meant going through the mode you already
had. It appears only when the current resolution offers more than one.

Default-application rows carry a chevron, having previously opened a chooser
while looking completely inert.

The notification contract asserted the literal Notifs.appRule(app.id).enabled,
which moved when the rows collapsed. The rule is still read through a binding on
Notifs.appRule, so a rule changed elsewhere still reaches the row -- the
assertion now requires that, rather than requiring one particular spelling of
it.

The power profile rows were left alone. A three-way choice in three rows looks
wasteful until you notice each row explains what the profile does, and that page
has empty space to spare; a segmented control would trade information for space
that is not scarce.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 22:54:51 -04:00
Gabriel Brown 0c364f38e6 Stop sending people to GNOME for pages this app already has
Panama absorbed Users, Sharing, Printers and Online Accounts one page at a time.
Each time, the row pointing at GNOME's equivalent stayed exactly where it was --
so an app whose stated purpose is to make GNOME Settings unnecessary shipped four
doors back to it, two of them inside a card headed "these areas remain owned by
Fedora and GNOME's mature system panels".

Nothing failed. Every row worked as written. They were simply no longer true, and
no test could notice, because none of them knew what Panama had come to own in
the meantime. gnome-handoff-contract reads the sidebar for the pages that exist
and the pages for the panels they hand off, and fails on any overlap -- derived
from both sides rather than a hand-kept list, so absorbing the next page cannot
leave a stale door behind. Adding an online account is allow-listed with its
reason: it genuinely requires GOA's own dialog.

health-ui-contract asserted those handoffs were present, which is how they
survived. The assertion is inverted rather than deleted, so reintroducing one
fails loudly.

The Home Assistant "Light entities" box is gone. It was a multi-line list of
comma-separated Zigbee entity IDs, and the light catalog does not come from it --
the helper discovers that live. It is a one-time migration seed for the Control
Center selection, so saving now passes the stored value back untouched: setting a
URL or a token cannot disturb it. Deleting the control naively would have written
an empty list over it.

Sharing showed two "Port" rows for RDP, same label and value, one read-only and
one editable, separated by a switch. The read-only leftover is gone. The SSH port
stays read-only because sshd's port is not ours to write.

About reported "488G free of 1.9T" where Storage said "523 GB free of 2.0 TB" --
the same drive, binary against decimal. About uses decimal now, matching how
drives are sold. Memory and swap stay in GiB, which is how RAM is sold.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 22:36:58 -04:00
Gabriel Brown 1aa1324083 Lead Appearance with light and dark, and stop pages listing whole datasets
Appearance was six cards deep and Light/Dark was the third of them, below the
wallpaper grid and the entire lock screen -- so the control reached most often
was the last one you got to. It is five tabs now, Theme first. The mock showed
four; the page turned out to have eleven cards, so Titlebars and Windows became
Windows, and Clock and vitals became Shell, rather than pretending four would
hold them.

Region, Date & Time and Displays each rendered a complete dataset as rows: every
installed locale, the whole tz database, every mode the monitor advertises. The
chooser was never the problem -- SearchPicker already existed and worked. It was
simply rendered always-expanded, so the one line saying what is currently set sat
under hundreds that were not. PickerRow collapses each behind its current value
and closes again once something is picked.

The avatar never appeared to change because accountsservice writes every picture
to the same path, leaving the URL byte-identical while Qt served its cached
image. cache:false was already set and could not have helped: an unchanged source
is never re-read at all. avatarUrl now carries a revision fragment, bumped only
when a write actually succeeds. Pictures are cropped before they are set, in the
picture's own pixel coordinates so the result does not depend on the size it
happened to be displayed at, and written out at 512x512 through GdkPixbuf --
already a dependency here, so nothing new is required.

Snapshots listed nothing. The timeline and its Delete buttons existed the whole
time, behind a row labelled "Browse...", a word that promises a file browser. The
three most recent points are shown inline now, with the rest one press away.

qmldir-registration-contract exists because an unregistered component is not a
quiet problem: Quickshell fails the entire configuration on it, so the settings
window dies and the bar and dock go with it. That happened twice while writing
this, both times on a machine somebody was using. It is pure file inspection, so
it runs before a change ever reaches the running shell.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 22:36:41 -04:00
Gabriel Brown ac5e6e2130 Add a Containers page, grouped by project and led by what is exposed
Every container on this machine is created by rootless podman-compose and
labelled with the project it belongs to, so the grouping is read from the
labels rather than invented. State then decides prominence within that
grouping -- running containers get rows, stopped ones collapse to a line --
which is why neither axis had to be chosen over the other.

Acting on a stack uses plain podman over the labelled set, never
`podman-compose down`, which would remove containers this shell did not
create. The compose file is the source of truth for what exists and belongs
to the repository. Nothing here needs privilege.

The findings on top are the crossing the Firewall page reports, seen from the
side that can close it: the firewall knows only that something is listening,
while this page knows which container, which compose file, and which token is
missing from it. So `bind-local` prepends a loopback address and leaves the
line byte-for-byte -- variables, quoting and style intact -- then re-parses and
rolls back unless exactly those ports moved. It refuses anything ambiguous
rather than guessing. Rewriting the mapping to the port podman reports today
would have deleted the ${POSTGRES_PORT} indirection that makes it
configurable at all.

Unused volumes are read from podman's own dangling filter. The first version
used MountCount, which is a runtime lock counter and not a usage signal: it
reads zero for a volume a running container has mounted this second, so
"remove unused volumes" offered to delete the live Command Center database.
The cross-check against `podman system df` is what exposed it. The contract
reintroduces that bug deliberately and fails if the guard does not catch it,
because a guard nobody has seen fail proves nothing.

Every mutation in the contract runs against a stubbed podman. Nothing in the
suite starts, stops or removes a real container, image or volume.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 20:49:04 -04:00
Gabriel Brown 536958430f Fix the polkit unit's ignored rate limit
systemd logged "Unknown key 'StartLimitIntervalSec'" on every start:
rate limiting belongs in [Unit], not [Service], so both keys were
ignored and the restart limit they were meant to impose did not exist.
An agent that failed repeatedly would have flapped rather than stopping.

Found while investigating reported prompt failures, which turned out not
to be a defect: the trace showed both authentications succeeding on the
first attempt. The failures came from testing -- prompts raised by
background pkexec runs and then cancelled seconds later, while someone
was trying to type into them.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 19:54:16 -04:00
Gabriel Brown fd99569666 Add a Firewall page, led by what is actually reachable
Listing zones and services is what firewall-cmd already does. The
question it does not answer needs both halves at once: a port is
reachable only when something is LISTENING on a network address AND the
firewall permits it.

On this machine that crossing is the whole story. The rules look
unremarkable -- one zone, three services, a port range -- and what they
mean is that PostgreSQL and Redis, published by rootless containers on
every interface, are reachable by anyone on the network. Neither half
says that alone, which is exactly how a tidy rules list coexists with an
open database. Nothing was misconfigured: Fedora's default zone met
podman's default publish behaviour.

Ephemeral client sockets are excluded. A browser's outbound UDP port is
indistinguishable from a service in ss, and listing twenty of them
buried the two rows that mattered.

Closing the port range names what it would cut off, by service, before
doing it, and removing ssh says so when someone is connected over it.
Rich rules are shown and never edited: a syntax is not a setting, but
hiding it would misrepresent the configuration.

The contract needed a recorded firewall, and the reason is worth
keeping. The rule this page exists for cannot be tested against this
machine -- its zone permits everything above 1024, so "listening" and
"listening and permitted" give identical answers, and a blocked listener
needs a port below 1024, which needs root. With the crossing deleted,
the contract passed. It now runs against a fixture where two listeners
are blocked, and catches it.

Also here: polkit response files are written 0600 rather than at the
default mask, the agent sweeps requests left by an instance that did not
exit cleanly, and the write sweep waits for its harness to be ready
instead of reporting the startup race as settings that failed.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 19:46:16 -04:00
Gabriel Brown a412e3d894 Fix the compositor write contract properly, not by waiting longer
Widening its waits earlier treated the symptom. It still failed about
one run in three, on an idle machine, taking eighteen seconds to do so
-- which was the clue: nothing was in flight to wait for.

SystemSettings verifies each write before storing it and serializes
overlapping ones, so the restore arriving while the previous batch was
still settling was being dropped outright. Waiting longer for a write
that was never queued cannot help. It is issued up to three times now,
and the run where a retry fires visibly takes eight seconds instead of
two.

Retrying cannot hide a broken write path: with the write stubbed out the
contract still reports exactly which policy failed to reach the
compositor.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 19:05:29 -04:00
Gabriel Brown 4cbe3b882a Add a Gaming page, and let the desktop react to games
Live first, because unlike every other page here this one has a live
dimension: card temperature, power draw, whether Game Mode actually
engaged. It polls only while it is open, since a settings page nobody is
looking at has no business waking the CPU.

The part that makes it Panama's page rather than a gamemode config
editor is the hook. gamemode runs a script when a game asks for it and
another when the game exits, so the power profile switches to
performance and notifications go quiet for exactly the duration of a
game -- and afterwards both go back to what they WERE, not to a default.
A Do Not Disturb someone set by hand survives a game; a power profile
someone chose is restored rather than replaced. Verified against real
gamemode activation, not merely by calling the hook.

Two things the page reports rather than hides. Game Mode's headline
trick is switching the CPU governor to performance, and this machine
already runs performance, so it says so instead of implying it helps.
And Proton builds are listed but never chosen: Steam picks the runtime
per game, and a control here would claim an authority this page does not
have.

The hook first called a notifications function that did not exist, and
the one that did was a TOGGLE -- the wrong primitive entirely, since
toggling at game start would unsilence notifications that were already
silent. The shell gained an explicit setter and reader.

search-routing-contract kept its own hand-written list of every page,
which made adding one fail as "not a known page" -- a sixth place to
register a page and a sixth chance to forget. It now derives the mapping
from the shell, which already knows it.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 18:55:10 -04:00
Gabriel Brown edc504af2e Drop the self-hosted integration batch, and say why
The machine runs essentially every popular self-hosted service, and on
looking at them the batch was revised once and then dropped. Almost all
of it is infrastructure for other services rather than anything a
desktop consumes -- whisper there feeds Bazarr subtitles, not dictation
here -- the desktop has the better GPU anyway, and the one genuinely
desktop-shaped idea is already solved by an installed application.

Recorded rather than deleted so it is not proposed again, with the
lesson that matters: a service existing is not a reason to integrate it.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 18:38:19 -04:00
Gabriel Brown a15f019c17 Offer automatic package downloads, now that the machinery is installed
The page had a row for the case where dnf-automatic is absent and
nothing for the case where it is present, so installing it made the
setting disappear rather than appear.

The switch enables downloading only, which is the shipped default and
the right one to keep: a machine that installs packages unattended can
reboot into a kernel nobody chose. Downloading ahead of time is what
makes the install quick when someone does choose it, and the row says
exactly that rather than implying updates apply themselves.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 18:24:30 -04:00
Gabriel Brown 116510caa8 Draw the authentication prompt ourselves
hyprpolkitagent's dialog is compiled into its binary -- no config, no
stylesheet, nothing to theme -- and it was the one window on this
desktop that looked like it belonged to something else.

The split between the two halves is the security design, not an
implementation detail. A small agent process owns the D-Bus side: it
registers with polkitd, receives the request, and hands the shell the
action, the message, who may answer, and a one-time cookie. It never
sees a password. The shell draws the prompt and, on submit, spawns the
setuid polkit-agent-helper-1 itself and writes the password to that
helper's stdin; the helper runs the PAM conversation and reports to
polkitd directly. The password exists in the shell and in the helper's
stdin and nowhere else -- never on a command line, never over D-Bus,
never through IPC arguments.

The prompt takes exclusive keyboard focus, because a password field that
lets keystrokes reach the window behind it is a keylogger with extra
steps. The request travels as a file created 0600 with O_EXCL inside a
0700 runtime directory: a cookie is not a password, but it is a
capability, and capabilities do not belong in a process listing either.

Three things cost real time. polkitd calls back on the same connection
that registered, so exporting the object on the session bus while
registering from the system bus failed every request as "Not authorized"
with no error anywhere. XDG_SESSION_ID is absent in a systemd user unit,
which runs under [email protected] and belongs to no login session, so the
session comes from logind's Display property instead. And PyGObject does
not accept the @ placeholder in variant format strings.

hyprpolkitagent stays installed as the fallback, only one agent is
started, and the comment beside the autostart says how to get the stock
prompt back. Verified end to end, including a real password accepted and
three cancellations refused.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 18:08:52 -04:00
Gabriel Brown b10f8e2593 Stop the compositor write contract failing under load
It passed alone in two seconds and failed in a full suite run at six,
which is the worst way for a test to be wrong: the code was fine and the
report said otherwise.

Each of its waits polls until the compositor reflects a write, and four
seconds was enough on an idle machine and not enough on a busy one. They
are fifteen now. A longer bound cannot mask a dead write, because a
write that never lands never matches -- verified by stubbing the write
path out and watching it still report exactly which policy failed.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 17:33:12 -04:00
Gabriel Brown 8f0fe23377 Add Software Update, across packages, applications and firmware
Three sources that fail independently, so they are counted and applied
separately: a flatpak mirror being down says nothing about whether a
kernel security fix is waiting. Blending them into one number would hide
exactly the case that matters.

Checking costs about nine seconds, which is too long to spend every time
a page opens, so the page opens on the last result and says when it was
taken. A first visit with nothing cached goes and finds out rather than
showing a confident "up to date" it has no basis for.

Installing packages takes a snapshot first, named after what is about to
happen, so Snapshots shows "before 32 package updates" rather than a
timestamp. Best effort: a machine without snapper still updates, because
an update that refuses to run when a nicety fails would be worse than
one without a restore point.

Automatic updates cover applications only, through a Panama-owned user
timer running daily with a randomized delay. Packages still ask, and
dnf-automatic is reported as absent rather than offered, because
installing software is not a settings action.

Health gained a check, and that is where the bug was: it first returned
status "degraded", which is not in the doctor's vocabulary of ok,
warning, error and unconfigured. It was counted as nothing at all while
the summary still said healthy -- the same silent no-op this codebase
keeps relearning. A contract now asserts every status a check can return
is one the doctor counts, and the doctor's own contract knows about the
new check rather than failing on its arrival.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 17:25:11 -04:00
Gabriel Brown 89cf0f8c29 Plan the settings work beyond GNOME parity
Parity is done except for Color, which the platform blocks. This is the
other direction: what a settings application should own on a machine
whose owner runs the servers.

Every item was checked against this machine before being written down,
including the one that was dropped -- a VPN page, because the WireGuard
tunnel lives on the router and neither wg nor tailscale is installed
here, so it would have had nothing to drive.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 17:06:39 -04:00
Gabriel Brown e290a262f3 Make notifications legible, and follow windows that ask for focus
The notification background was a 6% foreground tint over a transparent
window, which meant it had nothing behind it at all: the card sat
directly on whatever was on screen and the text competed with it.
Notifications are the one surface someone reads without having chosen to
look at it, so it now uses a real popover surface -- a little lighter
than one you opened deliberately, because a toast arrives unbidden and
full popover weight reads as a dialog demanding an answer.

Windows that ask for attention are now switched to rather than merely
highlighted, so a link opening on another workspace takes you there.
That setting already existed and was simply off; it is a preference
rather than a hardcoded behaviour, so it can be turned back off on the
Desktop & Dock page.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 17:05:37 -04:00
Gabriel Brown 6997dd535f Add high contrast, and make remote desktop configurable
Two of the three panels still handed to GNOME, having actually checked
each rather than repeating that they were not worth owning.

Universal Access turned out to be mostly ours already: the magnifier,
pointer size, text scale, motion and dimming were all present. High
contrast was the real gap. It reaches GTK4 applications through the
desktop portal, which republishes GNOME's accessibility setting as
org.freedesktop.appearance contrast -- so no high-contrast theme is
involved, and none is installed here. Verified end to end: committing
the preference drove gsettings and the portal reported contrast 1.

Sticky, slow and bounce keys stay absent. There is no Wayland or
Hyprland implementation, and the compositor would store the XKB option
while nothing ever acted on it.

Remote desktop gained port, view-only, and clearing stored credentials.
SETTING credentials opens a terminal running grdctl, which prompts for
the password itself. That is not a hand-off for lack of effort: grdctl
takes the password on a terminal and core-dumps without one, and the
only alternative -- passing it as an argument -- would publish it
through /proc to every process on this machine. Typed into grdctl
directly it never passes through Panama, and a contract now fails if it
ever appears on a command line.

Color stays with GNOME, and not for lack of effort either. colord runs
here with seven profiles and zero devices registered, because the
daemons that register displays do not run under this session, and
Hyprland exposes no ICC, gamma, or color-management option at all. A
Color page could import a profile, attach it to nothing, and change
nothing -- the same failure refused for rollback and printer drivers.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 16:42:20 -04:00
Gabriel Brown 1f40f8e136 Add a snapshot manager, and start covering home
The machine already had snapper running hourly on btrfs, so the tool was
never missing. What was missing is that snapper's only configuration
covered / -- and /home is a separate subvolume with no configuration at
all. Six hundred and forty-three snapshots existed and not one of them
contained a document. Anyone reaching for file history would have found
their system and none of their files.

/home now has a configuration on the same hourly timeline, with
deliberately conservative retention: Steam's 1.2 TB lives on that
subvolume and churns on every game update, so keeping five hourly and
seven daily bounds what those updates can pin.

Per volume, because on this machine "one is covered and the important
one is not" was the news, and a timeline opening on system snapshots
would have buried it. Inside a volume the timeline is the familiar view:
points in time, newest first, each openable as a folder tree to take a
file out of.

Restoring sets the current version aside as .before-restore-N rather
than overwriting it. A restore that destroys the thing you were about to
compare against is how someone loses the work they were trying to save.

Rollback is deliberately absent. snapper's rollback changes the btrfs
default subvolume, and this system's fstab pins subvol= explicitly,
which overrides it -- so a rollback would report success and change
nothing after a reboot. A recovery feature that silently does nothing is
worse than not having one, and making it work means editing fstab and
the bootloader, whose failure cannot be repaired from inside the
desktop.

Per-snapshot size is reported as not measured, because measuring it
needs btrfs quotas that cost performance on every write. Free space is
shown instead, which is the number that decides anything.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 16:11:50 -04:00
Gabriel Brown e9d567aa72 Own printers, driverless only
The fourth panel this desktop handed to GNOME Settings, and the last one
worth owning.

Driverless only, deliberately. Adding a printer that describes its own
capabilities over IPP is supported; choosing a PPD or fetching a vendor
driver is not, and the page says so rather than pretending. That
restraint is the whole design: a wrong driver produces a printer that
accepts jobs, reports success, and prints nothing, which is the worst
failure this page could ship because it looks like it worked. A printer
old enough to need a PPD stays a job for the system printer tool.

Printers and the queue are separate cards because they answer separate
questions. Which printers exist is one; where a document went is the
other, and it is the one that actually brings someone here -- so the
queue is a single list across every printer.

Device URIs are validated by scheme before reaching CUPS, whose backends
run as root. file: and pipe: do not lead to a printer and are refused
here rather than further down.

This machine has no printer, so the page was built against a temporary
CUPS queue that was created, exercised through the service, and removed;
the service was confirmed to observe the removal rather than merely
perform it. Discovery and the driverless add path are verified by their
refusals rather than against hardware.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 15:07:17 -04:00
Gabriel Brown 914d58f52b Record which GNOME panels are owned and which are not
Which panels we own is a question that otherwise gets answered one panel
at a time, and forgotten between them.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 13:17:53 -04:00
Gabriel Brown a23b42841a Own user accounts and sharing
Two of the panels this desktop still handed to GNOME Settings.

Users manages the account through accountsservice -- the same daemon
GNOME's panel drives, so a name or picture set here is what the login
screen and lock screen read. Name, picture, account type, password,
automatic login, and adding or removing other accounts. Every change is
authorized by polkit through the agent this session already runs; a
dismissed prompt is a normal outcome and says so.

A new password is read from the helper's stdin, hashed by openssl
reading its own stdin, and handed over D-Bus from inside that process.
It is never an argument: argv is world-readable through /proc, so a
password passed that way is published to every process on the machine.
Removing an account takes two presses and says it destroys their files;
the last administrator cannot be removed or demoted, because a machine
nobody can administer is not a state to offer.

Sharing reports what is actually true, including "the software for this
is not installed" -- the honest answer for Samba here, and the case the
panel it replaces shows as a switch that does nothing. Password sign-in
is reported from sshd's configuration rather than assumed: claiming
"keys only" when the file is silent would state a security property that
cannot be backed up.

The Control Center now draws the account's real picture and name. A
generic glyph sat there while a real avatar was already set, which made
the desktop look like it did not know whose it was.

Also here: the KDE Connect contract no longer requires a phone to be
awake. kdeconnectd drops its device objects for a phone it has not seen
recently while the pairing survives in its config, so demanding one
failed whenever the phone was off.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 13:05:13 -04:00
Gabriel Brown e0c0e53ae0 Say what the sweep helper is, when run on its own
A suite runner walking the tests directory executes it with no arguments
and gets an IndexError, which reads as a failing test rather than a
helper being used wrongly.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 12:22:38 -04:00
Gabriel Brown 180308135a Verify every setting actually does something
The audit that followed "some things in the settings app don't work".

Sixty-five settings reach the compositor and five of them were checked
against it. The rest were covered only by tests that read source text,
which is exactly where a dead setting hides: the write no-ops, nothing
fails, nothing logs, and the row simply does nothing.

The sweep drives each setting through SystemSettings.commitPreference --
the entry point a settings row uses -- flips it to a value it does not
hold, reads it back from the live compositor, and puts it straight back
before touching the next one. Settings Panama stores itself get the same
treatment against the store, since a value that fails to persist is the
same dead row from the outside.

Result: 61 of 63 compositor settings verified against the running
compositor, and 51 stored settings round-tripped. No failures. The
breakage was confined to the Applications page, which is fixed.

Proven able to fail before being trusted: with commitPreference stubbed
to return true without applying, 61 settings are reported; with the
store stubbed to return nothing, 51 are. A one-second settle window
keeps a slow read from being reported as a dead write, which it briefly
was.

Also here: control-center-contract asserted the literal margin
expression that made the panel hang 38 pixels below the bar, so the
contract and the code agreed and the bug was invisible to both. And
settings-page-registry-contract is deleted -- settings-nav-contract
already checked those files and more. It would have caught the Storage
page omission if I had run the suite instead of a hand-picked subset.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 12:17:27 -04:00
Gabriel Brown b743f44c5b Make changing a default application actually take effect
Three bugs, all silent, all in the same feature.

The write always worked. What failed was the refresh after it. That
refresh is called from the mutation's own onExited handler and was
guarded on `busy`, a binding over both processes -- and a binding hands
back its cached value until the change notification feeding it has been
delivered, which inside that handler has not happened yet. So `busy`
read true, refresh returned immediately, and the page kept showing the
old application with no error anywhere. Guards now read the Process
objects directly, where the value is current, and a refresh is no longer
blocked by the mutation that asked for it.

The service also kept its own list of which roles it would accept. It
stayed at seven when the helper and the page grew documents, text and
archives, so choosing a PDF viewer set an error and changed nothing.
It is derived from the snapshot now.

And category matching never worked. DesktopEntries returns a QML list,
for which Array.isArray is false, so the code stringified it into
"Network,WebBrowser" and split on ";" alone -- one token matching no
category. Browsers still appeared because their generic name contains
"web browser" and the terms fallback carried the role by itself.
Archives matched nothing at all, so that row could only ever offer the
application it already had.

The harness that should have caught the first bug passed while it was
live: it set each role to the value it already had and asserted no error
appeared, and the bug produces no error. It now changes a role to a
genuinely different application, requires the service to observe the new
value, and changes it back -- with the contract restoring the original
from the outside however the run ends.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 11:59:53 -04:00
Gabriel Brown 2528edfddc Stop every popover double-counting the bar height
The control center fix applies to five more surfaces: the date menu,
the clipboard panel, the activity panel, notification toasts, and the
signal glass all opened 48 pixels under the bar while their own
expression asked for 12.

wlr-layer-shell has three behaviors and only the middle one is subtle.
A positive exclusiveZone reserves space; a negative one ignores what
others reserved; zero reserves nothing but RESPECTS what others
reserved. Every popover here uses zero, so the compositor had already
placed them below the bar before their own margin applied, and adding
Theme.barHeight counted the bar twice.

It is not a crash or a warning -- the surface simply opens lower than
written -- so a contract now holds the rule mechanically: a surface
with exclusiveZone 0 may not name Theme.barHeight in a margin. It also
checks the premise it rests on, and fails if the bar ever stops
reserving its own height rather than quietly checking the wrong thing.

Measured after: date menu and clipboard at 12px, control center at 2px,
each matching what its code asks for.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 11:31:53 -04:00
Gabriel Brown dbd1472e6b Read busctl as JSON so device names keep their characters
A phone named "Gib's iPhone" with a typographic apostrophe was shown as
"Gib\342\200\231s iPhone".

busctl's default text output escapes every non-ASCII byte in octal, and
escapes it into the output rather than into a quoted string a
shell-style parser can undo, so shlex handed back the escape sequences
as literal characters and they went straight to the page. Apostrophes
were only the visible case: accents, emoji, quotes and backslashes were
all affected, and a name containing a quote could have split a field.

Property and method reads now use --json=short, which returns real
UTF-8, and the parsers read a document rather than splitting words.
That removes the class rather than unescaping octal by hand.

The fixtures were the reason this stayed invisible: every test fed the
text form and passed against output the helper is no longer asking for.
They now carry what busctl actually emits in the mode used, plus a case
for a non-ASCII name and one asserting the old text form is refused
rather than parsed wrongly.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 11:28:36 -04:00
Gabriel Brown eeb49c5aff Hang the control center under the bar instead of below it
The panel opened 38 pixels beneath the bar while its own comment said
two. margins.top added Theme.barHeight to the gap, but an exclusiveZone
of 0 means "reserve nothing, respect what others reserved" -- so the
surface already began below the bar's 36px zone and the bar height was
counted twice.

The margin is now the gap alone, which also means the panel follows the
bar: if the bar ever stops reserving space, this closes up against the
top edge rather than hanging under nothing.

Measured before and after against hyprctl layers: bar bottom at y=36,
panel top from y=74 to y=38.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 11:23:05 -04:00
Gabriel Brown 8e93f08977 Show what the keyring holds, without showing what it holds
Managing a stored credential meant installing Seahorse. The keyring
rows on Privacy could say whether it was locked and nothing about what
was in it.

Four rules, each pinned by a contract, because each is a way this could
leak the thing it exists to protect:

Listing never reads values. Enumerating reports labels and attributes;
it does not ask the keyring to hand over what it is protecting.

A secret never reaches a command line. /proc makes argv readable by
every process on this machine, so a password passed as an argument is
published to all of them. The helper reads the value in process and
writes it to wl-copy on stdin.

A secret never reaches an error message, a log, or a QML property. An
exception raised while holding a password does not get to choose what
text is printed, so the clipboard tool's stderr is discarded rather
than echoed.

Forgetting one is irreversible, so the first press asks and the second
does it, and the confirming button is the only one wearing danger.

The list is collapsed until asked for: opening Privacy should not
enumerate someone's passwords as a side effect. A copied value clears
itself about a minute later, but only if the clipboard still holds it --
the guard compares a SHA-256, so the waiting process never has the
password.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 11:14:32 -04:00
Gabriel Brown 99433c0e8e Add a Storage page
Nothing showed what was using the drive, and removable media was handled
by a tray helper with no surface in Settings at all.

One scroll rather than tabs: space above, the device below. Every other
settings page is a scrolling card stack, and a tab would not be
deep-linkable from the launcher command or from search.

Three things the page has to get right, each now pinned by a contract,
because each is a way it could quietly lie. / and /home are one btrfs
filesystem sharing one pool of free space, and a page that copies df
shows double the free space that exists. zram is a block device and is
not storage; counting it as a drive overstates this machine by 8 GB.
Unmount and eject refuse anything not on a removable drive, because the
UI is what asks and a UI can be wrong.

The cheap read -- layout, usage, health -- runs when the page opens, at
around 90ms. Measuring what is filling the drive means walking every
file, so it happens on request and says so rather than showing an empty
list that reads as "nothing here".

Partitioning and formatting are deliberately absent. A settings pane is
the wrong place to put erasing a disk two clicks deep; the page opens
GNOME Disks for that.

Adding the page found a fourth hard-coded page list in ShellState. A
page missing from it does not error -- openSettings() falls back to
"home", so the launcher opens the wrong page and logs nothing. A
registry contract now holds the three lists together.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 10:52:10 -04:00
Gabriel Brown a68e4f6dcd Make every settings page reachable from the launcher
Settings had a search index and the launcher had script commands, and
neither knew about the other: finding a setting meant opening Settings
first and searching there. One command per page is now generated from
the same sources the in-app search reads -- the sidebar page list, the
group routing, and the schema labels -- so a setting searchable inside
Settings is searchable from the launcher without a second list to
maintain.

Generating it surfaced a gap in the in-app search as well. Six pages had
no vocabulary at all, because their contents come from the system rather
than our schema, so searching "volume" found nothing in Settings either.
They now carry entries, which improves both surfaces.

The product prefix is dropped from every hand-written command title. The
generated ones are qualified "Settings: <page>" instead: a bare page
label collides with the feature of the same name, and two commands
sharing a title are indistinguishable in a launcher.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 09:47:07 -04:00
Gabriel Brown 2b3b762793 Install what the default-application roles are curated to
Seeding is deliberately conservative: a role whose preferred applications
are all missing is left alone rather than forced. That is right, and it
is also silent -- so a curated handler nobody installs presents as the
machine quietly going back to deciding defaults by installation order,
which is the problem seeding exists to fix. None of Loupe, Papers,
Decibels, Nautilus, mpv or sushi was declared anywhere.

Preview works, on org.gnome.NautilusPreviewer2 rather than the interface
its bus name suggests, but it opened tiled -- shoving the file manager
aside for something meant to be an overlay -- so it gets a float and
center rule sized to leave the file underneath visible.

xdg-utils and desktop-file-utils were undeclared too; seeding from
link-dotfiles started calling them, and the dependency contract said so.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 09:35:12 -04:00
Gabriel Brown 91306ce810 Give libadwaita applications the desktop's accent
Files, Papers, Loupe and every other libadwaita application read their
accent from the Settings portal, so they rendered in GNOME blue no
matter which accent this desktop was set to -- correct on our own
surfaces, wrong on half the screen.

xdg-desktop-portal-gtk cannot serve org.freedesktop.appearance
accent-color at all; the string does not appear in the 1.15.3 binary.
The gnome backend serves it, so Settings now routes to gnome with gtk
still listed behind it -- the frontend merges Settings backends in
order, so color-scheme keeps resolving if the gnome backend is ever
unavailable.

GNOME's accent-color is a fixed enum of nine names rather than a color,
so each of our eight accents carries its nearest member. Nearest by hue
rather than by name: rose maps to red, because it is the red role in
this palette.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 09:31:50 -04:00
Gabriel Brown 1b94af1163 Plan the desktop integration work
Six areas an OS is expected to own that this desktop still delegates:
preview, GTK accent cohesion, storage, launcher search, secrets, and
backups. Ordered so each is useful the day it lands.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 09:19:40 -04:00
Gabriel Brown b4ce148caf Give each default-application role a whole family of types
Every role carried a single representative type, so setting "Images"
changed image/png and left image/jpeg wherever it landed. That is how
this desktop ended up opening PDFs in GIMP, PNGs in a pixel-art editor
and MP3s in a video transcoder: nobody chose any of it, applications
registered themselves for everything they could read, and the roles
governed one type each.

Roles now own families and write every type when set, the settings page
exposes the documents, text and archives roles it never offered, and a
new seed command curates a fresh machine during setup while always
keeping a choice the user has already made.

The shipped editor entry launches kitty explicitly. The stock
nvim.desktop sets Terminal=true, which defers to whatever the system
considers default rather than the terminal this desktop themes.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 09:17:33 -04:00
206 changed files with 18733 additions and 586 deletions
+2 -2
View File
@@ -37,13 +37,13 @@ Last live audit: 2026-08-17, Fedora 44, Hyprland 0.56.2, Quickshell 0.3.0.
| Autostart apps | Nextcloud, Bitwarden, and RustDesk system service/tray | Live |
| Printer administration | CUPS with the `system-config-printer` graphical interface | Live |
| System settings | The Settings app for display policy, appearance, desktop, sound, focus, shortcuts, and services; labeled GNOME hardware/account handoffs | Live |
| System health and recovery | Settings → System Health, `Panama: Check System Health` in Vicinae, a degraded-only bar indicator, redacted reports, and bounded Panama-owned repairs | Live |
| System health and recovery | Settings → System Health, `Check System Health` in Vicinae, a degraded-only bar indicator, redacted reports, and bounded Panama-owned repairs | Live |
## System health and recovery
Panama stays silent while the desktop is healthy. A compact bar indicator
appears only for actionable warnings or errors and opens the same **System
Health** page available from Settings and the Vicinae command **Panama: Check
Health** page available from Settings and the Vicinae command **Check
System Health**. The terminal summary is available with:
```bash
+6 -1
View File
@@ -32,7 +32,12 @@ hl.on("hyprland.start", function()
-- 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")
-- panama-polkit-agent replaces hyprpolkitagent, whose prompt is compiled
-- into its binary and cannot be themed. Only one agent may register per
-- session, so they must not both start. hyprpolkitagent stays INSTALLED as
-- the fallback: `systemctl --user start hyprpolkitagent` restores the stock
-- prompt if Panama's ever fails to come up.
hl.exec_cmd("systemctl --user import-environment WAYLAND_DISPLAY XDG_CURRENT_DESKTOP && systemctl --user start panama-polkit-agent.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.
+20 -13
View File
@@ -180,23 +180,30 @@ bind(mod .. " + CTRL + L", hl.dsp.window.swap({ direction = "r" }), { descriptio
-- Resize (Forge: window-resize-<edge>-<increase|decrease>).
--
-- Forge resized one named edge at a time. Hyprland resizes the active window
-- along an axis and lets the layout decide which edge actually moves, so the
-- eight Forge keys collapse onto four behaviors. The pairing is kept
-- consistent with the original: Y/B/O/M are horizontal, I/P/U/N are vertical,
-- and "increase" always grows while "decrease" always shrinks.
-- Forge resized one named EDGE at a time: its resize() grows the window for a
-- positive amount in every direction, and the edge only decides which side
-- moves -- Y grew leftward, O grew rightward, and so on. Hyprland resizes along
-- an axis and lets the layout choose the border, so those eight distinct
-- behaviours collapse onto four and the direction is simply not expressible.
--
-- Because of that the sizes are deliberately INVERTED from Forge's naming.
-- Carried over faithfully, "increase" grew and "decrease" shrank, which was
-- correct on paper and wrong under the fingers: with the edge gone, the keys
-- that used to pull a window open from one side now push it from the other.
-- Gabriel uses these daily and reads Y/O as shrink and B/M as grow, so that is
-- what they do. Faithfulness to a mapping nobody can feel is not worth much.
local step = 60
bind(mod .. " + SHIFT + Y", hl.dsp.window.resize({ x = step, y = 0, relative = true }), { repeating = true, description = "Wider" })
bind(mod .. " + SHIFT + O", hl.dsp.window.resize({ x = step, y = 0, relative = true }), { repeating = true, description = "Wider" })
bind(mod .. " + SHIFT + B", hl.dsp.window.resize({ x = -step, y = 0, relative = true }), { repeating = true, description = "Narrower" })
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 + Y", hl.dsp.window.resize({ x = -step, y = 0, relative = true }), { repeating = true, description = "Narrower" })
bind(mod .. " + SHIFT + O", hl.dsp.window.resize({ x = -step, y = 0, relative = true }), { repeating = true, description = "Narrower" })
bind(mod .. " + SHIFT + B", hl.dsp.window.resize({ x = step, y = 0, relative = true }), { repeating = true, description = "Wider" })
bind(mod .. " + SHIFT + M", hl.dsp.window.resize({ x = step, y = 0, relative = true }), { repeating = true, description = "Wider" })
bind(mod .. " + SHIFT + I", hl.dsp.window.resize({ x = 0, y = -step, relative = true }), { repeating = true, description = "Shorter" })
bind(mod .. " + SHIFT + U", hl.dsp.window.resize({ x = 0, y = -step, relative = true }), { repeating = true, description = "Shorter" })
-- SUPER+SHIFT+P was double-bound with the color 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" })
bind(mod .. " + SHIFT + Comma", hl.dsp.window.resize({ x = 0, y = step, relative = true }), { repeating = true, description = "Taller" })
bind(mod .. " + SHIFT + N", hl.dsp.window.resize({ x = 0, y = step, relative = true }), { repeating = true, description = "Taller" })
-- Window cycling (GNOME: cycle-windows on SUPER+Tab), now with an overlay
-- showing what you are choosing between.
+12
View File
@@ -77,6 +77,18 @@ hl.window_rule({
center = true,
})
-- Quick Look. The GNOME previewer is what the file manager opens on space,
-- and it is an overlay rather than a window someone manages: tiled, it shoves
-- the file manager aside and has to be dismissed before the list is usable
-- again. Sized generously because a preview that needs zooming is not a
-- preview; it still gets a margin so the file underneath stays visible.
hl.window_rule({
match = { class = "^org\\.gnome\\.NautilusPreviewer$" },
float = true,
size = { "monitor_w * 0.7", "monitor_h * 0.8" },
center = true,
})
-- Portal dialogs (file chooser, screen share picker) should always float.
hl.window_rule({
match = { class = "^(xdg-desktop-portal-gtk|org\\.freedesktop\\.impl\\.portal\\.desktop\\.gtk|hyprland-share-picker)$" },
@@ -91,6 +91,31 @@ Singleton {
label: "Automatically hide the Dock",
detail: "Reveal it at the bottom edge when a workspace is occupied"
},
{
key: "dockPosition", type: "enum", def: "bottom", group: "dock",
label: "Position",
detail: "Which edge the Dock lives on",
options: [
{ value: "bottom", label: "Bottom" },
{ value: "left", label: "Left" },
{ value: "right", label: "Right" }
]
},
{
// A "json" value: the screen names the Dock appears on. Empty means
// every screen, which is both the sensible default and the right
// answer for the common single-monitor case -- storing a list of
// names there would go stale the moment a display is unplugged.
key: "dockScreens", type: "json", def: [], group: "dock",
label: "Screens",
detail: "Which displays show the Dock"
},
{
key: "dockIconSize", type: "int", def: 48, min: 32, max: 80, step: 4,
unit: "px", group: "dock",
label: "Icon size",
detail: "How large the Dock's application icons are drawn"
},
{
key: "dockRevealDelayMs", type: "int", def: 0, min: 0, max: 1000, step: 25,
unit: "ms",
@@ -107,6 +132,41 @@ Singleton {
},
// ── Focus ───────────────────────────────────────────────────────────
// ── Focus modes ─────────────────────────────────────────────────────
// A "json" value: named modes, each with what turns it on and what it
// does. Triggers rather than a scheduler -- a mode is on because a
// condition is true right now, which is re-evaluated rather than fired
// once. A schedule is one of those conditions ("is now inside this
// window?"), which is why suspend, a reboot mid-window, and a lid
// opened after the start time all behave correctly without special
// cases: there is no alarm to have missed.
//
// Gaming ships enabled because the behaviour already existed as
// gamingSilenceNotifications; Sleep ships disabled, because a desktop
// that starts silencing someone on first boot has overstepped.
{
key: "focusModes", type: "json", group: "focus",
label: "Focus modes",
detail: "What quiets this machine, and what turns it on",
def: [
{
id: "deep-work", name: "Deep work", enabled: true,
triggers: [{ kind: "manual" }],
durationMinutes: 45, silence: true, keepAwake: true, allow: []
},
{
id: "gaming", name: "Gaming", enabled: true,
triggers: [{ kind: "game" }],
durationMinutes: 0, silence: true, keepAwake: true, allow: []
},
{
id: "sleep", name: "Sleep", enabled: false,
triggers: [{ kind: "schedule", start: "23:30", end: "07:00",
days: [0, 1, 2, 3, 4, 5, 6] }],
durationMinutes: 0, silence: true, keepAwake: false, allow: []
}
]
},
{
key: "focusDurationMinutes", type: "int", def: 45, min: 5, max: 180, step: 5,
unit: "min",
@@ -702,6 +762,14 @@ Singleton {
detail: "Moves the magnified view in increments rather than gliding with the pointer",
hypr: { path: ["cursor", "zoom_rigid"], option: "cursor:zoom_rigid", readAs: "bool" }
},
{
key: "highContrast", type: "bool", def: false, group: "accessibility",
label: "High contrast",
detail: "Increases contrast in applications that support it. Modern GTK applications read this from the desktop portal and restyle themselves; older ones need a high-contrast theme, which is not installed here.",
// No hypr mapping: this is a GNOME interface setting the portal
// republishes as org.freedesktop.appearance contrast, which is what
// libadwaita actually reads. DesktopStyle applies it.
},
{
key: "dimInactive", type: "bool", def: false, group: "accessibility",
label: "Dim inactive windows",
@@ -716,6 +784,22 @@ Singleton {
hypr: { path: ["decoration", "dim_strength"], option: "decoration:dim_strength", readAs: "float" }
},
// ── Gaming ──────────────────────────────────────────────────────────
// What Panama does while a game runs. gamemode tells us when that
// starts and stops through its own hook scripts, so these are real
// behaviours rather than hints -- and each one is undone afterwards to
// whatever it was before, not to a default.
{
key: "gamingPerformanceProfile", type: "bool", def: true, group: "gaming",
label: "Use the performance power profile",
detail: "Switches while a game runs and switches back when it exits"
},
{
key: "gamingNotifyOnStart", type: "bool", def: false, group: "gaming",
label: "Say when Game Mode engages",
detail: "A notification when a game requests it, which is otherwise invisible"
},
// ── Night light ─────────────────────────────────────────────────────
{
key: "nightLightEnabled", type: "bool", def: false, group: "nightLight",
@@ -63,6 +63,11 @@ Singleton {
// ── Dock ────────────────────────────────────────────────────────────────
// Pinned apps, in order, taken from the GNOME dash favorites.
readonly property string dockPosition: DesktopPreferences.get("dockPosition")
readonly property var dockScreens: {
const stored = DesktopPreferences.get("dockScreens");
return Array.isArray(stored) ? stored : [];
}
readonly property var dockPinned: DesktopPreferences.get("dockPinned")
// Dash-to-Dock was set to intellihide against all windows: the dock hides
+23 -10
View File
@@ -59,15 +59,22 @@ Singleton {
//
// Blue is the shipped Prism -- blue leading, orchid following -- and stays
// the default.
//
// `gnome` is the nearest member of GNOME's own accent-color enum, which is
// a fixed list of nine we do not get to extend. It is what libadwaita
// applications -- Files, Papers, Loupe -- are told to use, so choosing an
// accent here recolors them too instead of leaving them in GNOME blue.
// Nearest by hue, not by name: "rose" maps to red rather than pink because
// it is the red role in this palette.
readonly property var accents: ({
"blue": { dark: "#82aaff", darkSecondary: "#b172b0", light: "#2e7de9", lightSecondary: "#9854f1", label: "Prism blue" },
"orchid": { dark: "#c099ff", darkSecondary: "#fca7ea", light: "#7847bd", lightSecondary: "#9854f1", label: "Orchid" },
"teal": { dark: "#86e1fc", darkSecondary: "#82aaff", light: "#007197", lightSecondary: "#2e7de9", label: "Teal" },
"green": { dark: "#c3e88d", darkSecondary: "#86e1fc", light: "#587539", lightSecondary: "#007197", label: "Green" },
"amber": { dark: "#ffc777", darkSecondary: "#ff966c", light: "#8c6c3e", lightSecondary: "#b15c00", label: "Amber" },
"orange": { dark: "#ff966c", darkSecondary: "#ff757f", light: "#b15c00", lightSecondary: "#c64343", label: "Orange" },
"rose": { dark: "#ff757f", darkSecondary: "#c099ff", light: "#f52a65", lightSecondary: "#9854f1", label: "Rose" },
"slate": { dark: "#828bb8", darkSecondary: "#82aaff", light: "#6172b0", lightSecondary: "#2e7de9", label: "Slate" }
"blue": { dark: "#82aaff", darkSecondary: "#b172b0", light: "#2e7de9", lightSecondary: "#9854f1", label: "Prism blue", gnome: "blue" },
"orchid": { dark: "#c099ff", darkSecondary: "#fca7ea", light: "#7847bd", lightSecondary: "#9854f1", label: "Orchid", gnome: "purple" },
"teal": { dark: "#86e1fc", darkSecondary: "#82aaff", light: "#007197", lightSecondary: "#2e7de9", label: "Teal", gnome: "teal" },
"green": { dark: "#c3e88d", darkSecondary: "#86e1fc", light: "#587539", lightSecondary: "#007197", label: "Green", gnome: "green" },
"amber": { dark: "#ffc777", darkSecondary: "#ff966c", light: "#8c6c3e", lightSecondary: "#b15c00", label: "Amber", gnome: "yellow" },
"orange": { dark: "#ff966c", darkSecondary: "#ff757f", light: "#b15c00", lightSecondary: "#c64343", label: "Orange", gnome: "orange" },
"rose": { dark: "#ff757f", darkSecondary: "#c099ff", light: "#f52a65", lightSecondary: "#9854f1", label: "Rose", gnome: "red" },
"slate": { dark: "#828bb8", darkSecondary: "#82aaff", light: "#6172b0", lightSecondary: "#2e7de9", label: "Slate", gnome: "slate" }
})
// Falls back to blue for an unknown name, so a settings file written by a
@@ -103,6 +110,13 @@ Singleton {
// light one, and text on it stops being legible.
readonly property real dockAlpha: root.dark ? 0.34 : 0.62
readonly property real popoverAlpha: root.dark ? 0.92 : 0.97
// Toasts sit a little lighter than a popover you opened on purpose. A
// notification arrives unbidden over whatever you were doing, and at full
// popover weight it reads as a dialog demanding an answer -- but the 6%
// foreground tint it used to have left the text competing with the desktop
// behind it. This is the point between the two.
readonly property real toastAlpha: root.dark ? 0.86 : 0.94
readonly property real overlayAlpha: root.dark ? 0.55 : 0.40
readonly property real hoverAlpha: root.dark ? 0.14 : 0.10
readonly property real activeAlpha: root.dark ? 0.24 : 0.18
@@ -112,7 +126,7 @@ Singleton {
readonly property int barGap: 6 // breathing room below the bar for popovers
readonly property int barSideMargin: 10 // inset for floating popovers
readonly property int dockIconSize: 48
readonly property int dockIconSize: DesktopPreferences.get("dockIconSize")
readonly property int dockPadding: 8
readonly property int dockGap: 8
readonly property int dockRadius: 20
@@ -121,7 +135,6 @@ Singleton {
readonly property int popoverPadding: 14
readonly property int popoverWidth: 380
readonly property int controlCenterWidth: 430
readonly property int controlCenterTopGap: 2
readonly property int cardRadius: 12
readonly property int pillRadius: 999
@@ -15,7 +15,11 @@ PanelWindow {
color: "transparent"
anchors.top: true
anchors.right: true
margins.top: Theme.barHeight + Theme.barGap * 2
// The gap ALONE, not the bar height plus the gap. exclusiveZone 0 means
// "reserve nothing, but respect what others reserved", so this surface
// already begins below the bar's zone -- adding the bar height here counted
// it twice and left the surface floating 48px under the bar instead of 12.
margins.top: Theme.barGap * 2
margins.right: Theme.barSideMargin
exclusiveZone: 0
implicitWidth: 350
@@ -20,8 +20,11 @@ PanelWindow {
top: true
right: true
}
// The gap ALONE, not the bar height plus the gap -- see the note in
// QuickSettings.qml: exclusiveZone 0 already places this below the bar's
// reserved zone, so adding the bar height counted it twice.
margins {
top: Theme.barHeight + Theme.barGap * 2
top: Theme.barGap * 2
right: Theme.barSideMargin
}
@@ -28,7 +28,11 @@ PanelWindow {
color: "transparent"
anchors.top: true
margins.top: Theme.barHeight + Theme.barGap * 2
// The gap ALONE, not the bar height plus the gap. exclusiveZone 0 means
// "reserve nothing, but respect what others reserved", so this surface
// already begins below the bar's zone -- adding the bar height here counted
// it twice and left the surface floating 48px under the bar instead of 12.
margins.top: Theme.barGap * 2
exclusiveZone: 0
implicitWidth: 760
+87 -11
View File
@@ -22,10 +22,34 @@ PanelWindow {
property var modelData: null
screen: root.modelData
// Which edge this dock lives on, and everything that follows from it. The
// bottom case is unchanged in every particular: same anchors, same
// geometry, same slide -- so a machine that never touches the setting sees
// exactly the dock it had.
readonly property string position: Settings.dockPosition
readonly property bool vertical: root.position === "left" || root.position === "right"
// Only on the screens asked for. An empty list means all of them, which is
// what a single-monitor machine wants and what an unplugged display should
// not be able to change.
readonly property bool onThisScreen: {
const wanted = Settings.dockScreens;
if (!wanted || wanted.length === 0)
return true;
return wanted.indexOf(String(root.screen?.name ?? "")) >= 0;
}
visible: root.onThisScreen
// A dock spans the edge it lives on, which means anchoring BOTH ends of
// that edge: bottom+left+right across the screen, or top+bottom plus one
// side down it. Anchoring only one end leaves the surface free to collapse
// to its implicit size on that axis -- a side dock came out one pixel tall.
anchors {
top: root.vertical
bottom: true
left: true
right: true
left: root.position !== "right"
right: root.position !== "left"
}
color: "transparent"
@@ -42,10 +66,13 @@ PanelWindow {
// ── Geometry ────────────────────────────────────────────────────────────
// Height = room for the tooltip above the bar + the bar + the gap under it.
readonly property int revealStripHeight: 3
readonly property int bottomMargin: Theme.barGap
readonly property int edgeMargin: Theme.barGap
readonly property int tooltipSpace: 34
implicitHeight: tooltipSpace + body.implicitHeight + bottomMargin
// Only the axis the dock is thin on gets an implicit size; the other is
// spanned by the anchors above. Setting both would fight them.
implicitHeight: root.vertical ? 0 : tooltipSpace + body.implicitHeight + edgeMargin
implicitWidth: root.vertical ? tooltipSpace + body.implicitWidth + edgeMargin : 0
// ── Intellihide ─────────────────────────────────────────────────────────
// This instance's own monitor, the same lookup Workspaces.qml uses to
@@ -140,22 +167,71 @@ PanelWindow {
id: pointer
}
// Revealed: the dock plus everything between it and the edge, so
// crossing the gap does not count as leaving. Hidden: a sliver along
// the edge the dock lives on, which is the only thing that can bring
// it back -- every other click passes through to the window beneath.
Item {
id: maskItem
x: root.revealed ? body.x : 0
y: root.revealed ? body.y : surface.height - root.revealStripHeight
width: root.revealed ? body.width : surface.width
height: root.revealed ? surface.height - body.y : root.revealStripHeight
x: {
if (!root.revealed)
return root.position === "right" ? surface.width - root.revealStripHeight : 0;
return root.position === "right" ? body.x : 0;
}
y: {
if (!root.revealed)
return root.vertical ? 0 : surface.height - root.revealStripHeight;
return root.vertical ? body.y : body.y;
}
width: {
if (!root.revealed)
return root.vertical ? root.revealStripHeight : surface.width;
return root.vertical
? (root.position === "right" ? surface.width - body.x : body.x + body.width)
: body.width;
}
height: {
if (!root.revealed)
return root.vertical ? surface.height : root.revealStripHeight;
return root.vertical ? body.height : surface.height - body.y;
}
}
DockBody {
id: body
anchors.horizontalCenter: parent.horizontalCenter
// Slides off the bottom edge when hidden.
y: root.revealed ? root.tooltipSpace : surface.height
vertical: root.vertical
leftSide: root.position === "left"
// Both axes are computed rather than anchored. Anchoring the centre
// on one axis and binding a position on the other looks tidier and
// is a conflict: an anchored centre owns that coordinate, so the
// binding beside it is fighting for the same value.
//
// Centred on the long axis; on the short one it sits a tooltip's
// width in from the edge when revealed, and off-screen when not.
x: {
if (!root.vertical)
return (surface.width - width) / 2;
if (root.position === "left")
return root.revealed ? root.tooltipSpace : -width;
return root.revealed ? surface.width - width - root.tooltipSpace : surface.width;
}
y: {
if (root.vertical)
return (surface.height - height) / 2;
return root.revealed ? root.tooltipSpace : surface.height;
}
opacity: root.revealed ? 1 : 0
Behavior on x {
NumberAnimation {
duration: root.revealed ? Theme.durDockReveal : Theme.durNormal
easing.type: root.revealed ? Easing.OutQuint : Easing.InCubic
}
}
// Asymmetric on purpose. Revealing is a response to something the
// user just did, so it has to feel immediate — any delay there
// reads as lag. Hiding is not a response to anything, so it can
+46 -16
View File
@@ -111,30 +111,51 @@ Rectangle {
inset: parent.radius
}
implicitWidth: row.implicitWidth + Theme.dockPadding * 2
implicitHeight: row.implicitHeight + Theme.dockPadding * 2
implicitWidth: strip.implicitWidth + Theme.dockPadding * 2
implicitHeight: strip.implicitHeight + Theme.dockPadding * 2
// The item the tooltip is currently describing, or null.
property Item hoveredItem: null
Row {
id: row
// Set by the Dock. A side dock runs the same strip down the screen instead
// of across it.
property bool vertical: false
// Which way a tooltip points on a side dock: away from the screen edge, so
// it never opens off-screen.
property bool leftSide: true
// Explicit rather than left to Grid's wrapping. This is always one line, so
// saying how many cells it holds is both simpler to read and immune to
// Grid's default column count quietly wrapping a long dock.
readonly property int cellCount: 2 + (root.items ? root.items.length : 0)
// A Grid rather than a Row so one declaration serves both orientations.
// Row and Column would each need their own children, and the cross-axis
// anchors that centre items in a Row (verticalCenter) are the wrong axis in
// a Column -- Grid centres through its own alignment properties instead,
// which is the same result without the anchors positioners disallow.
Grid {
id: strip
anchors.centerIn: parent
spacing: Theme.dockGap
rows: root.vertical ? root.cellCount : 1
columns: root.vertical ? 1 : root.cellCount
horizontalItemAlignment: Grid.AlignHCenter
verticalItemAlignment: Grid.AlignVCenter
ShowAppsButton {
id: showApps
anchors.verticalCenter: parent.verticalCenter
onEntered: root.hoveredItem = showApps
onExited: if (root.hoveredItem === showApps)
root.hoveredItem = null
}
// Separator between the launcher and the apps, as in GNOME's dash.
// Separator between the launcher and the apps, as in GNOME's dash. It
// turns with the dock: a hairline across a column, down a row.
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: 1
height: Theme.dockIconSize * 0.7
width: root.vertical ? Theme.dockIconSize * 0.7 : 1
height: root.vertical ? 1 : Theme.dockIconSize * 0.7
border.width: 0
color: Theme.alpha(Theme.fg, 0.14)
}
@@ -146,7 +167,6 @@ Rectangle {
id: dockItem
required property var modelData
app: modelData
anchors.verticalCenter: parent.verticalCenter
onEntered: root.hoveredItem = dockItem
onExited: if (root.hoveredItem === dockItem)
root.hoveredItem = null
@@ -172,16 +192,26 @@ Rectangle {
}
}
// Centered on the hovered item, clamped inside the dock. Reads the item's
// x directly (rather than mapToItem) so the binding re-evaluates when
// the row reflows.
// Centred on the hovered item along the dock's own axis, and placed just
// outside the edge it lives on. Reads the item's position directly
// (rather than mapToItem) so the binding re-evaluates when the strip
// reflows.
x: {
if (!root.hoveredItem)
return 0;
const center = row.x + root.hoveredItem.x + root.hoveredItem.width / 2;
return Math.max(4, Math.min(root.width - width - 4, center - width / 2));
if (root.vertical)
return root.leftSide ? root.width + 8 : -width - 8;
const centre = strip.x + root.hoveredItem.x + root.hoveredItem.width / 2;
return Math.max(4, Math.min(root.width - width - 4, centre - width / 2));
}
y: {
if (!root.hoveredItem)
return -height - 8;
if (!root.vertical)
return -height - 8;
const centre = strip.y + root.hoveredItem.y + root.hoveredItem.height / 2;
return Math.max(4, Math.min(root.height - height - 4, centre - height / 2));
}
y: -height - 8
width: tipLabel.implicitWidth + Theme.popoverPadding * 2
height: tipLabel.implicitHeight + 8
@@ -35,7 +35,17 @@ Rectangle {
implicitHeight: Math.max(layout.implicitHeight + 24, image.visible ? 96 : 0)
radius: Theme.cardRadius
border.width: 0
color: hover.containsMouse ? Theme.alpha(Theme.fg, 0.1) : Theme.alpha(Theme.fg, 0.06)
// A real popover surface, the same one the date menu and clipboard panel
// use, rather than a 6% foreground tint.
//
// A tint that faint has nothing behind it: the toast window is transparent,
// so the card was sitting directly on whatever happened to be on screen and
// the text competed with it. Notifications are the one surface someone reads
// without having chosen to look at it, so it has to be legible over a
// bright photo and a white document alike.
color: hover.containsMouse
? Theme.alpha(Theme.mix(Theme.bgPopover, Theme.fg, 0.06), Theme.toastAlpha)
: Theme.alpha(Theme.bgPopover, Theme.toastAlpha)
Behavior on color {
ColorAnimation {
@@ -17,7 +17,11 @@ PanelWindow {
anchors.top: true
anchors.right: true
margins.top: Theme.barHeight + Theme.barGap * 2
// The gap ALONE, not the bar height plus the gap. exclusiveZone 0 means
// "reserve nothing, but respect what others reserved", so this surface
// already begins below the bar's zone -- adding the bar height here counted
// it twice and left the surface floating 48px under the bar instead of 12.
margins.top: Theme.barGap * 2
margins.right: Theme.barSideMargin
exclusiveZone: 0
@@ -0,0 +1,157 @@
// The authentication prompt.
//
// Deliberately a Panama surface rather than the compositor's stock agent: this
// is the window that asks for the password to everything, and it was the one
// window on the desktop that looked like it belonged to something else.
//
// Two things here are security, not styling. It takes EXCLUSIVE keyboard focus,
// so keystrokes cannot reach the window underneath while a password is being
// typed. And the field is cleared on every exit path, including the ones nobody
// plans for.
import Quickshell
import Quickshell.Wayland
import QtQuick
import qs.config
import qs.services
import qs.modules.settings
PanelWindow {
id: root
visible: Polkit.active
color: "transparent"
anchors { top: true; bottom: true; left: true; right: true }
exclusiveZone: 0
WlrLayershell.namespace: "qs-polkit"
WlrLayershell.layer: WlrLayer.Overlay
// Exclusive, not OnDemand: a password prompt that lets keystrokes through
// to whatever is behind it is a keylogger with extra steps.
WlrLayershell.keyboardFocus: Polkit.active
? WlrKeyboardFocus.Exclusive
: WlrKeyboardFocus.None
onVisibleChanged: {
if (root.visible) {
field.text = "";
field.forceActiveFocus();
} else {
field.text = "";
}
}
// Dims what is behind, and swallows clicks so nothing outside the dialog
// can be operated while it is waiting.
Rectangle {
anchors.fill: parent
color: Theme.alpha(Theme.bgDark, Theme.overlayAlpha)
MouseArea {
anchors.fill: parent
// Clicking outside does nothing on purpose. Dismissing an
// authentication request by misclick, and having the thing that
// asked report a mysterious failure, is worse than an explicit
// Cancel.
hoverEnabled: true
}
}
Rectangle {
id: dialog
anchors.centerIn: parent
width: 420
implicitHeight: layout.implicitHeight + 44
radius: Theme.popoverRadius
color: Theme.alpha(Theme.bgPopover, Theme.popoverAlpha)
border.width: 1
border.color: Theme.alpha(Theme.fg, 0.1)
Column {
id: layout
anchors.centerIn: parent
width: parent.width - 44
spacing: 14
Text {
width: parent.width
text: "Authentication required"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeLarge
font.weight: Font.DemiBold
wrapMode: Text.WordWrap
}
Text {
width: parent.width
text: Polkit.message
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
wrapMode: Text.WordWrap
}
Text {
width: parent.width
visible: Polkit.users.length > 1
text: "Authenticating as " + Polkit.chosenUser
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
PasswordField {
id: field
width: parent.width
placeholder: "Password"
enabled: !Polkit.authenticating
onAccepted: {
if (field.text !== "")
Polkit.submit(field.text);
field.text = "";
}
}
Text {
width: parent.width
visible: Polkit.failureText !== ""
text: Polkit.failureText
color: Theme.danger
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
}
Row {
anchors.right: parent.right
spacing: 8
SettingsButton {
text: "Cancel"
onClicked: Polkit.cancel()
}
SettingsButton {
text: Polkit.authenticating ? "Checking…" : "Authenticate"
tone: "accent"
enabled: !Polkit.authenticating
onClicked: {
if (field.text !== "")
Polkit.submit(field.text);
field.text = "";
}
}
}
}
}
// Escape cancels, which is what every other dialog on this desktop does.
Item {
anchors.fill: parent
focus: true
Keys.onEscapePressed: Polkit.cancel()
}
}
@@ -0,0 +1,2 @@
module qs.modules.polkit
PolkitPrompt 1.0 PolkitPrompt.qml
@@ -36,9 +36,30 @@ Item {
Rectangle {
id: restingCard
// Shown when the section is closed; the expanded grid below replaces
// it when open.
readonly property bool shown: root.hasSelection && !root.expanded
width: parent.width
height: visible ? restingGrid.implicitHeight + 20 : 0
visible: root.hasSelection && !root.expanded
// Animated, and on the same curve and duration as the Section that
// replaces it. Previously this hid the instant `expanded` flipped:
// a Column skips invisible children, so everything above snapped up
// while the expanded grid was still sliding open underneath. That
// read as a glitch rather than as an animation, and only here,
// because this is the only control with a resting and an expanded
// form that swap.
height: restingCard.shown ? restingGrid.implicitHeight + 20 : 0
visible: height > 0
clip: true
Behavior on height {
NumberAnimation {
duration: Theme.durNormal
easing.type: Easing.OutCubic
}
}
radius: Theme.cardRadius + 2
color: Theme.alpha(Theme.warn, 0.028)
border.width: 1
@@ -20,9 +20,21 @@ PanelWindow {
// Hangs directly beneath the top-right status cluster. Two logical pixels
// preserve the Prism edge without making the surface feel detached.
//
// The margin is the gap ALONE, not the bar height plus the gap. An
// exclusiveZone of 0 means "reserve nothing, but respect what others
// reserved", so this surface already begins below the bar's 36px zone;
// adding the bar height here counted it twice and left the panel floating
// 38px under the bar instead of 2px. It also means the panel follows the
// bar on its own -- if the bar ever stops reserving space, this closes up
// against the top edge rather than hanging under nothing.
anchors.top: true
anchors.right: true
margins.top: Theme.barHeight + Theme.controlCenterTopGap
// The same gap every other popover uses. This had its own constant set to
// 2, which left the widest surface in the shell hanging ten pixels higher
// than the date menu beside it -- an early value nothing else converged on
// rather than a decision; it carried no reason, while barGap does.
margins.top: Theme.barGap * 2
margins.right: Theme.barSideMargin
exclusiveZone: 0
@@ -107,6 +107,27 @@ Item {
onExpanded: root.expand("wifi")
}
// Only present when there is a wired device at all -- most machines
// this runs on have one, but a laptop without a dock does not, and
// an Ethernet tile there would be a control for absent hardware.
Toggle {
width: root.cellWidth
visible: Connectivity.wiredDevice !== null
icon: Connectivity.wiredOn
? "network-wired-symbolic"
: "network-wired-disconnected-symbolic"
label: "Ethernet"
active: Connectivity.wiredOn
enabled: Connectivity.wiredAvailable
sublabel: {
if (Connectivity.wiredOn)
return Connectivity.wiredDevice.linkSpeed > 0
? Connectivity.wiredDevice.linkSpeed + " Mb/s" : "Connected";
return "Off";
}
onToggled: Connectivity.setWired(!Connectivity.wiredOn)
}
Toggle {
width: root.cellWidth
icon: root.btAdapter && root.btAdapter.enabled ? "bluetooth-active-symbolic" : "bluetooth-disabled-symbolic"
@@ -304,15 +325,45 @@ Item {
width: content.width
height: 36
ThemedIcon {
// The account's own picture, the same file the lock screen and the
// login screen read. A generic glyph sat here while a real avatar
// was already set, which made the desktop look like it did not know
// whose it was.
Item {
id: avatar
anchors.left: parent.left
anchors.leftMargin: 4
anchors.verticalCenter: parent.verticalCenter
width: 22
height: 22
ClippingRectangle {
anchors.fill: parent
radius: width / 2
color: "transparent"
visible: UserAccounts.avatarUrl !== ""
Image {
anchors.fill: parent
source: UserAccounts.avatarUrl
fillMode: Image.PreserveAspectCrop
// Replaced in place when the picture changes, so the
// cache has to be told to let go of the old one.
cache: false
asynchronous: true
sourceSize.width: 44
sourceSize.height: 44
}
}
ThemedIcon {
anchors.centerIn: parent
visible: UserAccounts.avatarUrl === ""
size: 20
icon: "avatar-default-symbolic"
iconFallback: "user-info-symbolic"
}
}
Text {
anchors.left: avatar.right
@@ -320,7 +371,9 @@ Item {
anchors.right: actions.left
anchors.rightMargin: 8
anchors.verticalCenter: parent.verticalCenter
text: Quickshell.env("USER") || "user"
text: UserAccounts.me
? UserAccounts.displayName(UserAccounts.me)
: (Quickshell.env("USER") || "user")
color: Theme.fg
elide: Text.ElideRight
font.family: Theme.fontFamily
@@ -27,7 +27,12 @@ SettingsPage {
title: "Text"
subtitle: "Scales text in applications. The shell's own panels are drawn at their design size, so they are unaffected."
SliderRow { setting: "textScale"; divider: false }
SliderRow { setting: "textScale" }
// Reaches GTK4 applications through the desktop portal, which
// republishes it as org.freedesktop.appearance contrast. No
// high-contrast theme is involved, and none is installed here -- older
// GTK3 applications will not change.
ToggleRow { setting: "highContrast"; divider: false }
}
SettingsCard {
@@ -19,6 +19,19 @@ SettingsPage {
property string expandedPicker: ""
// Which group of cards is on screen. Theme leads deliberately: light and
// dark is the control reached most often, and it used to be the third
// section down, below a wallpaper grid and the whole lock screen.
property string tab: "theme"
// Arriving from another page that named a section opens on it. Taken once
// rather than bound, so the tabs still work normally afterwards.
Component.onCompleted: {
const section = ShellState.takeSettingsSection();
if (section !== "")
root.tab = section;
}
title: "Appearance"
lede: "Tune the Prism shell and the applications that live inside it. The preview above is your real geometry, to scale."
@@ -44,7 +57,20 @@ SettingsPage {
}
}
SettingsTabs {
tabs: [
{ value: "theme", label: "Theme" },
{ value: "background", label: "Background" },
{ value: "type", label: "Typography" },
{ value: "windows", label: "Windows" },
{ value: "shell", label: "Shell" },
]
current: root.tab
onSelected: value => root.tab = value
}
SettingsCard {
visible: root.tab === "background"
title: "Background"
subtitle: Wallpaper.lastError !== ""
? Wallpaper.lastError
@@ -83,6 +109,7 @@ SettingsPage {
}
SettingsCard {
visible: root.tab === "background"
title: "Lock screen"
subtitle: LockScreen.lastError !== ""
? LockScreen.lastError
@@ -101,6 +128,7 @@ SettingsPage {
}
SettingsCard {
visible: root.tab === "theme"
title: "Color scheme"
subtitle: ColorScheme.lastError !== ""
? ColorScheme.lastError
@@ -116,6 +144,7 @@ SettingsPage {
}
SettingsCard {
visible: root.tab === "type"
title: "Shell typography"
subtitle: Fonts.lastError !== ""
? Fonts.lastError
@@ -157,6 +186,7 @@ SettingsPage {
}
SettingsCard {
visible: root.tab === "type"
title: "Application typography"
subtitle: DesktopStyle.lastError !== ""
? DesktopStyle.lastError
@@ -229,6 +259,7 @@ SettingsPage {
}
SettingsCard {
visible: root.tab === "type"
title: "Icons & pointer"
subtitle: DesktopStyle.lastError !== ""
? DesktopStyle.lastError
@@ -279,6 +310,7 @@ SettingsPage {
}
SettingsCard {
visible: root.tab === "windows"
title: "Titlebars"
subtitle: "For applications that draw GNOME-compatible titlebars. Hyprland itself does not add titlebar buttons to tiled windows."
@@ -288,6 +320,7 @@ SettingsPage {
}
SettingsCard {
visible: root.tab === "windows"
title: "Windows"
subtitle: "Spacing and shape of tiled windows. Each change is applied to the compositor and confirmed before it is saved."
@@ -302,6 +335,7 @@ SettingsPage {
}
SettingsCard {
visible: root.tab === "theme"
title: "Effects"
subtitle: "Each of these costs frame time. Turning one off is a legitimate way to buy it back while gaming."
@@ -318,6 +352,7 @@ SettingsPage {
}
SettingsCard {
visible: root.tab === "shell"
title: "Clock"
ToggleRow { setting: "use24Hour" }
@@ -326,6 +361,7 @@ SettingsPage {
}
SettingsCard {
visible: root.tab === "shell"
title: "System vitals"
subtitle: "Choose what appears beside the workspace indicator."
@@ -13,15 +13,26 @@ SettingsPage {
property string expandedRole: ""
property bool addingAutostart: false
// Which entry has been asked to be removed. Removal deletes a file, so
// it never happens on a first press.
property string confirmingAutostartRemoval: ""
readonly property var applications: DesktopEntries.applications.values
// Each role governs a whole family of types, not one representative: setting
// "Images" writes PNG, JPEG, WebP and the rest together, so a file manager
// can never open one image in a viewer and its neighbour in an editor.
// The detail line names the family the way someone would describe it.
readonly property var roles: [
{ key: "browser", label: "Browser", detail: "Web links and HTML pages", categorySets: [["webbrowser"]], terms: ["web browser", "browser"] },
{ key: "mail", label: "Mail", detail: "Email links", categorySets: [["email"]], terms: ["mail client", "email client"] },
{ key: "files", label: "Files", detail: "Folders and file locations", categorySets: [["filemanager"]], terms: ["file manager"] },
{ key: "terminal", label: "Terminal", detail: "Terminal links and command-line handoffs", categorySets: [["terminalemulator"]], terms: ["terminal emulator", "terminal"] },
{ key: "music", label: "Music", detail: "MP3 audio", categorySets: [["music"], ["audio", "player"]], terms: ["music player", "audio player"] },
{ key: "images", label: "Images", detail: "PNG images", categorySets: [], terms: ["image viewer", "image editor", "photo viewer", "photo editor", "picture viewer"] },
{ key: "video", label: "Video", detail: "MP4 video", categorySets: [["video"]], terms: ["video player", "movie player"] }
{ key: "images", label: "Images", detail: "PNG, JPEG, GIF, WebP, SVG and other pictures", categorySets: [], terms: ["image viewer", "image editor", "photo viewer", "photo editor", "picture viewer"] },
{ key: "music", label: "Music", detail: "MP3, FLAC, Ogg and other audio", categorySets: [["music"], ["audio", "player"]], terms: ["music player", "audio player"] },
{ key: "video", label: "Video", detail: "MP4, MKV, WebM and other video", categorySets: [["video"]], terms: ["video player", "movie player"] },
{ key: "documents", label: "Documents", detail: "PDF and EPUB documents", categorySets: [["office", "viewer"]], terms: ["document viewer", "pdf viewer", "ebook", "e-book"] },
{ key: "text", label: "Text", detail: "Plain text, Markdown, and source files", categorySets: [["texteditor"]], terms: ["text editor", "code editor"] },
{ key: "archives", label: "Archives", detail: "Zip, tar, and other archives", categorySets: [["archiving"], ["filemanager"]], terms: ["archive manager", "file roller", "file manager"] }
]
function desktopId(entry: var): string {
@@ -43,17 +54,27 @@ SettingsPage {
}
function matchesRole(entry: var, role: var): bool {
const rawCategories = Array.isArray(entry.categories)
? entry.categories
: [String(entry.categories ?? "")];
// DesktopEntries hands back a QML list, not a JavaScript array, so
// Array.isArray is false for it. The old code took that as "this is a
// string", stringified the list into "Network,WebBrowser" and then split
// on ";" only -- producing the single token "network,webbrowser", which
// matches no category at all.
//
// Nothing failed loudly. Browsers still appeared because their generic
// name contains "web browser", so the terms fallback carried the role
// by itself. Archives matched NOTHING, which meant that row could only
// ever offer the application it already had.
//
// Joining first and splitting on both separators handles the list form
// and a plain string equally.
const raw = entry.categories;
const joined = Array.isArray(raw) ? raw.join(";") : String(raw ?? "");
const categories = [];
for (const rawCategory of rawCategories) {
for (const value of String(rawCategory).split(";")) {
for (const value of joined.split(/[;,]/)) {
const category = value.trim().toLowerCase();
if (category !== "")
categories.push(category);
}
}
const metadata = [entry.name, entry.genericName]
.map(value => String(value ?? "").toLowerCase())
.join(" ");
@@ -95,14 +116,46 @@ SettingsPage {
width: parent.width
SettingRow {
id: roleRow
readonly property bool open: root.expandedRole === roleBlock.modelData.key
label: roleBlock.modelData.label
detail: roleBlock.modelData.detail
value: DefaultApps.busy ? "Loading…" : (
activatable: roleBlock.choices.length > 0 && !DefaultApps.busy
controlWidth: 210
// Drawn rather than left to SettingRow's plain value text, so
// the row carries the same chevron a PickerRow does. These
// open a chooser but looked completely inert without it.
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 9
Text {
anchors.verticalCenter: parent.verticalCenter
text: DefaultApps.busy ? "Loading…" : (
roleBlock.selectedEntry
? root.displayName(roleBlock.selectedEntry)
: (root.currentHandler(roleBlock.modelData.key) || "Not set")
)
activatable: roleBlock.choices.length > 0 && !DefaultApps.busy
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
elide: Text.ElideRight
}
Text {
anchors.verticalCenter: parent.verticalCenter
visible: roleBlock.choices.length > 0
text: roleRow.open ? "\u25B4" : "\u25BE"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
divider: root.expandedRole !== roleBlock.modelData.key && roleBlock.index < root.roles.length - 1
onActivated: {
root.expandedRole = root.expandedRole === roleBlock.modelData.key
@@ -219,12 +272,50 @@ SettingsPage {
required property var modelData
required property int index
readonly property bool confirming:
root.confirmingAutostartRemoval === String(autostartRow.modelData.id)
label: autostartRow.modelData.name
detail: autostartRow.modelData.id
value: autostartRow.modelData.enabled ? "Enabled" : "Disabled"
activatable: !DefaultApps.busy
detail: autostartRow.confirming
? "Removing deletes this entry. Turning it off instead is reversible."
: autostartRow.modelData.id
divider: autostartRow.index < DefaultApps.autostartEntries.length - 1
onActivated: DefaultApps.setAutostart(autostartRow.modelData.id, !autostartRow.modelData.enabled)
controlWidth: 210
// A switch, not the words "Enabled"/"Disabled". The row always
// toggled on click, but read as static text, so a control that
// worked looked like a status nobody could change.
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 9
SettingsButton {
anchors.verticalCenter: parent.verticalCenter
visible: autostartRow.confirming
text: "Remove it"
tone: "danger"
enabled: !DefaultApps.busy
onClicked: {
root.confirmingAutostartRemoval = "";
DefaultApps.removeAutostart(String(autostartRow.modelData.id));
}
}
SettingsButton {
anchors.verticalCenter: parent.verticalCenter
text: autostartRow.confirming ? "Keep" : "Remove…"
enabled: !DefaultApps.busy
onClicked: root.confirmingAutostartRemoval =
autostartRow.confirming ? "" : String(autostartRow.modelData.id)
}
SettingsToggle {
anchors.verticalCenter: parent.verticalCenter
checked: autostartRow.modelData.enabled
onToggled: value => DefaultApps.setAutostart(autostartRow.modelData.id, value)
}
}
}
}
}
@@ -0,0 +1,231 @@
// Choosing which part of a picture becomes the profile picture.
//
// Without this, whatever was picked went to accountsservice whole, and a
// landscape photograph became a squashed thumbnail with the subject off the
// edge. The circle is drawn where the avatar is actually round elsewhere in the
// shell, so what is framed here is what appears there.
//
// The crop is reported in the picture's OWN pixels, not screen ones, so the
// result does not depend on the size this happened to be displayed at.
import QtQuick
import qs.config
Item {
id: root
// Absolute path of the picture being framed.
property string source: ""
// Side of the square viewport, in screen pixels.
readonly property int viewport: 260
// How far in the picture is zoomed, as a multiple of the smallest scale
// that still covers the viewport. 1 means "just covers".
property real zoom: 1
readonly property real maxZoom: 4
// Top-left of the drawn picture, relative to the viewport's top-left.
property real offsetX: 0
property real offsetY: 0
readonly property bool ready: picture.status === Image.Ready
&& picture.sourceSize.width > 0 && picture.sourceSize.height > 0
// The scale at which the shorter side exactly fills the viewport. Any
// smaller and the square would include area the picture does not cover.
readonly property real baseScale: root.ready
? root.viewport / Math.min(picture.sourceSize.width, picture.sourceSize.height)
: 1
readonly property real scale: root.baseScale * root.zoom
readonly property real drawnWidth: root.ready ? picture.sourceSize.width * root.scale : 0
readonly property real drawnHeight: root.ready ? picture.sourceSize.height * root.scale : 0
// Emitted with a square in the picture's own pixel coordinates.
signal cropped(int x, int y, int size)
signal cancelled
implicitWidth: parent ? parent.width : 620
implicitHeight: column.implicitHeight
// Keeps the viewport covered: the picture may never be dragged far enough
// to expose an edge, so the crop is always entirely inside the image.
function clamp(): void {
root.offsetX = Math.min(0, Math.max(root.viewport - root.drawnWidth, root.offsetX));
root.offsetY = Math.min(0, Math.max(root.viewport - root.drawnHeight, root.offsetY));
}
// Start centred, filling the viewport.
function reset(): void {
root.zoom = 1;
root.offsetX = (root.viewport - root.drawnWidth) / 2;
root.offsetY = (root.viewport - root.drawnHeight) / 2;
}
onReadyChanged: if (root.ready) root.reset()
onZoomChanged: root.clamp()
Column {
id: column
width: parent.width
spacing: 14
Row {
spacing: 20
Rectangle {
id: frame
width: root.viewport
height: root.viewport
radius: 12
clip: true
color: Theme.bgDark
border.width: 1
border.color: Theme.alpha(Theme.fg, 0.09)
Image {
id: picture
source: root.source === "" ? "" : "file://" + root.source
x: root.offsetX
y: root.offsetY
width: root.drawnWidth
height: root.drawnHeight
fillMode: Image.Stretch
asynchronous: true
// The picture is drawn at whatever size the frame needs, so
// decoding it at full resolution wastes memory on a photo.
sourceSize.width: 1600
sourceSize.height: 1600
smooth: true
}
// The round mask: a dimming fill with the circle punched out of
// it, then the ring drawn on top. Painted once per change, not
// continuously.
Canvas {
id: mask
anchors.fill: parent
onPaint: {
const context = mask.getContext("2d");
context.clearRect(0, 0, mask.width, mask.height);
context.fillStyle = Qt.rgba(0.12, 0.13, 0.19, 0.62);
context.fillRect(0, 0, mask.width, mask.height);
context.globalCompositeOperation = "destination-out";
context.beginPath();
context.arc(mask.width / 2, mask.height / 2,
mask.width / 2 - 8, 0, Math.PI * 2);
context.fill();
context.globalCompositeOperation = "source-over";
context.strokeStyle = Qt.rgba(0.78, 0.83, 0.96, 0.85);
context.lineWidth = 2;
context.beginPath();
context.arc(mask.width / 2, mask.height / 2,
mask.width / 2 - 8, 0, Math.PI * 2);
context.stroke();
}
}
// The offset is tracked from where the press started rather than
// accumulated per frame, which would drift.
MouseArea {
anchors.fill: parent
enabled: root.ready
cursorShape: Qt.OpenHandCursor
property real pressX: 0
property real pressY: 0
property real originX: 0
property real originY: 0
onPressed: mouse => {
pressX = mouse.x; pressY = mouse.y;
originX = root.offsetX; originY = root.offsetY;
}
onPositionChanged: mouse => {
if (!pressed)
return;
root.offsetX = originX + (mouse.x - pressX);
root.offsetY = originY + (mouse.y - pressY);
root.clamp();
}
onWheel: wheel => {
const before = root.scale;
root.zoom = Math.max(1, Math.min(root.maxZoom,
root.zoom * (wheel.angleDelta.y > 0 ? 1.1 : 1 / 1.1)));
// Keep the centre of the frame pointing at the same part
// of the picture, so zooming does not walk the subject
// out of the circle.
const ratio = root.scale / before;
root.offsetX = root.viewport / 2 - (root.viewport / 2 - root.offsetX) * ratio;
root.offsetY = root.viewport / 2 - (root.viewport / 2 - root.offsetY) * ratio;
root.clamp();
}
}
}
Column {
width: column.width - frame.width - 20
spacing: 12
Text {
width: parent.width
text: root.ready
? "Drag to reposition, scroll to zoom."
: (root.source === "" ? "" : "Opening the picture…")
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
wrapMode: Text.WordWrap
}
Text {
width: parent.width
visible: root.ready
text: "The circle is what other people see. Written out at 512 × 512."
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
}
Text {
width: parent.width
visible: picture.status === Image.Error
text: "That file could not be opened as a picture."
color: Theme.danger
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
}
Row {
spacing: 8
SettingsButton {
text: "Set picture"
tone: "accent"
enabled: root.ready
onClicked: {
// Screen coordinates back into the picture's own.
const size = root.viewport / root.scale;
root.cropped(Math.round(-root.offsetX / root.scale),
Math.round(-root.offsetY / root.scale),
Math.round(size));
}
}
SettingsButton {
text: "Reset"
enabled: root.ready
onClicked: root.reset()
}
SettingsButton {
text: "Cancel"
onClicked: root.cancelled()
}
}
}
}
}
}
@@ -0,0 +1,32 @@
// Choosing a profile picture.
//
// A thin wrapper on the same FileDialog the phone controls use, so picking a
// picture is the ordinary file chooser rather than something this desktop
// invented. Nothing privileged happens here -- reading a file the user selected
// needs no authorization; only setting it on the account does.
import QtQuick
import QtQuick.Dialogs
Item {
id: root
signal picked(path: string)
function open(): void {
dialog.open();
}
FileDialog {
id: dialog
title: "Choose a profile picture"
fileMode: FileDialog.OpenFile
nameFilters: ["Pictures (*.png *.jpg *.jpeg *.webp *.gif *.bmp)", "All files (*)"]
onAccepted: {
const value = String(dialog.selectedFile);
if (!value.startsWith("file://"))
return;
root.picked(decodeURIComponent(value.slice(7)));
}
}
}
@@ -37,11 +37,27 @@ SettingsPage {
title: "Wired"
visible: Connectivity.wiredDevice !== null
TextRow {
SwitchRow {
label: "Ethernet"
detail: Connectivity.wiredDevice ? Connectivity.wiredDevice.name : ""
value: Connectivity.wiredDevice && Connectivity.wiredDevice.connected ? "Connected" : "Not connected"
// Three states worth telling apart: on, off but plugged in, and
// nothing in the socket. "Not connected" covered all three and
// explained none of them.
detail: {
const device = Connectivity.wiredDevice;
if (!device)
return "";
if (device.connected)
return device.name + (device.linkSpeed > 0
? " · " + device.linkSpeed + " Mb/s" : "");
// Not "no cable": that cannot be told apart from "switched off"
// by anything reliable here, and guessing produced a switch
// that blamed the hardware for what it had just done itself.
return device.name + " · off";
}
checked: Connectivity.wiredOn
enabled: Connectivity.wiredAvailable
divider: false
onToggled: value => Connectivity.setWired(value)
}
}
@@ -169,24 +185,15 @@ SettingsPage {
SettingsCard {
title: "Owned by Fedora"
subtitle: "VPNs, per-connection routing, printers, and online accounts are configured by GNOME's panels, which are installed and searchable."
subtitle: "VPNs and per-connection routing are still configured by GNOME's panel, which is installed and searchable. Printers and online accounts have their own pages here."
ActionRow {
label: "Network connections"
detail: "VPN, proxies, and per-connection settings"
action: "Open"
divider: false
onTriggered: SystemSettings.openGnomePanel("network")
}
ActionRow {
label: "Printers"
action: "Open"
onTriggered: SystemSettings.openGnomePanel("printers")
}
ActionRow {
label: "Online accounts"
action: "Open"
divider: false
onTriggered: SystemSettings.openGnomePanel("online-accounts")
}
}
}
@@ -0,0 +1,558 @@
// Rootless podman-compose stacks, led by what needs attention.
//
// The grouping is the compose project, because that is the unit a person thinks
// in: not "thirteen containers" but "the Command Center database" and "the
// capacity planner's Supabase". State decides prominence within that grouping --
// what is running gets rows, what is stopped collapses to a line -- so neither
// axis has to be chosen over the other.
//
// The findings at the top are the same crossing the Firewall page reports, seen
// from the side that can close it: the firewall knows only that something is
// listening, while this page knows which container, which compose file, and
// which token is missing from it.
import Quickshell
import QtQuick
import qs.config
import qs.services
Item {
id: root
objectName: "containers"
// "images", "volumes", or empty. Removal never happens on a first press.
property string confirmingPrune: ""
// Projects whose stopped containers have been expanded, by project name.
property var expanded: []
function isExpanded(name: string): bool { return root.expanded.indexOf(name) >= 0; }
function toggleExpanded(name: string): void {
root.expanded = root.isExpanded(name)
? root.expanded.filter(entry => entry !== name)
: root.expanded.concat([name]);
}
function uptimeOf(container: var): string {
const status = String(container.status ?? "");
// podman already phrases this well ("Up 32 hours (healthy)"); the health
// is shown separately, so only the duration is wanted here.
const match = /^Up ([^(]+)/.exec(status);
return match ? match[1].trim() : "";
}
function portSummary(container: var): string {
const ports = container.ports ?? [];
if (ports.length === 0)
return "no published ports";
return ports.map(port => {
const host = String(port.hostIp ?? "");
const where = host === "" ? "every interface" : host;
return where + ":" + port.hostPort + " → " + port.containerPort;
}).join(", ");
}
Component.onCompleted: Containers.refresh()
// ── the list ────────────────────────────────────────────────────────────
SettingsPage {
anchors.fill: parent
visible: Containers.logTarget === ""
title: "Containers"
lede: "Local services you run for development — what they expose, and what they cost."
TextRow {
visible: Containers.lastError !== ""
label: "That did not work"
detail: Containers.lastError
value: ""
divider: false
}
TextRow {
visible: Containers.scanned && !Containers.available
label: "podman is not available"
detail: "Nothing here can be shown until podman is installed."
value: ""
divider: false
}
// ── finding: published to the network ───────────────────────────────
SettingsCard {
visible: Containers.reachable.length > 0
title: Containers.reachable.length === 1
? "A container is published to your whole network"
: "Containers are published to your whole network"
subtitle: "These bind every interface, so any machine on your network can connect. "
+ "For a development database, loopback is almost always what you want. "
+ "Binding adds an address to the compose file and changes nothing else."
Repeater {
model: Containers.reachable
delegate: SettingRow {
required property var modelData
width: parent.width
label: String(modelData.container ?? "")
detail: "Port " + modelData.hostPort + "/" + String(modelData.protocol ?? "tcp")
+ " · " + String(modelData.image ?? "")
+ (modelData.configFile
? "\n" + Containers.shorten(String(modelData.configFile))
: "\nNo compose file is recorded for this container, so it cannot be bound from here.")
controlWidth: 250
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
SettingsButton {
text: "Open compose file"
visible: String(modelData.configFile ?? "") !== ""
enabled: !Containers.busy
onClicked: Containers.openFile(String(modelData.configFile))
}
SettingsButton {
text: "Bind to localhost"
tone: "accent"
// Needs both halves of the label to find the entry in
// the compose file; the Supabase CLI records neither.
visible: String(modelData.configFile ?? "") !== ""
&& String(modelData.service ?? "") !== ""
enabled: !Containers.busy
onClicked: Containers.bindLocal(
String(modelData.project), String(modelData.service))
}
}
}
}
TextRow {
visible: Containers.reachable.length > 0
label: "After binding"
detail: "The change takes effect the next time the stack comes up, because a "
+ "published port is fixed when the container is created."
value: ""
divider: false
}
}
// ── finding: disk nothing references ────────────────────────────────
SettingsCard {
visible: Containers.unusedImages.length > 0 || Containers.unusedVolumes.length > 0
title: Containers.formatBytes(Containers.reclaimable) + " nothing is using"
subtitle: "Images and volumes no container references. Removing them frees the space; "
+ "anything still needed downloads again on next use."
Repeater {
model: Containers.unusedImages.slice(0, 5)
delegate: TextRow {
required property var modelData
width: parent.width
label: String(modelData.name ?? "")
detail: ""
value: Containers.formatBytes(Number(modelData.size ?? 0))
}
}
TextRow {
visible: Containers.unusedImages.length > 5
label: "and " + (Containers.unusedImages.length - 5) + " more"
detail: ""
value: ""
}
SettingRow {
width: parent.width
label: root.confirmingPrune === "images"
? "Remove " + Containers.unusedImages.length + " images?"
: "Unused images"
detail: root.confirmingPrune === "images"
? "Each is removed by name. Nothing that a container references is touched."
: Containers.unusedImages.length + " images, "
+ Containers.formatBytes(Number(Containers.disk.imagesReclaimable ?? 0))
visible: Containers.unusedImages.length > 0
controlWidth: 230
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
SettingsButton {
text: root.confirmingPrune === "images" ? "Keep them" : "Remove…"
enabled: !Containers.busy
onClicked: root.confirmingPrune =
root.confirmingPrune === "images" ? "" : "images"
}
SettingsButton {
visible: root.confirmingPrune === "images"
text: "Remove them"
tone: "danger"
enabled: !Containers.busy
onClicked: {
root.confirmingPrune = "";
Containers.pruneImages();
}
}
}
}
SettingRow {
width: parent.width
visible: Containers.unusedVolumes.length > 0
label: root.confirmingPrune === "volumes"
? "Remove " + Containers.unusedVolumes.length + " volumes?"
: "Unused volumes"
detail: root.confirmingPrune === "volumes"
? "A volume holds data. These are the ones podman reports as referenced by "
+ "nothing, but removing them cannot be undone."
: Containers.unusedVolumes.length + " volumes, "
+ Containers.formatBytes(Number(Containers.disk.volumesReclaimable ?? 0))
controlWidth: 230
divider: false
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
SettingsButton {
text: root.confirmingPrune === "volumes" ? "Keep them" : "Remove…"
enabled: !Containers.busy
onClicked: root.confirmingPrune =
root.confirmingPrune === "volumes" ? "" : "volumes"
}
SettingsButton {
visible: root.confirmingPrune === "volumes"
text: "Remove them"
tone: "danger"
enabled: !Containers.busy
onClicked: {
root.confirmingPrune = "";
Containers.pruneVolumes();
}
}
}
}
}
// ── the stacks ──────────────────────────────────────────────────────
Repeater {
model: Containers.projects
delegate: SettingsCard {
id: projectCard
required property var modelData
readonly property var runningSet:
(projectCard.modelData.containers ?? []).filter(c => c.state === "running")
readonly property var stoppedSet:
(projectCard.modelData.containers ?? []).filter(c => c.state !== "running")
readonly property string projectName: String(projectCard.modelData.name ?? "")
title: String(projectCard.modelData.title ?? "")
subtitle: {
const total = Number(projectCard.modelData.total ?? 0);
const up = Number(projectCard.modelData.running ?? 0);
const where = String(projectCard.modelData.configFile ?? "");
const counts = up === 0
? total + (total === 1 ? " container, stopped" : " containers, all stopped")
: up + " of " + total + " running";
return where === "" ? counts : counts + " · " + Containers.shorten(where);
}
SettingRow {
width: parent.width
label: "The whole stack"
detail: projectCard.runningSet.length === 0
? "Starts every container this project defines."
: "Stopping leaves the containers in place; nothing is removed."
controlWidth: 250
divider: projectCard.runningSet.length > 0
|| projectCard.stoppedSet.length > 0
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
SettingsButton {
text: "Start all"
visible: projectCard.stoppedSet.length > 0
enabled: !Containers.busy
onClicked: Containers.startProject(projectCard.projectName)
}
SettingsButton {
text: "Restart all"
visible: projectCard.runningSet.length > 0
enabled: !Containers.busy
onClicked: Containers.restartProject(projectCard.projectName)
}
SettingsButton {
text: "Stop all"
visible: projectCard.runningSet.length > 0
enabled: !Containers.busy
onClicked: Containers.stopProject(projectCard.projectName)
}
}
}
Repeater {
model: projectCard.runningSet
delegate: SettingRow {
required property var modelData
width: parent.width
label: String(modelData.name ?? "")
detail: String(modelData.image ?? "") + " · " + root.portSummary(modelData)
value: {
const health = String(modelData.health ?? "");
const up = root.uptimeOf(modelData);
if (health !== "" && up !== "")
return health + " · " + up;
return health !== "" ? health : up;
}
controlWidth: 250
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
SettingsButton {
text: "Logs"
onClicked: Containers.openLogs(String(modelData.name))
}
SettingsButton {
text: "Restart"
enabled: !Containers.busy
onClicked: Containers.restart(String(modelData.name))
}
SettingsButton {
text: "Stop"
enabled: !Containers.busy
onClicked: Containers.stop(String(modelData.name))
}
}
}
}
SettingRow {
width: parent.width
visible: projectCard.stoppedSet.length > 0
activatable: true
divider: root.isExpanded(projectCard.projectName)
label: (root.isExpanded(projectCard.projectName) ? "▾ " : "▸ ")
+ projectCard.stoppedSet.length
+ (projectCard.stoppedSet.length === 1
? " stopped container" : " stopped containers")
detail: {
const bad = projectCard.stoppedSet.filter(c => Number(c.exitCode ?? 0) !== 0);
return bad.length === 0
? ""
: bad.length + (bad.length === 1 ? " exited" : " exited") + " badly.";
}
onActivated: root.toggleExpanded(projectCard.projectName)
}
Repeater {
model: root.isExpanded(projectCard.projectName) ? projectCard.stoppedSet : []
delegate: SettingRow {
required property var modelData
required property int index
width: parent.width
label: String(modelData.name ?? "")
detail: String(modelData.image ?? "") + " · " + String(modelData.status ?? "")
controlWidth: 180
divider: index < projectCard.stoppedSet.length - 1
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
SettingsButton {
text: "Logs"
onClicked: Containers.openLogs(String(modelData.name))
}
SettingsButton {
text: "Start"
enabled: !Containers.busy
onClicked: Containers.start(String(modelData.name))
}
}
}
}
}
}
// ── containers no compose project claims ────────────────────────────
SettingsCard {
visible: Containers.loose.length > 0
title: "Not part of a project"
subtitle: "Started directly rather than by a compose file."
Repeater {
model: Containers.loose
delegate: SettingRow {
required property var modelData
required property int index
width: parent.width
label: String(modelData.name ?? "")
detail: String(modelData.image ?? "") + " · " + String(modelData.status ?? "")
controlWidth: 180
divider: index < Containers.loose.length - 1
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
SettingsButton {
text: "Logs"
onClicked: Containers.openLogs(String(modelData.name))
}
SettingsButton {
text: modelData.state === "running" ? "Stop" : "Start"
enabled: !Containers.busy
onClicked: modelData.state === "running"
? Containers.stop(String(modelData.name))
: Containers.start(String(modelData.name))
}
}
}
}
}
TextRow {
visible: Containers.scanned && Containers.available && Containers.total === 0
label: "No containers"
detail: "Nothing has been created on this machine yet."
value: ""
divider: false
}
}
// ── logs ────────────────────────────────────────────────────────────────
//
// A drill-in rather than a panel inside the scrolling page: logs need their
// own scrollback, and nesting one scrolling view inside another makes the
// wheel ambiguous over the region where you most want to use it.
Item {
anchors.fill: parent
visible: Containers.logTarget !== ""
Column {
id: logHeader
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.leftMargin: 34
anchors.rightMargin: 34
anchors.topMargin: 30
spacing: 6
Row {
width: parent.width
spacing: 12
SettingsButton {
text: "‹ Back"
anchors.verticalCenter: parent.verticalCenter
onClicked: Containers.closeLogs()
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: Containers.logTarget
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: 22
font.weight: Font.DemiBold
}
}
Text {
width: parent.width
text: Containers.logError !== ""
? Containers.logError
: (Containers.logFollowing
? "Following. The last " + Containers.logLines.count + " lines are shown."
: "The stream has ended.")
color: Containers.logError !== "" ? Theme.danger : Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
}
}
Rectangle {
anchors.left: parent.left
anchors.right: parent.right
anchors.top: logHeader.bottom
anchors.bottom: parent.bottom
anchors.leftMargin: 34
anchors.rightMargin: 34
anchors.topMargin: 14
anchors.bottomMargin: 30
radius: Theme.cardRadius + 2
color: Theme.alpha(Theme.bgDark, 0.75)
border.width: 1
border.color: Theme.alpha(Theme.fg, 0.07)
ListView {
id: logList
anchors.fill: parent
anchors.margins: 12
clip: true
model: Containers.logLines
spacing: 1
boundsBehavior: Flickable.StopAtBounds
cacheBuffer: 400
// Stay pinned to the newest line while the reader is already at
// the bottom, and leave the view alone the moment they scroll up
// to read something.
property bool pinned: true
onContentYChanged: logList.pinned =
logList.contentY >= logList.contentHeight - logList.height - 24
onCountChanged: if (logList.pinned) logList.positionViewAtEnd()
delegate: Text {
required property string line
width: logList.width - 24
text: line
color: Theme.fgDim
font.family: Theme.fontMono
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WrapAnywhere
textFormat: Text.PlainText
}
}
}
}
}
@@ -50,8 +50,16 @@ SettingsPage {
SettingsCard {
title: "Timezone"
subtitle: "Currently " + (DateTime.timezone === "" ? "unknown" : DateTime.timezone)
+ ". Type to narrow the list."
PickerRow {
id: zonePicker
label: "Time zone"
detail: DateTime.timezone === ""
? "Reading the system clock"
: DateTime.cityOf(DateTime.timezone) + " — " + DateTime.regionOf(DateTime.timezone)
value: DateTime.timezone === "" ? "Unknown" : DateTime.cityOf(DateTime.timezone)
divider: false
SearchField {
id: zoneSearch
@@ -72,7 +80,10 @@ SettingsPage {
controlWidth: 90
divider: index < root.matchingZones.length - 1
activatable: true
onActivated: DateTime.setTimezone(modelData)
onActivated: {
DateTime.setTimezone(modelData);
zonePicker.collapse();
}
}
}
@@ -83,6 +94,7 @@ SettingsPage {
divider: false
}
}
}
SettingsCard {
visible: DateTime.lastError !== ""
@@ -8,21 +8,100 @@
// them.
import QtQuick
import Quickshell
import qs.config
import qs.services
SettingsPage {
id: root
// Turning the last screen off would leave no dock anywhere and no obvious
// way back, so the final one cannot be removed -- it collapses to "every
// screen" instead, which is the same thing on one display and recoverable
// on several.
function toggleDockScreen(name: string): void {
const all = Quickshell.screens.map(screen => String(screen.name));
const current = Settings.dockScreens.length === 0
? all.slice()
: Settings.dockScreens.map(String);
const at = current.indexOf(name);
let next = current.slice();
if (at >= 0)
next.splice(at, 1);
else
next.push(name);
if (next.length === 0 || next.length === all.length)
next = [];
DesktopPreferences.set("dockScreens", next);
}
title: "Desktop & Dock"
lede: "Keep the shell instant, spatial, and out of your way."
SettingsCard {
title: "Dock"
ChoiceRow { setting: "dockPosition" }
// One row per connected screen. Nothing selected means every screen,
// which is stated rather than left as an empty list somebody has to
// interpret -- and it is what a single-monitor machine should do
// without being configured at all.
SettingRow {
label: "Screens"
detail: Settings.dockScreens.length === 0
? "On every display"
: "On " + Settings.dockScreens.length + " of "
+ Quickshell.screens.length + " displays"
visible: Quickshell.screens.length > 1
controlWidth: 260
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 7
Repeater {
model: Quickshell.screens
delegate: Rectangle {
id: screenPill
required property var modelData
readonly property string screenName: String(screenPill.modelData.name ?? "")
// An empty list means all, so every pill reads as on.
readonly property bool on: Settings.dockScreens.length === 0
|| Settings.dockScreens.indexOf(screenPill.screenName) >= 0
width: pillLabel.implicitWidth + 20
height: 28
radius: 8
color: screenPill.on ? Theme.alpha(Theme.accent, 0.22)
: Theme.alpha(Theme.fg, 0.06)
border.width: screenPill.on ? 1 : 0
border.color: Theme.alpha(Theme.accent, 0.5)
Text {
id: pillLabel
anchors.centerIn: parent
text: screenPill.screenName
color: screenPill.on ? Theme.fg : Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
HoverHandler { cursorShape: Qt.PointingHandCursor }
TapHandler { onTapped: root.toggleDockScreen(screenPill.screenName) }
}
}
}
}
ToggleRow { setting: "dockAutohide" }
SliderRow { setting: "dockRevealDelayMs"; zeroLabel: "Instant" }
SliderRow { setting: "dockHideDelayMs"; zeroLabel: "Instant"; divider: false }
SliderRow { setting: "dockHideDelayMs"; zeroLabel: "Instant" }
SliderRow { setting: "dockIconSize"; divider: false }
}
SettingsCard {
@@ -62,7 +141,7 @@ SettingsPage {
detail: "Adjusted on the Appearance page, beside a live preview"
action: "Open Appearance"
divider: false
onTriggered: ShellState.openSettings("appearance")
onTriggered: ShellState.openSettingsSection("appearance", "windows")
}
}
@@ -122,6 +201,71 @@ SettingsPage {
SliderRow { setting: "focusDurationMinutes"; divider: false }
}
// Distinct from the snapshots below, which put THIS machine back as it
// was. This carries settings to a different one, and deliberately leaves
// behind anything that describes hardware.
SettingsCard {
title: "Carry settings to another machine"
subtitle: SettingsSync.lastError !== ""
? SettingsSync.lastError
: "Everything except what describes this machine: the display arrangement stays here."
ActionRow {
label: "Export"
detail: SettingsSync.lastAction === "export" && SettingsSync.carried > 0
? SettingsSync.carried + " settings written to " + SettingsSync.defaultPath
: "Writes " + SettingsSync.defaultPath
action: "Export"
enabled: !SettingsSync.busy
onTriggered: SettingsSync.exportTo(SettingsSync.defaultPath)
}
ActionRow {
label: "See what an import would change"
detail: SettingsSync.previewed
? SettingsSync.changes.length + " would change, "
+ SettingsSync.skipped.length + " skipped"
: "Reads " + SettingsSync.defaultPath + " without applying anything"
action: "Preview"
enabled: !SettingsSync.busy
onTriggered: SettingsSync.preview(SettingsSync.defaultPath)
}
// Only offered once a preview has said what it would do. Importing
// settings sight unseen is how somebody ends up wondering why their
// desktop changed.
ActionRow {
visible: SettingsSync.previewed && SettingsSync.changes.length > 0
label: "Apply those " + SettingsSync.changes.length + " changes"
detail: "Settings the file does not mention are left alone"
action: "Import"
enabled: !SettingsSync.busy
onTriggered: SettingsSync.importFrom(SettingsSync.defaultPath)
}
Repeater {
model: SettingsSync.previewed ? SettingsSync.skipped : []
delegate: TextRow {
required property var modelData
width: parent.width
label: String(modelData.key ?? "")
detail: "Skipped: " + String(modelData.reason ?? "")
value: ""
}
}
TextRow {
visible: SettingsSync.lastAction === "import" && SettingsSync.lastError === ""
label: SettingsSync.applied === 0
? "Nothing needed changing"
: SettingsSync.applied + " settings applied"
detail: "From " + (SettingsSync.exportedFrom || "the export")
value: ""
divider: false
}
}
SettingsCard {
title: "Snapshots"
subtitle: SettingsBackup.lastError !== ""
@@ -15,6 +15,9 @@ Column {
property var monitor: null
property bool enabled: true
// Emitted once a mode has been asked for, so a container can put the list
// away. Applying is still this component's job; closing is not.
signal picked
spacing: 0
readonly property var grouped: {
@@ -106,11 +109,14 @@ Column {
TapHandler {
enabled: root.enabled && !rate.selected
onTapped: Displays.apply(
onTapped: {
Displays.apply(
root.monitor.name,
rate.modelData.mode,
Displays.nearestCleanScale(rate.modelData.mode, root.monitor.scale),
root.monitor.transform)
root.monitor.transform);
root.picked();
}
}
}
}
@@ -27,6 +27,15 @@ SettingsPage {
? root.monitor.mode
: ""
// Every mode at the resolution in use, which is what a refresh-rate choice
// actually is: the same width and height at a different rate.
readonly property var ratesForCurrentResolution: {
if (!root.monitor || !root.monitor.modes)
return [];
return root.monitor.modes.filter(mode => mode.width === root.monitor.width
&& mode.height === root.monitor.height);
}
function syncSelectedOutput(): void {
if (!Displays.monitorNamed(root.selectedOutput))
root.selectedOutput = Displays.monitors.length > 0 ? Displays.monitors[0].name : "";
@@ -175,10 +184,61 @@ SettingsPage {
title: "Resolution"
subtitle: "Applied straight away, then reverted automatically unless you confirm."
PickerRow {
id: modePicker
label: "Resolution"
detail: root.monitor
? root.monitor.width + " × " + root.monitor.height + " native"
: "No display selected"
value: root.monitor
? root.monitor.width + " × " + root.monitor.height
: ""
enabled: !Displays.awaitingConfirmation && !Displays.busy
divider: false
DisplayModePicker {
width: parent.width
monitor: root.monitor
enabled: !Displays.awaitingConfirmation && !Displays.busy
onPicked: modePicker.collapse()
}
}
// Refresh rate on its own, because the rates for a resolution used to be
// reachable only by opening the resolution list -- which is now
// collapsed, so changing only the rate meant going through the mode you
// already had.
PickerRow {
id: ratePicker
label: "Refresh rate"
detail: "Rates this display offers at " + (root.monitor
? root.monitor.width + " × " + root.monitor.height
: "the current resolution")
value: root.monitor ? root.monitor.refreshRate.toFixed(2) + " Hz" : ""
visible: root.ratesForCurrentResolution.length > 1
enabled: !Displays.awaitingConfirmation && !Displays.busy
divider: false
Repeater {
model: root.ratesForCurrentResolution
delegate: TextRow {
required property var modelData
required property int index
width: parent.width
label: String(modelData.refreshLabel ?? "")
value: Displays.modeIsCurrent(root.monitor, modelData) ? "Current" : ""
controlWidth: 90
divider: index < root.ratesForCurrentResolution.length - 1
activatable: !Displays.modeIsCurrent(root.monitor, modelData)
onActivated: {
root.applyWith({ mode: modelData.mode });
ratePicker.collapse();
}
}
}
}
}
@@ -45,6 +45,50 @@ Column {
DesktopPreferences.set("dockPinned", next);
}
// ── Dragging ────────────────────────────────────────────────────────────
//
// By a grip rather than the whole row. The objection this file used to
// record -- that dragging inside a Flickable inside a scrolling page is
// hard to get right and fails in a way that reads as breakage -- is real,
// and the answer is preventStealing on the grip: the Flickable cannot take
// a gesture that started there, so a vertical drag reorders instead of
// scrolling the page out from under it. The arrow buttons stay, because
// they are the keyboard-reachable path and a grip is not.
//
// The order is held here while the drag runs and written once on release.
// Committing on every slot crossed would rewrite settings.json a dozen
// times for one gesture.
property int draggingIndex: -1
property var workingOrder: []
readonly property var displayed: root.draggingIndex >= 0 ? root.workingOrder : root.pinned
function beginDrag(index: int): void {
root.workingOrder = root.pinned.slice();
root.draggingIndex = index;
}
function dragTo(target: int): void {
if (root.draggingIndex < 0 || target === root.draggingIndex)
return;
if (target < 0 || target >= root.workingOrder.length)
return;
const next = root.workingOrder.slice();
const moved = next.splice(root.draggingIndex, 1)[0];
next.splice(target, 0, moved);
root.workingOrder = next;
root.draggingIndex = target;
}
function endDrag(): void {
if (root.draggingIndex < 0)
return;
const next = root.workingOrder.slice();
root.draggingIndex = -1;
root.workingOrder = [];
root.commit(next);
}
function move(from: int, to: int): void {
if (to < 0 || to >= root.pinned.length)
return;
@@ -67,7 +111,7 @@ Column {
}
Repeater {
model: root.pinned
model: root.displayed
SettingRow {
id: pin
@@ -76,15 +120,71 @@ Column {
required property int index
label: root.nameFor(pin.modelData)
detail: pin.modelData
// The desktop id is shown only when the name alone would not say
// which entry this is. It is developer text, and repeating it under
// fifteen recognisable application names is noise that makes the
// list harder to scan, not easier.
detail: root.pinned.filter(other =>
root.nameFor(other) === root.nameFor(pin.modelData)).length > 1
? pin.modelData
: ""
divider: pin.index < root.pinned.length - 1
controlWidth: 132
// Lifted while dragging so the row being moved is the one that
// looks moved.
z: root.draggingIndex === pin.index ? 2 : 0
opacity: root.draggingIndex === pin.index ? 0.85 : 1
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 4
// The grip. preventStealing is the whole reason this works
// inside a scrolling page: without it the Flickable claims the
// vertical gesture and the row never moves.
Item {
width: 26
height: 26
anchors.verticalCenter: parent.verticalCenter
Text {
anchors.centerIn: parent
text: "\u2261"
color: root.draggingIndex === pin.index ? Theme.accent : Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: 15
}
MouseArea {
id: grip
anchors.fill: parent
preventStealing: true
cursorShape: Qt.SizeVerCursor
property real pressY: 0
onPressed: mouse => {
grip.pressY = mouse.y;
root.beginDrag(pin.index);
}
onPositionChanged: mouse => {
if (root.draggingIndex < 0 || pin.height <= 0)
return;
// How many whole rows the pointer has travelled from
// where it started. Rounded, so the swap happens as
// the grip passes the midpoint of the next row.
const travelled = (mouse.y - grip.pressY);
const slots = Math.round(travelled / pin.height);
if (slots !== 0)
root.dragTo(root.draggingIndex + slots);
}
onReleased: root.endDrag()
onCanceled: root.endDrag()
}
}
SettingsButton {
text: "↑"
enabled: pin.index > 0
@@ -0,0 +1,267 @@
// The firewall, led by what another machine can actually reach.
//
// A rules list alone is not an answer: a port is reachable only when something
// is listening on a network address AND the firewall permits it. On this
// machine that crossing is the whole story -- the rules look unremarkable while
// a database and a cache sit open, because Fedora Workstation's zone opens
// every port above 1024 and rootless containers publish on all interfaces.
//
// Rich rules are shown but never edited. They are a syntax rather than a
// setting, and a page that half-supports a syntax is a trap -- but hiding them
// would mean the page misrepresents the configuration.
import Quickshell
import QtQuick
import qs.config
import qs.services
SettingsPage {
id: root
objectName: "firewall"
title: "Firewall"
lede: "What another machine on your network can reach, and what allows it."
property string confirmingRemoval: ""
property bool confirmingRange: false
Component.onCompleted: Firewall.refresh()
TextRow {
visible: Firewall.lastError !== ""
label: "The firewall needs attention"
detail: Firewall.lastError
value: ""
divider: false
}
// ── The finding, when there is one ───────────────────────────────────────
SettingsCard {
visible: Firewall.exposedDataStores.length > 0
title: Firewall.exposedDataStores.length === 1
? "A database is reachable from your network"
: "Databases are reachable from your network"
subtitle: {
const names = Firewall.exposedDataStores.map(entry => String(entry.name));
return names.join(" and ") + " "
+ (names.length === 1 ? "is" : "are")
+ " listening on every interface, and this zone permits it. Anyone on your network can connect.";
}
Repeater {
model: Firewall.exposedDataStores
delegate: TextRow {
required property var modelData
required property int index
width: parent.width
label: String(modelData.name ?? "")
detail: "Port " + modelData.port + "/" + String(modelData.protocol ?? "")
+ (String(modelData.process ?? "") !== ""
? " · " + String(modelData.process) : "")
+ " · allowed by " + String(modelData.allowedBy ?? "")
value: ""
divider: index < Firewall.exposedDataStores.length - 1
}
}
}
// ── Everything reachable ─────────────────────────────────────────────────
SettingsCard {
title: "Reachable right now"
subtitle: !Firewall.scanned
? "Checking what is listening and what the firewall permits…"
: (Firewall.available
? "Listening on a network address, and permitted by the firewall. Both have to be true."
: "The firewall is not running, so nothing here is being filtered.")
Repeater {
model: Firewall.exposed
delegate: TextRow {
required property var modelData
required property int index
width: parent.width
label: String(modelData.name ?? "")
detail: "Port " + modelData.port + "/" + String(modelData.protocol ?? "")
+ (String(modelData.process ?? "") !== "" && String(modelData.process) !== String(modelData.name)
? " · " + String(modelData.process) : "")
+ " · allowed by " + String(modelData.allowedBy ?? "")
value: String(modelData.kind ?? "") === "data" ? "Database" : ""
divider: index < Firewall.exposed.length - 1
}
}
TextRow {
visible: Firewall.exposed.length === 0 && Firewall.scanned && Firewall.available
label: "Nothing is reachable"
detail: "No service is both listening on a network address and permitted"
value: ""
divider: false
}
}
// ── The rules that allow it ──────────────────────────────────────────────
SettingsCard {
visible: Firewall.zone !== null
title: "What this zone allows"
subtitle: Firewall.zone
? String(Firewall.zone.name) + ", applied to "
+ (Firewall.zone.interfaces ?? []).join(" and ")
: ""
// The single rule that explains almost every row above.
Column {
width: parent.width
visible: Firewall.wideOpen
SettingRow {
width: parent.width
label: "Ports " + Firewall.openRanges.join(", ")
detail: root.confirmingRange
? "Closing this cuts off " + Firewall.rangeDependents().length
+ " reachable service" + (Firewall.rangeDependents().length === 1 ? "" : "s")
+ ", including " + Firewall.rangeDependents().slice(0, 3)
.map(entry => String(entry.name)).join(", ")
+ ". Anything that needs a port will have to be allowed by name."
: "Fedora Workstation opens these so applications can listen without asking. It is why most of the list above is reachable."
controlWidth: 230
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
SettingsButton {
text: root.confirmingRange ? "Keep it open" : "Close the range…"
enabled: !Firewall.busy
onClicked: root.confirmingRange = !root.confirmingRange
}
SettingsButton {
visible: root.confirmingRange
text: "Close it"
tone: "danger"
enabled: !Firewall.busy
onClicked: {
root.confirmingRange = false;
for (const spec of Firewall.openRanges)
Firewall.removePort(String(spec));
}
}
}
}
}
Repeater {
model: Firewall.zone?.services ?? []
delegate: SettingRow {
id: serviceRow
required property var modelData
required property int index
readonly property string serviceName: String(serviceRow.modelData)
readonly property bool confirming: root.confirmingRemoval === serviceRow.serviceName
// Removing ssh while someone is connected over it ends their
// session. Worth saying before, not after.
readonly property bool risky: serviceRow.serviceName === "ssh"
&& Firewall.sshSessions > 0
width: parent.width
label: serviceRow.serviceName
detail: serviceRow.confirming
? (serviceRow.risky
? "Someone is connected over SSH right now. Removing this ends that session."
: "Anything relying on this service stops being reachable.")
: "Allowed by name, so it works whatever the port range says"
controlWidth: 210
divider: serviceRow.index < (Firewall.zone?.services ?? []).length - 1
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
SettingsButton {
text: serviceRow.confirming ? "Keep" : "Remove…"
enabled: !Firewall.busy
onClicked: root.confirmingRemoval =
serviceRow.confirming ? "" : serviceRow.serviceName
}
SettingsButton {
visible: serviceRow.confirming
text: "Remove"
tone: "danger"
enabled: !Firewall.busy
onClicked: {
root.confirmingRemoval = "";
Firewall.removeService(serviceRow.serviceName);
}
}
}
}
}
// Shown, never edited.
TextRow {
visible: (Firewall.zone?.richRules ?? []).length > 0
label: "Rich rules"
detail: "Custom rules in firewalld's own syntax. Shown here so this page does not misrepresent your configuration; edit them with firewall-cmd."
value: (Firewall.zone?.richRules ?? []).length + " defined"
divider: false
}
}
// ── Zones ────────────────────────────────────────────────────────────────
SettingsCard {
title: "Zones"
subtitle: "A zone is a set of rules. Each network connection uses one."
Repeater {
model: Object.keys(Firewall.activeZones ?? ({}))
delegate: TextRow {
required property var modelData
required property int index
width: parent.width
label: String(modelData)
detail: "Applied to " + (Firewall.activeZones[String(modelData)] ?? []).join(", ")
value: String(modelData) === Firewall.defaultZone ? "Default" : ""
divider: true
}
}
TextRow {
label: "Default for new connections"
detail: "Used when a network does not ask for a particular zone"
value: Firewall.defaultZone
divider: false
}
}
// ── The service underneath ───────────────────────────────────────────────
SettingsCard {
title: "Firewall service"
TextRow {
label: "firewalld"
detail: !Firewall.scanned
? "Reading the firewall's state…"
: (Firewall.running
? (Firewall.enabledAtBoot
? "Running, and starts with the system"
: "Running, but not started at boot")
: "Not running, so nothing is being filtered")
value: !Firewall.scanned ? "Checking…" : (Firewall.running ? "Running" : "Stopped")
divider: false
}
}
}
@@ -0,0 +1,253 @@
// Gaming: what the machine is doing now, and what it should do while you play.
//
// Live first, because unlike every other page here this one has a genuinely
// live dimension -- "is Game Mode actually on, and how hot is the card" is the
// question that brings someone here mid-session.
//
// The part that makes this Panama's page rather than a gamemode config editor
// is "While a game is running": gamemode runs a script when a game starts and
// another when it exits, so the power profile and Do Not Disturb can follow the
// game and be put back afterwards -- back to what they were, not to a default.
import Quickshell
import QtQuick
import qs.config
import qs.services
SettingsPage {
id: root
objectName: "gaming"
title: "Gaming"
lede: "What this machine is doing, and how it should behave while you play."
readonly property var gpu: Gaming.primaryGpu
// Polling only while this page is on screen.
Component.onCompleted: {
Gaming.refresh();
Gaming.watching = true;
}
Component.onDestruction: Gaming.watching = false
TextRow {
visible: Gaming.lastError !== ""
label: "Gaming needs attention"
detail: Gaming.lastError
value: ""
divider: false
}
// ── Right now ────────────────────────────────────────────────────────────
SettingsCard {
Column {
width: parent.width
spacing: 8
Row {
width: parent.width
spacing: 12
Text {
text: Gaming.active ? "Playing" : "Idle"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeTitle
font.weight: Font.DemiBold
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: Gaming.active
? "Game Mode is engaged"
: "No game has requested Game Mode"
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
}
}
Repeater {
model: Gaming.gpus
delegate: TextRow {
required property var modelData
required property int index
width: parent.width
label: modelData.discrete ? "Graphics card" : "Integrated graphics"
detail: String(modelData.driver ?? "")
+ (modelData.discrete ? " · the one games use" : " · idle unless something asks for it")
value: Gaming.gpuSummary(modelData)
divider: true
}
}
TextRow {
label: "CPU governor"
detail: "What the cores are scaling to right now"
value: String(Gaming.gameMode?.governorNow ?? "unknown")
divider: false
}
}
// ── What Panama does about it ────────────────────────────────────────────
SettingsCard {
title: "While a game is running"
subtitle: Gaming.gameMode?.hooksInstalled === true
? "Panama reacts when Game Mode engages, and puts everything back when the game exits."
: "Panama can react when Game Mode engages. This needs a hook in gamemode's configuration."
ActionRow {
visible: Gaming.gameMode?.hooksInstalled !== true
label: "Let Panama react to games"
detail: "Adds a start and end hook to your gamemode configuration. Nothing else in that file is touched."
action: "Enable"
enabled: !Gaming.busy && Gaming.gameMode?.available === true
onTriggered: Gaming.installHooks()
}
ToggleRow {
visible: Gaming.gameMode?.hooksInstalled === true
setting: "gamingPerformanceProfile"
}
// Silencing while a game runs is a focus mode with a trigger, and it
// now lives with the others -- so "something silenced my notifications"
// has one answer rather than two places to look.
ActionRow {
visible: Gaming.gameMode?.hooksInstalled === true
label: "Silence notifications"
detail: {
const mode = FocusModes.modes.find(entry => entry.id === "gaming");
if (!mode)
return "Handled by a focus mode on Notifications & Focus.";
return mode.enabled === true
? "Handled by the Gaming focus mode, which is on."
: "The Gaming focus mode is off, so notifications are not silenced.";
}
action: "Open Focus"
onTriggered: ShellState.settingsPage = "notifications"
}
ToggleRow {
visible: Gaming.gameMode?.hooksInstalled === true
setting: "gamingNotifyOnStart"
}
ActionRow {
visible: Gaming.gameMode?.hooksInstalled === true
label: "Stop reacting to games"
detail: "Removes the hooks. The settings above are kept."
action: "Disable"
enabled: !Gaming.busy
divider: false
onTriggered: Gaming.removeHooks()
}
}
// ── Game Mode itself ─────────────────────────────────────────────────────
SettingsCard {
title: "Game Mode"
subtitle: Gaming.gameMode?.available === true
? "Applied by gamemode to a game while it runs, then undone."
: "gamemode is not installed."
TextRow {
label: "Daemon"
detail: Gaming.gameMode?.daemonRunning === true
? "Running, waiting for a game to ask"
: "Not running, so no game can request it"
value: Gaming.gameMode?.daemonRunning === true ? "Running" : "Stopped"
}
// Said plainly rather than implied: on a machine already running the
// governor gamemode would switch to, its headline effect is nothing.
TextRow {
label: "Governor while gaming"
detail: Gaming.governorAlreadyThere
? "This machine already runs that governor, so Game Mode changes nothing here"
: "Switched for the duration of the game"
value: String(Gaming.gameMode?.governorWhileGaming ?? "unknown")
divider: false
}
}
// ── Overlay ──────────────────────────────────────────────────────────────
SettingsCard {
title: "Performance overlay"
subtitle: Gaming.overlay?.installed === true
? "MangoHud, drawn on top of the game."
: "MangoHud is not installed."
SwitchRow {
label: "Show the overlay in games"
detail: "Takes effect for games launched after your next sign-in, because it is read from the session environment"
checked: Gaming.overlay?.globallyEnabled === true
enabled: !Gaming.busy && Gaming.overlay?.installed === true
onToggled: value => Gaming.setOverlay(value)
}
SegmentRow {
label: "What it shows"
detail: "Frame rate alone, or the full readout with GPU and CPU"
options: [
{ value: "fps", label: "Frame rate" },
{ value: "detailed", label: "Detailed" }
]
value: String(Gaming.overlay?.preset ?? "fps")
enabled: !Gaming.busy && Gaming.overlay?.installed === true
onSelected: value => Gaming.setOverlayPreset(value)
}
TextRow {
label: "Toggle in game"
detail: "Shows and hides the overlay without leaving the game"
value: String(Gaming.overlay?.toggleKey ?? "")
divider: false
}
}
// ── Library ──────────────────────────────────────────────────────────────
SettingsCard {
title: "Library"
subtitle: "Where games live, and what can run them."
TextRow {
label: "Installed games"
detail: String(Gaming.library?.path ?? "")
value: String(Gaming.library?.games ?? 0)
}
// Listed, not chosen. Steam picks the runtime per game in its own
// properties, and a control here would be claiming an authority this
// page does not have.
Repeater {
model: Gaming.library?.protonBuilds ?? []
delegate: TextRow {
required property var modelData
required property int index
width: parent.width
label: String(modelData.name ?? "")
detail: modelData.community === true
? "Community build · chosen per game in Steam"
: "Valve · chosen per game in Steam"
value: "Installed"
divider: true
}
}
TextRow {
label: "gamescope"
detail: "Micro-compositor for scaling and frame limiting, used per game from Steam"
value: Gaming.library?.gamescope === true ? "Available" : "Not installed"
divider: false
}
}
}
@@ -422,22 +422,6 @@ SettingsPage {
}
}
ActionRow {
objectName: "health-fedora-handoff:users"
label: "Users"
detail: "Accounts, passwords, and automatic login"
action: "Open users"
onTriggered: SystemSettings.openGnomePanel("system", "users")
}
ActionRow {
objectName: "health-fedora-handoff:sharing"
label: "Sharing"
detail: "Remote desktop, media sharing, and remote login"
action: "Open sharing"
onTriggered: SystemSettings.openGnomePanel("sharing")
}
ActionRow {
objectName: "health-fedora-handoff:color"
label: "Color profiles"
@@ -1,3 +1,19 @@
// Home — the page you open to do something, not to read a report.
//
// It used to lead with a diagram of the attached monitor: DP-2, 4500 x 3000,
// 1.13x scale, XRGB2101010. That is Displays-page data, it was the largest
// thing on the screen, and nobody has ever needed it here. Below it sat a
// permanently open search field for a weather location that is set about once a
// year, and then roughly half a page of nothing.
//
// What Panama already knows is the useful material: whether anything needs
// attention, what is running, and the two or three things worth doing next. A
// finding is shown when there is one and the page is quiet when there is not,
// which is the same shape the Firewall and Containers pages use.
//
// Weather stays. It is the one genuinely ambient thing here, and it belongs
// next to a greeting rather than in a card of its own with a search box open.
import QtQuick
import qs.config
import qs.services
@@ -6,119 +22,101 @@ SettingsPage {
id: root
readonly property int openedHour: new Date().getHours()
readonly property string greeting: openedHour < 12 ? "Good morning" : (openedHour < 18 ? "Good afternoon" : "Good evening")
readonly property string greeting: openedHour < 12
? "Good morning"
: (openedHour < 18 ? "Good afternoon" : "Good evening")
// Findings, in the order they deserve attention. Each names the page that
// can actually resolve it, because a home page that reports a problem it
// cannot help with is just an alarm.
readonly property var findings: {
const found = [];
const exposed = Firewall.exposedDataStores ?? [];
if (exposed.length > 0) {
found.push({
label: exposed.length === 1
? "A database is reachable from your network"
: "Databases are reachable from your network",
detail: exposed.map(entry => String(entry.name)).join(" and ")
+ ", published on every interface",
page: "firewall",
action: "Review"
});
}
if (Updates.securityCount > 0) {
found.push({
label: Updates.securityCount + " update"
+ (Updates.securityCount === 1 ? "" : "s") + " carry a security advisory",
detail: "Worth installing before the rest",
page: "updates",
action: "Open"
});
}
if (Updates.rebootNeeded) {
found.push({
label: "A newer kernel is installed than the one running",
detail: "Restart to use it",
page: "updates",
action: "Open"
});
}
if (Health.summary?.errors > 0 || Health.summary?.warnings > 0) {
const count = Number(Health.summary?.errors ?? 0) + Number(Health.summary?.warnings ?? 0);
found.push({
label: count + " health check" + (count === 1 ? "" : "s") + " need attention",
detail: "Something the desktop owns is not in its expected state",
page: "services",
action: "Open"
});
}
return found;
}
// One line of everything that is fine, so the quiet case still says
// something rather than showing an empty page.
readonly property string reassurance: {
const parts = [];
if (Health.checks.length > 0)
parts.push(Health.checks.length + " health checks pass");
if (Disks.rootFilesystem)
parts.push(Disks.formatBytes(Number(Disks.rootFilesystem.available ?? 0)) + " free");
const newest = Snapshots.configs.length > 0
? (Snapshots.configs[0].snapshots ?? [])[0]
: null;
if (newest)
parts.push("snapshots ran at " + root.clockOf(String(newest.date ?? "")));
return parts.join(" · ");
}
// snapper prints a full date; only the time is wanted in a summary line.
function clockOf(stamp: string): string {
const match = /(\d{1,2}:\d{2})(?::\d{2})?\s*(AM|PM)?/i.exec(stamp);
if (!match)
return stamp;
return match[2] ? match[1] + " " + match[2].toUpperCase() : match[1];
}
title: `${root.greeting}, Gabriel`
lede: "Your desktop is configured and ready."
lede: Weather.available
? Math.round(Weather.temperature) + Weather.unitSuffix + " and "
+ Weather.description.toLowerCase() + " in " + Settings.weatherLocation
: "Your desktop is configured and ready."
SettingsCard {
title: SystemSettings.monitorDescription || "Active display"
subtitle: SystemSettings.monitorName || "Detecting your display…"
Component.onCompleted: {
if (!Firewall.scanned)
Firewall.refresh();
if (!Containers.scanned)
Containers.refresh();
if (!Snapshots.scanned)
Snapshots.refresh();
if (!Disks.scanned)
Disks.refresh();
}
// ── What you came here to do ────────────────────────────────────────────
Grid {
id: monitorLayout
width: parent.width
columns: width >= 620 ? 2 : 1
columnSpacing: 28
rowSpacing: 16
Item {
width: monitorLayout.columns === 2
? (monitorLayout.width - monitorLayout.columnSpacing) * 0.47
: monitorLayout.width
height: 164
Rectangle {
width: Math.min(parent.width - 24, 260)
height: width * 0.64
anchors.horizontalCenter: parent.horizontalCenter
anchors.top: parent.top
anchors.topMargin: 5
radius: 12
color: Theme.alpha(Theme.bgDark, 0.94)
border.width: 1
border.color: Theme.alpha(Theme.accent, 0.34)
Rectangle {
anchors.fill: parent
anchors.margins: 10
radius: 7
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: 0; color: Theme.mix(Theme.bg, Theme.accent, 0.08) }
GradientStop { position: 1; color: Theme.mix(Theme.bg, Theme.accentSecondary, 0.08) }
}
Text {
anchors.centerIn: parent
text: SystemSettings.monitorName || "DISPLAY"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.letterSpacing: 1.5
}
}
}
}
Column {
width: monitorLayout.columns === 2
? (monitorLayout.width - monitorLayout.columnSpacing) * 0.47
: monitorLayout.width
height: monitorLayout.columns === 2 ? 164 : implicitHeight
spacing: 13
Text {
text: `${SystemSettings.monitorWidth} × ${SystemSettings.monitorHeight}`
color: Theme.fg
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSizeLarge
font.weight: Font.DemiBold
}
Text {
text: `${SystemSettings.monitorRefreshRate.toFixed(0)} Hz · ${SystemSettings.monitorScale.toFixed(1)}× scale`
color: Theme.fgDim
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSize
}
Text {
text: `${SystemSettings.monitorFormat || "Detecting format"} · ${SystemSettings.colorPreset || "standard color"}`
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
Text {
text: SystemSettings.autoHdr ? "Game-aware HDR is ready" : "Game-aware HDR is off"
color: SystemSettings.autoHdr ? Theme.ok : Theme.warn
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
}
}
}
SettingsCard {
title: "Weather"
subtitle: "Local conditions in the date menu"
TextRow {
label: "Location"
detail: "Only the search term is sent; the name below is a label kept on this machine"
value: Settings.weatherLocation
}
LocationPicker {
width: parent.width
}
ChoiceRow { setting: "temperatureUnit" }
SliderRow { setting: "weatherRefreshMinutes"; divider: false }
}
Grid {
id: summaryCards
id: doing
width: parent.width
columns: width >= 720 ? 2 : 1
@@ -126,20 +124,46 @@ SettingsPage {
rowSpacing: 16
SettingsCard {
width: summaryCards.columns === 2
? (summaryCards.width - summaryCards.columnSpacing) / 2
: summaryCards.width
title: "Quiet focus"
subtitle: "Notifications and focused work"
width: doing.columns === 2
? (doing.width - doing.columnSpacing) / 2
: doing.width
title: "Focus"
subtitle: FocusSession.active
? "A session is running · " + FocusSession.remainingText + " left"
: (FocusModes.active
? FocusModes.activeName + " is on because " + FocusModes.activeReason
: (Notifs.doNotDisturb
? "Do Not Disturb is on"
: "Nothing is quieting this machine"))
SettingRow {
label: FocusSession.active ? "End the session" : "Start a focus session"
detail: FocusSession.active
? "Puts Do Not Disturb and Caffeine back as they were"
: Settings.focusDurationMinutes + " minutes · Super+Shift+F"
controlWidth: 120
SettingsButton {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: FocusSession.active ? "End" : "Start"
tone: FocusSession.active ? "normal" : "accent"
onClicked: FocusSession.active ? FocusSession.end(false) : FocusSession.startDefault()
}
}
SettingRow {
label: "Do Not Disturb"
detail: Notifs.doNotDisturb ? "Banners are currently quiet" : "Notification banners are visible"
detail: FocusModes.active
? "Held on by the " + FocusModes.activeName + " mode"
: "Banners are held; the notification centre still fills"
divider: false
controlWidth: 48
SettingsToggle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
enabled: !FocusModes.active && !FocusSession.active
checked: Notifs.doNotDisturb
onToggled: value => Notifs.doNotDisturb = value
}
@@ -147,18 +171,119 @@ SettingsPage {
}
SettingsCard {
width: summaryCards.columns === 2
? (summaryCards.width - summaryCards.columnSpacing) / 2
: summaryCards.width
title: "Desktop services"
subtitle: "The essentials are running"
width: doing.columns === 2
? (doing.width - doing.columnSpacing) / 2
: doing.width
title: "Right now"
subtitle: Containers.running > 0
? Containers.running + " of " + Containers.total + " containers running"
: "No containers are running"
TextRow {
label: "Sync & remote access"
detail: `${SystemSettings.nextcloudActive ? "Nextcloud ready" : "Nextcloud stopped"} · ${SystemSettings.rustdeskActive ? "RustDesk ready" : "RustDesk stopped"}`
label: Containers.running > 0
? Containers.projects.filter(project => project.running > 0)
.map(project => String(project.title)).join(", ")
: "Nothing to report"
detail: Containers.running > 0
? "Started for development, still up"
: "Start a stack from the Containers page"
value: ""
}
ActionRow {
label: "Containers"
detail: "Projects, what they expose, and what they cost"
action: "Open"
divider: false
value: SystemSettings.nextcloudActive && SystemSettings.rustdeskActive ? "Healthy" : "Review"
onTriggered: ShellState.settingsPage = "containers"
}
}
}
// ── This machine ────────────────────────────────────────────────────────
SettingsCard {
title: "This machine"
subtitle: root.reassurance
TextRow {
visible: root.findings.length === 0
label: "Nothing needs your attention"
detail: "Anything worth acting on would appear here."
value: ""
divider: false
}
Repeater {
model: root.findings
delegate: SettingRow {
required property var modelData
required property int index
width: parent.width
label: String(modelData.label ?? "")
detail: String(modelData.detail ?? "")
controlWidth: 110
divider: index < root.findings.length - 1
SettingsButton {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: String(modelData.action ?? "Open")
onClicked: ShellState.settingsPage = String(modelData.page)
}
}
}
}
// ── Worth doing, when there is something ────────────────────────────────
SettingsCard {
visible: Updates.total > 0 || Containers.reclaimable > 0
title: "Do next"
subtitle: "Small things this machine is waiting on."
ActionRow {
visible: Updates.total > 0
label: "Install " + Updates.total + " update" + (Updates.total === 1 ? "" : "s")
detail: "Packages, applications and firmware, from wherever each comes from"
action: "Open"
divider: Containers.reclaimable > 0
onTriggered: ShellState.settingsPage = "updates"
}
ActionRow {
visible: Containers.reclaimable > 0
label: "Reclaim " + Containers.formatBytes(Containers.reclaimable)
detail: "Container images and volumes nothing references"
action: "Review"
divider: false
onTriggered: ShellState.settingsPage = "containers"
}
}
// ── Weather ─────────────────────────────────────────────────────────────
SettingsCard {
title: "Weather"
subtitle: "Shown above and in the date menu."
// The location is set roughly once a year, so the search that changes it
// is behind a press rather than permanently open.
PickerRow {
id: locationPicker
label: "Location"
detail: "Only the search term is sent; the name is a label kept on this machine"
value: Settings.weatherLocation
LocationPicker {
width: parent.width
onPicked: locationPicker.collapse()
}
}
ChoiceRow { setting: "temperatureUnit" }
SliderRow { setting: "weatherRefreshMinutes"; divider: false }
}
}
@@ -43,10 +43,14 @@ SettingsPage {
return "Home Assistant is unavailable";
}
// The entity list is a one-time migration seed for the Control Center
// selection, not the light catalog -- the helper discovers that live from
// Home Assistant. It is passed back unchanged so that saving a URL or a
// token cannot disturb the seed.
function saveHomeAssistantConfig(): void {
HomeAssistantConfig.save(
homeUrlInput.text,
homeEntitiesInput.text,
HomeAssistantConfig.entities.join(", "),
homeTokenInput.text
);
}
@@ -57,7 +61,6 @@ SettingsPage {
function onConfigurationSaved(): void {
homeTokenInput.clear();
homeUrlInput.text = HomeAssistantConfig.url;
homeEntitiesInput.text = HomeAssistantConfig.entities.join(", ");
}
}
@@ -130,65 +133,6 @@ SettingsPage {
}
}
Column {
width: parent.width
spacing: 7
topPadding: 10
bottomPadding: 12
Text {
width: parent.width
text: "Light entities"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.Medium
}
Text {
width: parent.width
text: "Comma-separated entity IDs. These define the discoverable light catalog."
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
}
Rectangle {
width: parent.width
height: 72
radius: 10
color: Theme.alpha(Theme.fg, 0.055)
border.width: homeEntitiesInput.activeFocus ? 2 : 1
border.color: homeEntitiesInput.activeFocus
? Theme.alpha(Theme.accent, 0.55) : Theme.alpha(Theme.fg, 0.06)
TextEdit {
id: homeEntitiesInput
anchors.fill: parent
anchors.margins: 10
activeFocusOnTab: true
text: HomeAssistantConfig.entities.join(", ")
color: Theme.fg
selectionColor: Theme.alpha(Theme.accent, 0.5)
selectedTextColor: Theme.fg
font.family: Theme.fontMono
font.pixelSize: Theme.fontSizeSmall
wrapMode: TextEdit.Wrap
clip: true
Text {
anchors.fill: parent
visible: homeEntitiesInput.text === ""
text: "light.living_room, light.kitchen"
color: Theme.fgMuted
font: homeEntitiesInput.font
wrapMode: Text.WordWrap
}
}
}
}
Text {
width: parent.width
visible: HomeAssistantConfig.lastError !== ""
@@ -12,6 +12,10 @@ import qs.modules.clipboard
Column {
id: root
// Emitted once a place has been chosen, so a container can put the search
// away. Choosing is still this component's job; closing is not.
signal picked
spacing: 0
SearchField {
@@ -37,8 +41,10 @@ Column {
divider: place.index < Geocoding.results.length - 1
activatable: true
onActivated: {
if (Geocoding.choose(place.modelData))
if (Geocoding.choose(place.modelData)) {
query.text = "";
root.picked();
}
}
}
}
@@ -3,6 +3,52 @@ import qs.config
import qs.services
SettingsPage {
id: root
// Only one application is expanded at a time; the point is a card you
// can read, not twenty open at once.
property string expandedApp: ""
// Which focus mode is open for editing. One at a time, like the app rules.
property string expandedMode: ""
// Which mode's exception list is open. Separate from expandedMode so the
// list does not unfold every time a mode is opened to change a switch.
property string expandedAllow: ""
function toggleAllowed(mode: var, appId: string, allowed: bool): void {
const current = Array.isArray(mode.allow) ? mode.allow.map(String) : [];
const at = current.indexOf(appId);
if (allowed && at < 0)
current.push(appId);
else if (!allowed && at >= 0)
current.splice(at, 1);
FocusModes.update(String(mode.id), { allow: current });
}
// Schedule edits go through the mode's trigger list rather than replacing
// it, so a mode that also has a game or fullscreen trigger keeps it.
function reschedule(mode: var, changes: var): void {
FocusModes.update(String(mode.id), {
triggers: (mode.triggers ?? []).map(trigger =>
trigger.kind === "schedule" ? Object.assign({}, trigger, changes) : trigger)
});
}
function toggleDay(mode: var, day: int): void {
const schedule = (mode.triggers ?? []).find(trigger => trigger.kind === "schedule");
if (!schedule)
return;
const days = Array.isArray(schedule.days) ? schedule.days.slice() : [];
const at = days.indexOf(day);
if (at >= 0)
days.splice(at, 1);
else
days.push(day);
days.sort((a, b) => a - b);
root.reschedule(mode, { days: days });
}
title: "Notifications & Focus"
lede: "Control interruptions without losing useful history."
@@ -25,7 +71,7 @@ SettingsPage {
TextRow {
label: "Notification history"
detail: "Live notifications retained by the shell"
value: `${Notifs.history.length} items`
value: Notifs.history.length === 1 ? "1 item" : `${Notifs.history.length} items`
}
ActionRow {
@@ -65,26 +111,65 @@ SettingsPage {
model: Notifs.applications
Column {
id: appEntry
required property var modelData
readonly property var app: modelData
readonly property var app: appEntry.modelData
readonly property var rule: Notifs.appRule(appEntry.app.id)
readonly property bool open: root.expandedApp === String(appEntry.app.id)
// What the two lock-screen switches add up to, so the common
// case -- reading rather than changing -- needs no interaction.
// Every app repeated both switch labels verbatim before this,
// which made twenty apps sixty rows of identical sentences.
readonly property string summary: {
if (!appEntry.rule.enabled)
return "Notifications off";
if (!appEntry.rule.showOnLockScreen)
return "On · hidden on the lock screen";
return appEntry.rule.showContentOnLockScreen
? "On · lock screen shows content"
: "On · lock screen shows the sender only";
}
width: parent.width
SettingRow {
label: app.name
detail: app.id
controlWidth: 48
width: parent.width
label: appEntry.app.name
// The identifier is only worth the space while the app is
// open, which is the only time it disambiguates anything.
detail: appEntry.open ? String(appEntry.app.id) : appEntry.summary
activatable: true
divider: !appEntry.open
controlWidth: 92
onActivated: root.expandedApp = appEntry.open ? "" : String(appEntry.app.id)
SettingsToggle {
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
checked: Notifs.appRule(app.id).enabled
onToggled: value => Notifs.setAppRule(app.id, { enabled: value })
spacing: 11
SettingsToggle {
anchors.verticalCenter: parent.verticalCenter
checked: appEntry.rule.enabled
onToggled: value => Notifs.setAppRule(appEntry.app.id, { enabled: value })
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: appEntry.open ? "\u25B4" : "\u25BE"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
}
SettingRow {
width: parent.width
visible: appEntry.open
label: "Show on lock screen"
detail: "Allow this app's notifications on the lock screen"
controlWidth: 48
@@ -92,22 +177,264 @@ SettingsPage {
SettingsToggle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
checked: Notifs.appRule(app.id).showOnLockScreen
onToggled: value => Notifs.setAppRule(app.id, { showOnLockScreen: value })
checked: appEntry.rule.showOnLockScreen
onToggled: value => Notifs.setAppRule(appEntry.app.id, { showOnLockScreen: value })
}
}
SettingRow {
width: parent.width
visible: appEntry.open
label: "Show content on lock screen"
detail: "Show message details when this app is visible there"
divider: false
controlWidth: 48
// Meaningless unless the app reaches the lock screen at all.
opacity: appEntry.rule.showOnLockScreen ? 1 : 0.45
SettingsToggle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
enabled: appEntry.rule.showOnLockScreen
checked: appEntry.rule.showContentOnLockScreen
onToggled: value => Notifs.setAppRule(appEntry.app.id, { showContentOnLockScreen: value })
}
}
}
}
}
SettingsCard {
title: "Focus modes"
subtitle: FocusModes.active
? FocusModes.activeName + " is on because " + FocusModes.activeReason + "."
: "What quiets this machine, and what turns it on. The first mode whose condition is true wins, so order is priority."
Repeater {
model: FocusModes.modes
delegate: Column {
id: modeEntry
required property var modelData
readonly property var mode: modeEntry.modelData
readonly property string modeId: String(modeEntry.mode.id ?? "")
readonly property bool open: root.expandedMode === modeEntry.modeId
readonly property bool running: FocusModes.activeMode?.id === modeEntry.modeId
readonly property var schedule: (modeEntry.mode.triggers ?? [])
.find(trigger => trigger.kind === "schedule") ?? null
width: parent.width
SettingRow {
width: parent.width
label: String(modeEntry.mode.name ?? "")
detail: modeEntry.running
? "On now — " + FocusModes.activeReason
: FocusModes.summary(modeEntry.mode)
activatable: true
divider: !modeEntry.open
controlWidth: 92
onActivated: root.expandedMode = modeEntry.open ? "" : modeEntry.modeId
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 11
SettingsToggle {
anchors.verticalCenter: parent.verticalCenter
checked: modeEntry.mode.enabled === true
onToggled: value => FocusModes.setEnabled(modeEntry.modeId, value)
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: modeEntry.open ? "\u25B4" : "\u25BE"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
}
SettingRow {
width: parent.width
visible: modeEntry.open
label: "Silence notifications"
detail: "Banners are held until the mode ends"
controlWidth: 48
SettingsToggle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
checked: Notifs.appRule(app.id).showContentOnLockScreen
onToggled: value => Notifs.setAppRule(app.id, { showContentOnLockScreen: value })
checked: modeEntry.mode.silence === true
onToggled: value => FocusModes.update(modeEntry.modeId, { silence: value })
}
}
SettingRow {
width: parent.width
visible: modeEntry.open
label: "Keep the screen awake"
detail: "Caffeine, for as long as the mode is on"
controlWidth: 48
divider: modeEntry.schedule !== null
SettingsToggle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
checked: modeEntry.mode.keepAwake === true
onToggled: value => FocusModes.update(modeEntry.modeId, { keepAwake: value })
}
}
// Only modes that actually have a schedule get the schedule
// controls; a game trigger has no times to set.
TextFieldRow {
width: parent.width
visible: modeEntry.open && modeEntry.schedule !== null
label: "From"
detail: "24-hour, such as 23:30"
text: String(modeEntry.schedule?.start ?? "")
placeholder: "23:30"
onAccepted: value => root.reschedule(modeEntry.mode, { start: value })
}
TextFieldRow {
width: parent.width
visible: modeEntry.open && modeEntry.schedule !== null
label: "Until"
detail: "A time earlier than the start means the window crosses midnight"
text: String(modeEntry.schedule?.end ?? "")
placeholder: "07:00"
onAccepted: value => root.reschedule(modeEntry.mode, { end: value })
}
SettingRow {
width: parent.width
visible: modeEntry.open && modeEntry.mode.silence === true
label: "May interrupt"
detail: (modeEntry.mode.allow ?? []).length === 0
? "Nothing gets through while this mode is on"
: "Everything else is held until the mode ends"
activatable: true
controlWidth: 150
onActivated: root.expandedAllow =
root.expandedAllow === modeEntry.modeId ? "" : modeEntry.modeId
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 9
Text {
anchors.verticalCenter: parent.verticalCenter
text: (modeEntry.mode.allow ?? []).length === 0
? "Nothing"
: (modeEntry.mode.allow ?? []).length + " apps"
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: root.expandedAllow === modeEntry.modeId ? "\u25B4" : "\u25BE"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
}
// Drawn from the applications that have actually sent a
// notification, which is the same list the rules above use --
// an exception for something that never notifies is not a
// choice worth offering.
Repeater {
model: (modeEntry.open && root.expandedAllow === modeEntry.modeId)
? Notifs.applications
: []
delegate: SettingRow {
required property var modelData
width: parent.width
label: String(modelData.name ?? "")
detail: String(modelData.id ?? "")
controlWidth: 48
SettingsToggle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
checked: (modeEntry.mode.allow ?? []).indexOf(String(modelData.id)) >= 0
onToggled: value => root.toggleAllowed(modeEntry.mode, String(modelData.id), value)
}
}
}
TextRow {
width: parent.width
visible: modeEntry.open && root.expandedAllow === modeEntry.modeId
&& Notifs.applications.length === 0
label: "No applications yet"
detail: "They appear here once they have sent a notification."
value: ""
}
SettingRow {
width: parent.width
visible: modeEntry.open && modeEntry.schedule !== null
label: "On these days"
detail: "A window that crosses midnight belongs to the day it starts on"
controlWidth: 250
divider: false
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 5
Repeater {
model: [
{ value: 1, label: "M" }, { value: 2, label: "T" },
{ value: 3, label: "W" }, { value: 4, label: "T" },
{ value: 5, label: "F" }, { value: 6, label: "S" },
{ value: 0, label: "S" }
]
delegate: Rectangle {
id: dayPill
required property var modelData
readonly property bool on:
(modeEntry.schedule?.days ?? []).indexOf(dayPill.modelData.value) >= 0
width: 30
height: 28
radius: 8
color: dayPill.on ? Theme.alpha(Theme.accent, 0.22)
: Theme.alpha(Theme.fg, 0.06)
border.width: dayPill.on ? 1 : 0
border.color: Theme.alpha(Theme.accent, 0.5)
Text {
anchors.centerIn: parent
text: String(dayPill.modelData.label)
color: dayPill.on ? Theme.fg : Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: dayPill.on ? Font.DemiBold : Font.Medium
}
HoverHandler { cursorShape: Qt.PointingHandCursor }
TapHandler {
onTapped: root.toggleDay(modeEntry.mode, dayPill.modelData.value)
}
}
}
}
}
}
@@ -15,6 +15,7 @@
// quietly stops syncing for weeks.
import QtQuick
import Quickshell
import qs.config
import qs.services
@@ -57,6 +58,22 @@ SettingsPage {
id: accountCard
required property var modelData
// GOA hands back a preference-ordered chain; this walks it and takes
// the first name the active icon theme actually has. Taking the
// first name blindly, or the last as a fallback, both looked right
// and were not: on Adwaita the tail of these chains ("mail",
// "goa-symbolic") does not exist, so a miss would have rendered
// nothing at all.
icon: {
const chain = accountCard.modelData.providerIcons ?? [];
for (const name of chain) {
if (Quickshell.iconPath(String(name), true) !== "")
return String(name);
}
return "";
}
iconFallback: "avatar-default-symbolic"
title: accountCard.modelData.identity || accountCard.modelData.providerName
subtitle: accountCard.modelData.needsAttention
? accountCard.modelData.providerName + " · sign-in expired, so this account has stopped syncing"
@@ -0,0 +1,26 @@
// A row whose trailing control is a masked password field.
//
// Reports every keystroke rather than committing on Enter, because the page it
// serves has to compare two fields as they are typed and say whether they match
// before offering to set anything.
import QtQuick
import qs.config
SettingRow {
id: root
property string placeholder: "Password"
signal changed(value: string)
controlWidth: 220
PasswordField {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
width: root.controlWidth
placeholder: root.placeholder
onTextChanged: root.changed(this.text)
}
}
@@ -0,0 +1,83 @@
// A setting whose value is chosen from far more options than belong on screen.
//
// PickerRow {
// label: "Language"
// value: SystemLocale.currentLabel
// SearchPicker { items: SystemLocale.locales; onPicked: ... }
// }
//
// Three pages rendered an entire dataset as rows -- every installed locale, the
// whole tz database, every mode a monitor advertises -- so the one line telling
// you what is currently set was buried under hundreds that were not. The chooser
// itself was never the problem and is unchanged: this only collapses it behind
// the current value, which is what a person came to the page to read.
//
// Collapsed by default, and closes again once something is picked, so the page
// returns to being readable rather than staying open on a list nobody needs any
// more.
import QtQuick
import qs.config
Column {
id: root
property string label: ""
property string detail: ""
// What is set right now. Shown on the collapsed row, so the common case --
// looking rather than changing -- needs no interaction at all.
property string value: ""
property bool enabled: true
property bool divider: true
property bool expanded: false
default property alias content: body.data
// Pages call this from their picker's onPicked, so choosing something puts
// the list away instead of leaving it open over the rest of the page.
function collapse(): void { root.expanded = false; }
width: parent ? parent.width : 620
spacing: 0
SettingRow {
width: parent.width
label: root.label
detail: root.detail
activatable: root.enabled
divider: root.divider && !root.expanded
opacity: root.enabled ? 1 : 0.5
controlWidth: 210
onActivated: root.expanded = !root.expanded
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 9
Text {
anchors.verticalCenter: parent.verticalCenter
text: root.value
color: Theme.fgDim
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSize
elide: Text.ElideRight
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: root.expanded ? "▴" : "▾"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
}
Column {
id: body
width: parent.width
visible: root.expanded
}
}
@@ -0,0 +1,353 @@
// Printers, and one queue across all of them.
//
// The list answers "which printers exist"; the queue answers "where is my
// document", which is the question that actually brings someone here. They are
// separate cards because they are separate questions -- a job that has not come
// out is not necessarily a problem with the printer it was sent to.
//
// Driverless only. A printer old enough to need a PPD is named as such rather
// than offered and then failing at the moment it is added.
import Quickshell
import QtQuick
import qs.config
import qs.services
SettingsPage {
id: root
objectName: "printers"
title: "Printers"
lede: "Printers this machine can use, and what they are waiting on."
property string expandedPrinter: ""
property string confirmingRemoval: ""
property bool addingByAddress: false
property string manualUri: ""
property string manualName: ""
readonly property bool manualReady: /^(ipp|ipps|socket):\/\/\S+$/.test(root.manualUri)
&& root.manualName.trim() !== ""
Component.onCompleted: Printers.refresh()
TextRow {
visible: Printers.lastError !== ""
label: "Printing needs attention"
detail: Printers.lastError
value: ""
divider: false
}
// ── Which printers exist ─────────────────────────────────────────────────
SettingsCard {
visible: Printers.anyPrinters
title: "Printers"
subtitle: "Open one to change what it does by default."
Repeater {
model: Printers.printers
delegate: Column {
id: printerBlock
required property var modelData
required property int index
readonly property string name: String(printerBlock.modelData.name ?? "")
readonly property bool open: root.expandedPrinter === printerBlock.name
readonly property bool paused: String(printerBlock.modelData.state ?? "") === "stopped"
width: parent.width
SettingRow {
width: printerBlock.width
icon: printerBlock.paused ? "\u{F0026}" : "\u{F042A}"
label: String(printerBlock.modelData.description ?? printerBlock.name)
+ (printerBlock.modelData.isDefault ? " · Default" : "")
detail: Printers.stateSummary(printerBlock.modelData)
+ " · " + String(printerBlock.modelData.uri ?? "")
value: Printers.jobsFor(printerBlock.name) + " job"
+ (Printers.jobsFor(printerBlock.name) === 1 ? "" : "s")
activatable: !Printers.busy
divider: !printerBlock.open
&& printerBlock.index < Printers.printers.length - 1
onActivated: {
root.confirmingRemoval = "";
root.expandedPrinter = printerBlock.open ? "" : printerBlock.name;
}
}
Column {
width: printerBlock.width
visible: printerBlock.open
TextRow {
width: printerBlock.width
label: "Model"
detail: "Reported by the printer itself"
value: String(printerBlock.modelData.makeAndModel ?? "Unknown")
}
ActionRow {
width: printerBlock.width
visible: !printerBlock.modelData.isDefault
label: "Use by default"
detail: "Applications print here unless they are told otherwise"
action: "Make default"
enabled: !Printers.busy
onTriggered: Printers.setDefault(printerBlock.name)
}
ActionRow {
width: printerBlock.width
label: printerBlock.paused ? "Resume printing" : "Pause printing"
detail: printerBlock.paused
? "Queued jobs start again"
: "Jobs keep queuing, but nothing is sent until it resumes"
action: printerBlock.paused ? "Resume" : "Pause"
enabled: !Printers.busy
onTriggered: printerBlock.paused
? Printers.resume(printerBlock.name)
: Printers.pause(printerBlock.name)
}
ActionRow {
width: printerBlock.width
label: "Print a test page"
detail: "Confirms the printer answers and puts ink on paper"
action: "Print"
enabled: !Printers.busy && !printerBlock.paused
onTriggered: Printers.testPage(printerBlock.name)
}
SettingRow {
width: printerBlock.width
label: "Remove this printer"
detail: root.confirmingRemoval === printerBlock.name
? "Anything still queued for it is cancelled."
: "It can be added again later"
controlWidth: 200
divider: printerBlock.index < Printers.printers.length - 1
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
SettingsButton {
text: root.confirmingRemoval === printerBlock.name ? "Keep" : "Remove…"
enabled: !Printers.busy
onClicked: root.confirmingRemoval =
root.confirmingRemoval === printerBlock.name ? "" : printerBlock.name
}
SettingsButton {
visible: root.confirmingRemoval === printerBlock.name
text: "Remove"
tone: "danger"
enabled: !Printers.busy
onClicked: {
root.confirmingRemoval = "";
root.expandedPrinter = "";
Printers.remove(printerBlock.name);
}
}
}
}
}
}
}
}
// ── Where my document is ─────────────────────────────────────────────────
SettingsCard {
visible: Printers.anyPrinters
title: "Print queue"
subtitle: Printers.jobs.length === 0
? "Nothing is waiting."
: "Everything waiting, across every printer."
Repeater {
model: Printers.jobs
delegate: ActionRow {
id: jobRow
required property var modelData
required property int index
width: parent.width
label: String(jobRow.modelData.name ?? "Untitled")
detail: String(jobRow.modelData.printer ?? "") + " · "
+ String(jobRow.modelData.state ?? "")
+ (Number(jobRow.modelData.pages ?? 0) > 0
? " · " + jobRow.modelData.pages + " pages" : "")
action: "Cancel"
enabled: !Printers.busy
divider: jobRow.index < Printers.jobs.length - 1
onTriggered: Printers.cancel(Number(jobRow.modelData.id))
}
}
TextRow {
visible: Printers.jobs.length === 0
label: "Queue is empty"
detail: "Jobs appear here while they wait, and can be cancelled from here"
value: ""
divider: false
}
}
// ── Nothing set up yet ───────────────────────────────────────────────────
SettingsCard {
visible: Printers.scanned && !Printers.anyPrinters
title: "No printers yet"
subtitle: "Printers that announce themselves on your network appear here on their own."
ActionRow {
label: "Search the network"
detail: Printers.searching
? "Listening for printers that announce themselves…"
: (Printers.searched
? Printers.addable.length + " found"
: "Looks for printers over mDNS, the same way phones and laptops find them")
action: Printers.searching ? "Searching…" : "Search"
enabled: !Printers.searching
divider: false
onTriggered: Printers.search()
}
}
// ── Adding ───────────────────────────────────────────────────────────────
SettingsCard {
title: "Add a printer"
subtitle: "Driverless printers only. One that needs a manufacturer driver has to be set up with the system printer tool."
ActionRow {
visible: Printers.anyPrinters
label: "Search the network"
detail: Printers.searching
? "Listening for printers that announce themselves…"
: (Printers.searched
? Printers.addable.length + " printer"
+ (Printers.addable.length === 1 ? "" : "s") + " found that are not set up here"
: "Looks for printers over mDNS")
action: Printers.searching ? "Searching…" : "Search"
enabled: !Printers.searching
onTriggered: Printers.search()
}
Repeater {
model: Printers.addable
delegate: ActionRow {
id: foundRow
required property var modelData
width: parent.width
label: String(foundRow.modelData.name ?? "")
detail: foundRow.modelData.driverless === true
? String(foundRow.modelData.uri ?? "") + " · driverless"
: String(foundRow.modelData.uri ?? "") + " · needs a manufacturer driver"
action: foundRow.modelData.driverless === true ? "Add" : "Not supported"
enabled: !Printers.busy && foundRow.modelData.driverless === true
onTriggered: Printers.add(String(foundRow.modelData.uri ?? ""),
Printers.suggestedName(String(foundRow.modelData.name ?? "")))
}
}
TextRow {
visible: Printers.searched && Printers.addable.length === 0 && !Printers.searching
label: "Nothing found"
detail: "No printer announced itself. It may be asleep, on another network, or may not support network discovery."
value: ""
}
ActionRow {
label: "Add by address"
detail: "For a printer that does not announce itself"
action: root.addingByAddress ? "Cancel" : "Enter…"
enabled: !Printers.busy
divider: root.addingByAddress
onTriggered: {
root.addingByAddress = !root.addingByAddress;
root.manualUri = "";
root.manualName = "";
}
}
Column {
width: parent.width
visible: root.addingByAddress
TextFieldRow {
width: parent.width
label: "Address"
detail: "ipp://, ipps://, or socket:// followed by the printer's host"
placeholder: "ipp://printer.local/ipp/print"
text: root.manualUri
onAccepted: value => root.manualUri = value
}
TextFieldRow {
width: parent.width
label: "Name"
detail: "What this printer is called on this machine"
placeholder: "office-printer"
text: root.manualName
onAccepted: value => root.manualName = value
}
ActionRow {
width: parent.width
label: "Add this printer"
detail: "The printer is asked what it can do; if it cannot answer, it is not added"
action: "Add"
enabled: root.manualReady && !Printers.busy
divider: false
onTriggered: {
Printers.add(root.manualUri, Printers.suggestedName(root.manualName));
root.addingByAddress = false;
}
}
}
}
// ── The service underneath ───────────────────────────────────────────────
SettingsCard {
title: "Printing service"
subtitle: "What has to be running for any of this to work."
TextRow {
label: "CUPS"
detail: Printers.service?.running === true
? "Running, so a printer added here works immediately"
: "Not running, so nothing can print"
value: Printers.service?.running === true ? "Running" : "Stopped"
}
TextRow {
label: "Starts at boot"
detail: Printers.service?.startsAtBoot === true
? "Started with the system"
: "Started on demand, when something prints. This is a normal configuration."
value: Printers.service?.startsAtBoot === true ? "Yes" : "On demand"
}
TextRow {
label: "Network discovery"
detail: Printers.service?.discoveryAvailable === true
? "Avahi is running, which is what finds printers that announce themselves"
: "Avahi is not running, so network printers cannot be discovered"
value: Printers.service?.discoveryAvailable === true ? "Available" : "Unavailable"
divider: false
}
}
}
@@ -19,6 +19,25 @@ SettingsPage {
id: root
title: "Privacy & Security"
// The stored-secret list is collapsed until asked for, and one item at a
// time can be waiting on a confirmed Forget.
property bool showingSecrets: false
property string confirmingPath: ""
// What a stored secret is FOR, from its attributes. Never its value.
function describe(item: var): string {
const attributes = item?.attributes ?? {};
const parts = [];
for (const key of ["user", "username", "account", "server", "host", "domain", "service", "application"]) {
if (attributes[key])
parts.push(String(attributes[key]));
}
if (parts.length > 0)
return parts.join(" · ");
const schema = String(item?.schema ?? "");
return schema !== "" ? schema : "No further detail stored";
}
lede: DeviceSecurity.scanned && DeviceSecurity.attentionCount === 0
? "Screen lock, device access, and a machine whose security settings all check out."
: "Screen lock, which applications can see you, and how this machine is protected."
@@ -28,6 +47,8 @@ SettingsPage {
DeviceSecurity.refresh();
if (!Keyring.scanned)
Keyring.refresh();
if (!Permissions.scanned)
Permissions.refresh();
}
SettingsCard {
@@ -96,6 +117,140 @@ SettingsPage {
}
}
// ── What is actually stored ──────────────────────────────────────────────
// Collapsed until asked. Opening the Privacy page should not enumerate
// someone's saved passwords as a side effect, and the list is long enough
// that it would bury every other setting on the page.
SettingsCard {
visible: Keyring.scanned && Keyring.available && !Keyring.locked
title: "Stored secrets"
subtitle: Keyring.listed
? "Passwords and tokens applications have saved. The values are never shown here."
: "Passwords and tokens applications have saved, listed only when you ask."
ActionRow {
label: "Saved items"
detail: Keyring.listed
? Keyring.storedCount + " stored across "
+ Keyring.collections.length + " keyring"
+ (Keyring.collections.length === 1 ? "" : "s")
: "Read the keyring and list what is in it"
action: root.showingSecrets
? "Hide"
: (Keyring.listing ? "Reading…" : "Show")
enabled: !Keyring.listing
divider: root.showingSecrets
onTriggered: {
if (root.showingSecrets) {
root.showingSecrets = false;
root.confirmingPath = "";
return;
}
root.showingSecrets = true;
if (!Keyring.listed)
Keyring.list();
}
}
TextRow {
visible: root.showingSecrets && Keyring.copiedPath !== ""
label: "Copied to the clipboard"
detail: "It clears itself in about a minute, unless you copy something else first."
value: ""
divider: true
}
Repeater {
model: root.showingSecrets && Keyring.listed ? Keyring.collections : []
delegate: Column {
id: collectionBlock
required property var modelData
width: parent.width
TextRow {
width: collectionBlock.width
label: String(collectionBlock.modelData.label ?? "")
detail: collectionBlock.modelData.locked
? "Locked, so its contents cannot be listed"
: (collectionBlock.modelData.items ?? []).length + " stored"
value: ""
divider: false
}
Repeater {
model: collectionBlock.modelData.items ?? []
delegate: SettingRow {
id: secretRow
required property var modelData
required property int index
readonly property string itemPath: String(secretRow.modelData.path ?? "")
readonly property bool confirming: root.confirmingPath === secretRow.itemPath
width: collectionBlock.width
label: String(secretRow.modelData.label ?? "")
// Attributes, never the value: what the secret is FOR is
// the part that identifies it.
detail: root.describe(secretRow.modelData)
controlWidth: 200
divider: secretRow.index < (collectionBlock.modelData.items ?? []).length - 1
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
SettingsButton {
text: secretRow.confirming ? "Cancel" : "Copy"
enabled: !Keyring.working
onClicked: {
if (secretRow.confirming) {
root.confirmingPath = "";
return;
}
Keyring.copy(secretRow.itemPath);
}
}
SettingsButton {
// Two presses, always. Forgetting a stored
// password cannot be undone, and the button
// sits next to Copy where a misclick is cheap.
text: secretRow.confirming ? "Forget it" : "Forget"
tone: secretRow.confirming ? "danger" : "normal"
enabled: !Keyring.working
onClicked: {
if (!secretRow.confirming) {
root.confirmingPath = secretRow.itemPath;
return;
}
root.confirmingPath = "";
Keyring.forget(secretRow.itemPath);
}
}
}
}
}
Item { width: 1; height: 6 }
}
}
TextRow {
visible: root.showingSecrets && Keyring.listed && Keyring.storedCount === 0
label: "Nothing stored yet"
detail: "Applications that save a password or token will appear here."
value: ""
divider: false
}
}
SettingsCard {
title: "Camera & microphone"
subtitle: PrivacyState.anyActive
@@ -128,6 +283,70 @@ SettingsPage {
}
}
SettingsCard {
title: "Application permissions"
// The limit is stated here rather than left to be discovered. Saying
// "your camera is protected" when a native binary can open it
// directly would be a claim this page cannot back up.
subtitle: Permissions.available
? "Applications that asked through the desktop portal. Programs installed outside it can still reach these devices directly."
: (Permissions.lastError || "The desktop portal's permission store is not running.")
Repeater {
model: Permissions.devices
delegate: Column {
id: deviceBlock
required property var modelData
readonly property var applications: deviceBlock.modelData.applications ?? []
width: parent.width
TextRow {
width: parent.width
visible: deviceBlock.applications.length === 0
label: String(deviceBlock.modelData.label ?? "")
detail: "No application has asked for this."
value: ""
}
Repeater {
model: deviceBlock.applications
delegate: SettingRow {
required property var modelData
width: parent.width
label: String(modelData.app ?? "")
detail: String(deviceBlock.modelData.label ?? "")
controlWidth: 150
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 9
SettingsButton {
anchors.verticalCenter: parent.verticalCenter
text: "Ask again"
enabled: !Permissions.busy
onClicked: Permissions.forget(
String(deviceBlock.modelData.id), String(modelData.app))
}
SettingsToggle {
anchors.verticalCenter: parent.verticalCenter
checked: modelData.allowed === true
onToggled: value => Permissions.setAllowed(
String(deviceBlock.modelData.id), String(modelData.app), value)
}
}
}
}
}
}
}
SettingsCard {
title: "Device security"
subtitle: DeviceSecurity.attentionCount === 0
@@ -8,7 +8,7 @@ such rather than half-reimplemented.
The stable internal `services` route renders **System Health**. It is reachable
from the Settings sidebar and its live 54px footer, the degraded-only bar
indicator, and Vicinae's **Panama: Check System Health** command. Healthy scans
indicator, and Vicinae's **Check System Health** command. Healthy scans
reserve no bar space and produce no notification.
`services/Health.qml` owns the last accepted redacted snapshot. It invokes
@@ -29,13 +29,15 @@ SettingsPage {
title: "Language"
subtitle: "Changing this needs your password, and takes effect for programs started afterwards."
TextRow {
PickerRow {
id: languagePicker
label: "Current language"
detail: SystemLocale.pendingRestart
? "Chosen, but not in use until you sign out and back in"
: "Used by programs that ask the system what language to speak"
value: SystemLocale.currentLabel || "Reading…"
}
divider: false
SearchPicker {
width: parent.width
@@ -43,7 +45,11 @@ SettingsPage {
current: SystemLocale.current
placeholder: "Search languages and regions"
emptyText: SystemLocale.scanning ? "Reading installed locales…" : "No locales are installed"
onPicked: value => SystemLocale.set(value)
onPicked: value => {
SystemLocale.set(value);
languagePicker.collapse();
}
}
}
}
@@ -0,0 +1,69 @@
// A segmented choice that is NOT schema-bound.
//
// ChoiceRow reads its options from the preference schema. This one is handed
// them, for choices that describe system state rather than a Panama setting.
import QtQuick
import qs.config
SettingRow {
id: root
// [{ value, label }]
property var options: []
property var value: null
property bool enabled: true
signal selected(value: var)
controlWidth: Math.max(150, root.options.length * 92)
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 6
opacity: root.enabled ? 1 : 0.45
Repeater {
model: root.options
delegate: Rectangle {
id: segment
required property var modelData
readonly property bool current: root.value === segment.modelData.value
width: Math.max(84, segmentLabel.implicitWidth + 22)
height: 30
radius: 9
color: segment.current
? Theme.alpha(Theme.accent, 0.22)
: Theme.alpha(Theme.fg, segmentMouse.containsMouse ? 0.12 : 0.06)
border.width: 1
border.color: segment.current
? Theme.alpha(Theme.accent, 0.5)
: Theme.alpha(Theme.fg, 0.08)
Text {
id: segmentLabel
anchors.centerIn: parent
text: String(segment.modelData.label ?? "")
color: segment.current ? Theme.fg : Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: segment.current ? Font.DemiBold : Font.Medium
}
MouseArea {
id: segmentMouse
anchors.fill: parent
hoverEnabled: true
enabled: root.enabled && !segment.current
cursorShape: Qt.PointingHandCursor
onClicked: root.selected(segment.modelData.value)
}
}
}
}
}
@@ -5,6 +5,10 @@ Rectangle {
id: root
property string text: ""
// "normal", "accent", or "danger". Danger is for an action that cannot be
// undone -- it never initiates one on a single press, it marks the press
// that confirms it, so the confirming button cannot be mistaken for the
// Cancel sitting next to it.
property string tone: "normal"
property bool enabled: true
signal clicked
@@ -16,16 +20,20 @@ Rectangle {
color: {
if (tone === "accent")
return mouse.containsMouse ? Theme.mix(Theme.accent, Theme.fg, 0.12) : Theme.accent;
if (tone === "danger")
return mouse.containsMouse ? Theme.alpha(Theme.danger, 0.22) : Theme.alpha(Theme.danger, 0.12);
return mouse.containsMouse ? Theme.alpha(Theme.fg, 0.13) : Theme.alpha(Theme.fg, 0.075);
}
border.width: tone === "accent" ? 0 : 1
border.color: Theme.alpha(Theme.fg, 0.08)
border.color: tone === "danger" ? Theme.alpha(Theme.danger, 0.45) : Theme.alpha(Theme.fg, 0.08)
Text {
id: label
anchors.centerIn: parent
text: root.text
color: root.tone === "accent" ? Theme.bgDark : Theme.fg
color: root.tone === "accent"
? Theme.bgDark
: (root.tone === "danger" ? Theme.danger : Theme.fg)
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
@@ -9,6 +9,13 @@ Rectangle {
property string title: ""
property string subtitle: ""
// An optional themed icon beside the title, for cards that represent a
// specific thing rather than a topic -- an online account is one of four
// that otherwise differ only by a line of small grey text. Empty by
// default, and when empty the header lays out exactly as it did before.
property string icon: ""
property string iconFallback: "dialog-information-symbolic"
width: parent ? parent.width : 680
implicitHeight: body.implicitHeight + 30
radius: Theme.cardRadius + 2
@@ -32,15 +39,38 @@ Rectangle {
anchors.margins: 15
spacing: 0
Text {
Row {
width: parent.width
visible: root.title !== ""
spacing: root.icon === "" ? 0 : 10
ThemedIcon {
anchors.verticalCenter: parent.verticalCenter
visible: root.icon !== ""
width: root.icon === "" ? 0 : 22
icon: root.icon
iconFallback: root.iconFallback
size: 22
}
Text {
anchors.verticalCenter: parent.verticalCenter
width: parent.width - (root.icon === "" ? 0 : 32)
text: root.title
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeLarge
font.weight: Font.DemiBold
bottomPadding: root.subtitle === "" ? 10 : 3
elide: Text.ElideRight
}
}
// The title's bottom padding lived on the title Text; it moves here so
// the Row above can centre an icon against it without the padding
// pushing the icon off-centre.
Item {
width: 1
height: root.title === "" ? 0 : (root.subtitle === "" ? 10 : 3)
}
Text {
@@ -109,6 +109,7 @@ Rectangle {
case "home-phone": return homePhonePage;
case "desktop": return desktopPage;
case "sound": return soundPage;
case "gaming": return gamingPage;
case "notifications": return notificationsPage;
case "screen-intelligence": return screenIntelligencePage;
case "shortcuts": return shortcutsPage;
@@ -120,6 +121,15 @@ Rectangle {
case "power": return powerPage;
case "datetime": return dateTimePage;
case "applications": return applicationsPage;
case "storage": return storagePage;
case "snapshots": return snapshotsPage;
case "updates": return updatesPage;
case "users": return usersPage;
case "sharing": return sharingPage;
case "firewall": return firewallPage;
case "printers": return printersPage;
case "containers": return containersPage;
case "ssh-keys": return sshKeysPage;
case "services": return healthPage;
case "about": return aboutPage;
default: return homePage;
@@ -158,6 +168,15 @@ Rectangle {
Component { id: homePage; HomePage {} }
Component { id: applicationsPage; ApplicationsPage {} }
Component { id: storagePage; StoragePage {} }
Component { id: snapshotsPage; SnapshotsPage {} }
Component { id: updatesPage; UpdatesPage {} }
Component { id: usersPage; UsersPage {} }
Component { id: sharingPage; SharingPage {} }
Component { id: firewallPage; FirewallPage {} }
Component { id: containersPage; ContainersPage {} }
Component { id: sshKeysPage; SshKeysPage {} }
Component { id: printersPage; PrintersPage {} }
Component { id: accessibilityPage; AccessibilityPage {} }
Component { id: powerPage; PowerPage {} }
Component { id: dateTimePage; DateTimePage {} }
@@ -167,6 +186,7 @@ Rectangle {
Component { id: homePhonePage; HomePhonePage {} }
Component { id: desktopPage; DesktopPage {} }
Component { id: soundPage; SoundPage {} }
Component { id: gamingPage; GamingPage {} }
Component { id: notificationsPage; NotificationsPage {} }
Component { id: screenIntelligencePage; ScreenIntelligencePage {} }
Component { id: shortcutsPage; ShortcutsPage {} }
@@ -26,9 +26,15 @@ Rectangle {
{ page: "appearance", label: "Appearance", icon: "\u{F0E0D}" },
{ page: "displays", label: "Displays", icon: "\u{F0379}" },
{ page: "connectivity", label: "Network & Devices", icon: "\u{F08D4}" },
{ page: "sharing", label: "Sharing", icon: "\u{F04E6}" },
{ page: "firewall", label: "Firewall", icon: "\u{F0483}" },
{ page: "printers", label: "Printers", icon: "\u{F042A}" },
{ page: "containers", label: "Containers", icon: "\u{F0868}" },
{ page: "ssh-keys", label: "SSH Keys", icon: "\u{F0306}" },
{ page: "home-phone", label: "Home & Phone", icon: "\u{F02DC}" },
{ page: "desktop", label: "Desktop & Dock", icon: "\u{F04A4}" },
{ page: "sound", label: "Sound", icon: "\u{F057E}" },
{ page: "gaming", label: "Gaming", icon: "\u{F0297}" },
{ page: "notifications", label: "Notifications & Focus", icon: "\u{F009A}" },
{ page: "screen-intelligence", label: "Screen Intelligence", icon: "\u{F05A8}" },
{ page: "shortcuts", label: "Keyboard", icon: "\u{F030C}" },
@@ -40,6 +46,10 @@ Rectangle {
{ page: "power", label: "Power & Lock", icon: "\u{F0425}" },
{ page: "datetime", label: "Date & Time", icon: "\u{F0954}" },
{ page: "applications", label: "Applications", icon: "\u{F003B}" },
{ page: "updates", label: "Software Update", icon: "\u{F06B0}" },
{ page: "storage", label: "Storage", icon: "\u{F02CA}" },
{ page: "snapshots", label: "Snapshots", icon: "\u{F0954}" },
{ page: "users", label: "Users", icon: "\u{F0004}" },
{ page: "services", label: "System Health", icon: "\u{F0493}" },
{ page: "about", label: "About", icon: "\u{F02FD}" }
]
@@ -0,0 +1,89 @@
// Page-level tabs, for a page that is genuinely several subjects.
//
// SettingsTabs {
// tabs: [{ value: "theme", label: "Theme" }, …]
// current: root.tab
// onSelected: value => root.tab = value
// }
//
// Only for pages long enough that scrolling hides the control someone came for.
// Appearance was six cards deep, so light and dark -- the thing reached most --
// sat below a wallpaper grid and an entire lock screen. Tabs are not a way to
// make a short page look organised; they are for when the page is long enough
// that the order stops being a suggestion and starts being a burial.
import QtQuick
import qs.config
Item {
id: root
// [{ value, label }]
property var tabs: []
property string current: ""
signal selected(string value)
width: parent ? parent.width : 620
implicitHeight: 40
Row {
id: strip
anchors.left: parent.left
anchors.bottom: parent.bottom
spacing: 2
Repeater {
model: root.tabs
delegate: Item {
id: tab
required property var modelData
readonly property bool active: String(tab.modelData.value) === root.current
implicitWidth: caption.implicitWidth + 30
implicitHeight: 38
Text {
id: caption
anchors.centerIn: parent
text: String(tab.modelData.label ?? "")
color: tab.active ? Theme.fg : (hover.hovered ? Theme.fgDim : Theme.fgMuted)
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: tab.active ? Font.DemiBold : Font.Medium
}
Rectangle {
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: 2
radius: 1
visible: tab.active
color: Theme.accent
}
HoverHandler {
id: hover
cursorShape: Qt.PointingHandCursor
}
TapHandler {
onTapped: root.selected(String(tab.modelData.value))
}
}
}
}
Rectangle {
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: 1
color: Theme.alpha(Theme.fg, 0.08)
z: -1
}
}
@@ -0,0 +1,209 @@
// What this machine offers to other machines on the network.
//
// Every row states what is true right now, including "the software for this is
// not installed" -- which is the honest answer for file sharing here, and is
// what the panel this replaces hides behind a switch that silently does
// nothing.
//
// Turning remote login on changes the whole system and prompts. Remote desktop
// is a user service and does not.
import Quickshell
import QtQuick
import qs.config
import qs.services
SettingsPage {
id: root
objectName: "sharing"
title: "Sharing"
lede: "What this machine offers to other machines on the network."
Component.onCompleted: Sharing.refresh()
TextRow {
visible: Sharing.lastError !== ""
label: "Sharing needs attention"
detail: Sharing.lastError
value: ""
divider: false
}
SettingsCard {
title: "This machine"
subtitle: "The name other machines see."
TextFieldRow {
label: "Network name"
detail: "Used for ssh and for anything else that finds this machine by name"
text: Sharing.hostname
placeholder: "desktop"
enabled: !Sharing.busy
divider: false
onAccepted: value => Sharing.setHostname(value)
}
}
SettingsCard {
title: "Remote login"
subtitle: "Sign in to a terminal on this machine over SSH."
SwitchRow {
label: "Allow remote login"
detail: Sharing.remoteLogin?.installed === true
? (Sharing.remoteLoginOn
? "Running, and starts automatically at boot"
: "Not running")
: "OpenSSH server is not installed"
checked: Sharing.remoteLoginOn
enabled: !Sharing.busy && Sharing.remoteLogin?.installed === true
onToggled: value => Sharing.setRemoteLogin(value)
}
TextRow {
visible: Sharing.remoteLoginOn
label: "Connect with"
detail: "From another machine on your network"
value: "ssh " + Sharing.networkName
}
TextRow {
visible: Sharing.remoteLoginOn && Sharing.remoteSessions.length === 0
label: "Nobody is signed in"
detail: "Remote login is on, and no one is connected from another machine."
value: ""
}
Repeater {
model: Sharing.remoteSessions
delegate: TextRow {
required property var modelData
width: parent.width
label: String(modelData.user ?? "") + " is signed in from " + String(modelData.from ?? "")
detail: "Since " + String(modelData.since ?? "") + " · " + String(modelData.line ?? "")
value: ""
}
}
TextRow {
visible: Sharing.remoteLoginOn
label: "Port"
detail: "Where the SSH server is listening"
value: String(Sharing.remoteLogin?.port ?? "22")
}
// Reported from the configuration rather than assumed. Saying "keys
// only" on a machine that actually accepts passwords would be a
// security claim this page cannot back up.
TextRow {
visible: Sharing.remoteLoginOn
label: "Password sign-in"
detail: Sharing.passwordLoginSummary()
value: ""
divider: false
}
}
SettingsCard {
title: "Remote desktop"
subtitle: "See and control this desktop from another machine."
SwitchRow {
label: "Allow remote desktop"
detail: Sharing.remoteDesktop?.available === true
? (Sharing.remoteDesktopOn
? "Running for your session"
: (Sharing.remoteDesktop?.hasCredentials === true
? "Not running"
: "Set a username and password before turning this on"))
: "Remote desktop support is not installed"
checked: Sharing.remoteDesktopOn
enabled: !Sharing.busy
&& Sharing.remoteDesktop?.available === true
&& Sharing.remoteDesktop?.hasCredentials === true
onToggled: value => Sharing.setRemoteDesktop(value)
}
TextFieldRow {
visible: Sharing.remoteDesktop?.available === true
label: "Port"
detail: "The RDP port other machines connect to"
text: String(Sharing.remoteDesktop?.port ?? "")
placeholder: "3389"
enabled: !Sharing.busy
onAccepted: value => Sharing.setRdpPort(value)
}
SwitchRow {
visible: Sharing.remoteDesktop?.available === true
label: "View only"
detail: "Let someone watch this desktop without controlling the pointer or keyboard"
checked: Sharing.remoteDesktop?.viewOnly === true
enabled: !Sharing.busy
onToggled: value => Sharing.setRdpViewOnly(value)
}
// The password is typed into gnome-remote-desktop's own tool in a
// terminal, never into this page. grdctl prompts for it on a terminal
// and crashes without one, and passing it as an argument would publish
// it through /proc to every process on this machine.
ActionRow {
visible: Sharing.remoteDesktop?.available === true
label: "Credentials"
detail: Sharing.remoteDesktop?.hasCredentials === true
? "Stored in the login keyring · setting new ones opens a terminal to type into"
: "None stored yet · remote desktop cannot be turned on without them"
action: "Set…"
enabled: !Sharing.busy
onTriggered: Sharing.setRdpCredentials(Quickshell.env("USER") || "")
}
ActionRow {
visible: Sharing.remoteDesktop?.available === true
&& Sharing.remoteDesktop?.hasCredentials === true
label: "Forget the stored credentials"
detail: "Remote desktop cannot be turned on again until new ones are set"
action: "Clear"
enabled: !Sharing.busy
divider: false
onTriggered: Sharing.clearRdpCredentials()
}
}
SettingsCard {
title: "File and media sharing"
subtitle: "Sharing folders and media needs software this machine does not necessarily have."
TextRow {
label: "Share folders on the network"
detail: Sharing.fileSharing?.installed === true
? "Samba is installed"
: "Needs Samba, which is not installed. Settings does not install software."
value: Sharing.fileSharing?.installed === true ? "Available" : "Not installed"
}
SwitchRow {
visible: Sharing.mediaSharing?.installed === true
label: "Share music and video to devices"
// Said before it happens, not after: this advertises on the network
// to anything that speaks DLNA, with no password in front of it.
detail: Sharing.mediaSharing?.active === true
? "Rygel is serving your media to devices on the network"
: "Publishes your media folders to every device on the network. No password is asked for."
checked: Sharing.mediaSharing?.active === true
enabled: !Sharing.busy
divider: false
onToggled: value => Sharing.setMediaSharing(value)
}
TextRow {
visible: Sharing.mediaSharing?.installed !== true
label: "Share music and video to devices"
detail: "Needs Rygel, which is not installed."
value: "Not installed"
divider: false
}
}
}
@@ -21,6 +21,11 @@ SettingsPage {
// when nothing is being captured. Held here rather than per row so that
// starting a new capture cancels any other.
property string capturingChord: ""
// The action already holding a chord somebody just pressed, and the chord
// itself. Held while the capture stays open so the message can name both.
property string conflict: ""
property string conflictChord: ""
readonly property string storedXkbOptions: String(DesktopPreferences.get("keyboardOptions") ?? "")
function xkbOptions(): var {
@@ -170,11 +175,28 @@ SettingsPage {
height: 30
visible: bindRow.capturing
focus: bindRow.capturing
message: root.conflict === ""
? ""
: root.conflictChord + " is already " + root.conflict
// A chord already in use is reported rather than
// taken. Two actions on one chord means whichever
// Hyprland happens to read last wins, which is not
// a thing to discover later by pressing it.
onCaptured: chord => {
const taken = Keybinds.boundTo(chord, bindRow.modelData.luaChord);
if (taken !== "") {
root.conflict = taken;
root.conflictChord = chord;
return;
}
root.conflict = "";
Keybinds.rebind(bindRow.modelData.luaChord, chord);
root.capturingChord = "";
}
onCanceled: root.capturingChord = ""
onCanceled: {
root.conflict = "";
root.capturingChord = "";
}
}
Row {
@@ -0,0 +1,372 @@
// Snapshots: what is protected, and how to get something back.
//
// Per volume, because on this machine the news was that one volume was covered
// and the important one was not -- six hundred snapshots of the system, none of
// anyone's documents. A timeline that opened on all those snapshots would have
// buried that.
//
// Inside a volume, the timeline is the Time Machine view: points in time,
// newest first, each one openable as a folder tree you can take a file out of.
//
// Rollback is deliberately absent. snapper's rollback changes the btrfs default
// subvolume, and this system's fstab pins subvol= explicitly, which overrides
// it -- so it would report success and change nothing after a reboot.
import Quickshell
import QtQuick
import qs.config
import qs.services
SettingsPage {
id: root
objectName: "snapshots"
title: "Snapshots"
lede: "Points in time you can go back to, taken automatically for each volume."
// Points in time shown without asking. The page used to show none: the
// timeline and its Delete buttons sat behind a row labelled "Browse…", a
// word that promises a file browser, so a page about points in time
// appeared to list only how many there were.
readonly property int previewCount: 3
property string openConfig: ""
property string confirmingDelete: ""
property string confirmingRestore: ""
readonly property bool browsingOpen: Snapshots.browsingConfig !== ""
Component.onCompleted: Snapshots.refresh()
TextRow {
visible: Snapshots.lastError !== ""
label: "Snapshots need attention"
detail: Snapshots.lastError
value: ""
divider: false
}
// What just happened to the file that was already there.
TextRow {
visible: Snapshots.lastRestore !== null
label: "Restored"
detail: Snapshots.lastRestore
? String(Snapshots.lastRestore.restored ?? "")
+ (String(Snapshots.lastRestore.keptAs ?? "") !== ""
? " — the version that was there was kept as "
+ String(Snapshots.lastRestore.keptAs).split("/").pop()
: "")
: ""
value: ""
divider: false
}
// ── Anything unprotected is the headline ─────────────────────────────────
SettingsCard {
visible: Snapshots.unprotected.length > 0
title: "Not protected"
subtitle: "These volumes have no snapshot configuration, so nothing on them can be recovered."
Repeater {
model: Snapshots.unprotected
delegate: TextRow {
required property var modelData
width: parent.width
label: String(modelData.path ?? "")
detail: "btrfs subvolume " + String(modelData.subvolume ?? "")
+ " · needs a configuration, which takes a password once"
value: "Unprotected"
divider: false
}
}
}
// ── One card per volume ──────────────────────────────────────────────────
Repeater {
model: Snapshots.configs
delegate: SettingsCard {
id: volumeCard
required property var modelData
readonly property string configName: String(volumeCard.modelData.name ?? "")
readonly property var snapshots: volumeCard.modelData.snapshots ?? []
readonly property bool open: root.openConfig === volumeCard.configName
readonly property int shownCount: volumeCard.open
? volumeCard.snapshots.length
: Math.min(root.previewCount, volumeCard.snapshots.length)
title: Snapshots.labelFor(volumeCard.modelData)
subtitle: String(volumeCard.modelData.subvolume ?? "")
SwitchRow {
label: "Take snapshots automatically"
detail: volumeCard.modelData.timelineEnabled
? Snapshots.describe(volumeCard.modelData)
: "Nothing is being taken for this volume"
checked: volumeCard.modelData.timelineEnabled === true
enabled: !Snapshots.busy && volumeCard.modelData.readable === true
onToggled: value => Snapshots.setTimeline(volumeCard.configName, value)
}
TextRow {
label: "Keep"
detail: "Older points are removed automatically once these counts are exceeded"
value: Snapshots.retentionSummary(volumeCard.modelData)
}
ActionRow {
label: "Take one now"
detail: "Kept until you remove it, unlike the automatic ones"
action: "Take snapshot"
enabled: !Snapshots.busy && volumeCard.modelData.readable === true
onTriggered: Snapshots.take(volumeCard.configName, "Taken from Settings")
}
TextRow {
visible: volumeCard.snapshots.length === 0
label: "No points in time yet"
detail: "One is taken automatically on the schedule above."
value: ""
divider: false
}
// ── The timeline ─────────────────────────────────────────────────
Column {
width: parent.width
visible: !root.browsingOpen
Repeater {
model: volumeCard.open
? volumeCard.snapshots
: volumeCard.snapshots.slice(0, root.previewCount)
delegate: SettingRow {
id: pointRow
required property var modelData
required property int index
readonly property string token: volumeCard.configName + ":" + pointRow.modelData.number
readonly property bool confirming: root.confirmingDelete === pointRow.token
width: parent.width
// A kept snapshot is one the timeline will not remove,
// which is the distinction that matters when choosing
// what to rely on later.
icon: pointRow.modelData.kept ? "\u{F0A22}" : "\u{F0954}"
label: String(pointRow.modelData.date ?? "")
detail: String(pointRow.modelData.description ?? "")
+ " · #" + pointRow.modelData.number
+ (pointRow.modelData.kept ? " · kept" : "")
controlWidth: 250
divider: pointRow.index < volumeCard.shownCount - 1
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
SettingsButton {
text: "Open"
enabled: !Snapshots.browsing
onClicked: Snapshots.browse(volumeCard.configName,
Number(pointRow.modelData.number), "")
}
SettingsButton {
text: pointRow.confirming ? "Keep" : "Delete"
enabled: !Snapshots.busy
onClicked: root.confirmingDelete =
pointRow.confirming ? "" : pointRow.token
}
SettingsButton {
visible: pointRow.confirming
text: "Delete it"
tone: "danger"
enabled: !Snapshots.busy
onClicked: {
root.confirmingDelete = "";
Snapshots.remove(volumeCard.configName,
Number(pointRow.modelData.number));
}
}
}
}
}
SettingRow {
width: parent.width
visible: volumeCard.snapshots.length > root.previewCount
activatable: true
divider: false
label: volumeCard.open
? "Showing all " + volumeCard.snapshots.length + " points in time"
: (volumeCard.snapshots.length - root.previewCount)
+ " older point"
+ (volumeCard.snapshots.length - root.previewCount === 1 ? "" : "s")
+ " in time"
detail: volumeCard.open
? ""
: "Oldest is " + String(volumeCard.snapshots[volumeCard.snapshots.length - 1]?.date ?? "")
controlWidth: 110
onActivated: {
root.confirmingDelete = "";
root.openConfig = volumeCard.open ? "" : volumeCard.configName;
}
Text {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: volumeCard.open ? "Show fewer \u25B4" : "Show all \u25BE"
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
}
// ── Inside one point in time ─────────────────────────────────────
Column {
width: parent.width
visible: volumeCard.open && root.browsingOpen
&& Snapshots.browsingConfig === volumeCard.configName
ActionRow {
width: parent.width
label: Snapshots.browsingPath === ""
? "Snapshot #" + Snapshots.browsingSnapshot
: "…/" + Snapshots.browsingPath
detail: "Choosing Restore puts a copy back where it came from, keeping whatever is there now"
action: "Back"
enabled: !Snapshots.browsing
onTriggered: Snapshots.browseUp()
}
TextRow {
width: parent.width
visible: Snapshots.browsing
label: "Reading the snapshot…"
detail: "Listing a folder from a point in time"
value: ""
}
Repeater {
model: Snapshots.browsing ? [] : Snapshots.browseEntries
delegate: SettingRow {
id: entryRow
required property var modelData
required property int index
readonly property string entryPath: Snapshots.browsingPath === ""
? String(entryRow.modelData.name)
: Snapshots.browsingPath + "/" + String(entryRow.modelData.name)
readonly property bool confirming: root.confirmingRestore === entryRow.entryPath
width: parent.width
icon: entryRow.modelData.directory ? "\u{F024B}" : "\u{F0214}"
label: String(entryRow.modelData.name ?? "")
detail: entryRow.modelData.directory
? "Folder"
: Snapshots.formatBytes(entryRow.modelData.bytes ?? 0)
controlWidth: 230
divider: entryRow.index < Snapshots.browseEntries.length - 1
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
SettingsButton {
visible: entryRow.modelData.directory === true
text: "Open"
enabled: !Snapshots.browsing
onClicked: Snapshots.browse(Snapshots.browsingConfig,
Snapshots.browsingSnapshot,
entryRow.entryPath)
}
SettingsButton {
text: entryRow.confirming ? "Cancel" : "Restore"
enabled: !Snapshots.busy
onClicked: root.confirmingRestore =
entryRow.confirming ? "" : entryRow.entryPath
}
SettingsButton {
visible: entryRow.confirming
text: "Put it back"
tone: "danger"
enabled: !Snapshots.busy
onClicked: {
root.confirmingRestore = "";
Snapshots.restore(Snapshots.browsingConfig,
Snapshots.browsingSnapshot,
entryRow.entryPath);
}
}
}
}
}
TextRow {
width: parent.width
visible: !Snapshots.browsing && Snapshots.browseTruncated
label: "Only the first entries are shown"
detail: "This folder holds more than this list can usefully show"
value: ""
divider: false
}
TextRow {
width: parent.width
visible: !Snapshots.browsing && Snapshots.browseEntries.length === 0
label: "Nothing here"
detail: "This folder was empty at that point in time"
value: ""
divider: false
}
}
}
}
// ── What it costs ────────────────────────────────────────────────────────
SettingsCard {
title: "Space"
subtitle: "A snapshot shares its data with the live filesystem and grows only as files change afterwards."
TextRow {
label: "Free space"
detail: "Snapshots are removed oldest-first when this runs low"
value: Snapshots.formatBytes(Snapshots.space?.freeBytes ?? 0)
}
TextRow {
label: "Automatic snapshots"
detail: Snapshots.timelineRunning
? "The hourly timer is running"
: "The hourly timer is not running, so nothing new is being taken"
value: Snapshots.timelineRunning ? "Running" : "Stopped"
}
// Honest about what cannot be measured: per-snapshot size needs btrfs
// quota groups, which cost performance on every write. Reporting a
// made-up number would be worse than saying so.
TextRow {
label: "Space used by snapshots"
detail: "Measuring this per snapshot needs btrfs quotas, which slow down every write. Free space above is the number that matters."
value: "Not measured"
divider: false
}
}
}
@@ -89,6 +89,19 @@ Rectangle {
}
}
Row {
anchors.verticalCenter: parent.verticalCenter
spacing: 7
// Outputs only. Testing an input would mean recording and playing
// it back, which is a different thing than this button implies.
SettingsButton {
anchors.verticalCenter: parent.verticalCenter
visible: root.output
text: "Test"
onClicked: SoundTest.play(root.node)
}
SettingsButton {
id: useButton
anchors.verticalCenter: parent.verticalCenter
@@ -98,6 +111,7 @@ Rectangle {
onClicked: AudioDevices.select(root.output, root.node)
}
}
}
IconButton {
id: muteButton
@@ -0,0 +1,216 @@
// SSH keys, and what this machine can reach with them.
//
// Read-heavy on purpose. The genuinely useful things a person wants from a page
// like this are "which key is this", "is the agent holding it", "copy the public
// half", and "forget a host whose key changed" -- and all four are safe. What is
// not here is generating a key, because a passphrase cannot be collected and
// handed to ssh-keygen without putting it somewhere it should not be, and a
// page offering to make an unencrypted key instead would be a downgrade
// disguised as a feature.
import Quickshell
import QtQuick
import qs.config
import qs.services
SettingsPage {
id: root
objectName: "ssh-keys"
title: "SSH Keys"
lede: "The keys this machine signs in with, and the hosts it has met."
property string confirmingForget: ""
Component.onCompleted: if (!SshKeys.scanned) SshKeys.refresh()
TextRow {
visible: SshKeys.lastError !== ""
label: "That did not work"
detail: SshKeys.lastError
value: ""
divider: false
}
TextRow {
visible: SshKeys.scanned && !SshKeys.available
label: "No SSH directory"
detail: "Nothing has created ~/.ssh on this machine yet."
value: ""
divider: false
}
// ── Keys readable by other people ───────────────────────────────────────
SettingsCard {
visible: SshKeys.overexposed.length > 0
title: SshKeys.overexposed.length === 1
? "A private key is readable by other accounts"
: "Private keys are readable by other accounts"
subtitle: "ssh refuses to use a key with these permissions, so it will never be offered."
Repeater {
model: SshKeys.overexposed
delegate: TextRow {
required property var modelData
width: parent.width
label: String(modelData.name ?? "")
detail: "Mode " + String(modelData.mode ?? "") + " · should be 600"
value: ""
}
}
}
// ── The agent ───────────────────────────────────────────────────────────
SettingsCard {
title: "Agent"
subtitle: SshKeys.agent?.available === true
? (SshKeys.agent?.kind === "gnome-keyring"
? "The login keyring is holding your keys, and offers every key it finds in ~/.ssh."
: "An SSH agent is holding your keys for this session.")
: String(SshKeys.agent?.detail ?? "No SSH agent is running.")
TextRow {
label: "Holding"
detail: SshKeys.agent?.available === true
? String(SshKeys.agent?.socket ?? "")
: "Keys will be asked for on every connection"
value: SshKeys.loadedCount + " key" + (SshKeys.loadedCount === 1 ? "" : "s")
divider: SshKeys.agent?.kind === "gnome-keyring"
}
// Said plainly because it is measurable and surprising: ssh-add -d
// reports success against this agent and the key is still offered a
// moment later, because it is read back off disk.
TextRow {
visible: SshKeys.agent?.kind === "gnome-keyring"
label: "Removing a key from this agent does not stick"
detail: "It lists every key in ~/.ssh, so one removed comes straight back. Move the file out of ~/.ssh to stop it being offered."
value: ""
divider: false
}
}
// ── Keys ────────────────────────────────────────────────────────────────
SettingsCard {
title: SshKeys.keys.length === 1 ? "Your key" : "Your keys"
subtitle: SshKeys.keys.length === 0
? "No keys in " + SshKeys.directory
: "Public halves are safe to share; the private half never leaves this machine."
Repeater {
model: SshKeys.keys
delegate: SettingRow {
id: keyRow
required property var modelData
required property int index
label: String(keyRow.modelData.name ?? "")
detail: String(keyRow.modelData.type ?? "") + " · "
+ String(keyRow.modelData.fingerprint ?? "")
+ (String(keyRow.modelData.comment ?? "") !== ""
? " · " + keyRow.modelData.comment : "")
+ (keyRow.modelData.encrypted === true
? " · passphrase protected"
: (keyRow.modelData.encrypted === false ? " · no passphrase" : ""))
divider: keyRow.index < SshKeys.keys.length - 1
controlWidth: 220
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
Text {
anchors.verticalCenter: parent.verticalCenter
visible: keyRow.modelData.loaded === true
text: "In the agent"
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
SettingsButton {
anchors.verticalCenter: parent.verticalCenter
visible: keyRow.modelData.loaded !== true
&& SshKeys.agent?.available === true
text: "Add to agent"
enabled: !SshKeys.busy
onClicked: SshKeys.addToAgent(String(keyRow.modelData.path))
}
SettingsButton {
anchors.verticalCenter: parent.verticalCenter
text: "Copy public key"
onClicked: SshKeys.copyPublicKey(String(keyRow.modelData.publicPath))
}
}
}
}
}
// ── Known hosts ─────────────────────────────────────────────────────────
SettingsCard {
visible: SshKeys.hosts.length > 0
title: "Known hosts"
subtitle: "Machines this one has connected to before. Forgetting one means being asked to trust it again."
Repeater {
model: SshKeys.hosts
delegate: SettingRow {
id: hostRow
required property var modelData
required property int index
readonly property bool confirming:
root.confirmingForget === String(hostRow.modelData.host ?? "")
label: hostRow.modelData.hashed === true
? hostRow.modelData.count + " hashed entries"
: String(hostRow.modelData.host ?? "")
detail: hostRow.modelData.hashed === true
? "Hashed on purpose, so the names cannot be read from the file"
: (hostRow.confirming
? "You will be asked to trust this host the next time you connect."
: (hostRow.modelData.types ?? []).join(", "))
divider: hostRow.index < SshKeys.hosts.length - 1
controlWidth: 190
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
SettingsButton {
anchors.verticalCenter: parent.verticalCenter
visible: hostRow.confirming
text: "Forget it"
tone: "danger"
enabled: !SshKeys.busy
onClicked: {
root.confirmingForget = "";
SshKeys.forgetHost(String(hostRow.modelData.host));
}
}
SettingsButton {
anchors.verticalCenter: parent.verticalCenter
visible: hostRow.modelData.hashed !== true
text: hostRow.confirming ? "Keep" : "Forget…"
enabled: !SshKeys.busy
onClicked: root.confirmingForget = hostRow.confirming
? "" : String(hostRow.modelData.host)
}
}
}
}
}
}
@@ -0,0 +1,356 @@
// Storage: what is using the drive, then what the drive is.
//
// The page is one scroll with a rule behind it: everything above the divider
// answers "how much space do I have and what took it", everything below answers
// "what is this device and is it healthy". A card that answers neither does not
// belong here.
//
// Measuring what is using the space is expensive -- a Steam library alone can
// be a terabyte -- so it happens on request rather than on open. The page says
// so plainly instead of showing an empty list that reads as "nothing here".
//
// Partitioning and formatting are deliberately absent; GNOME Disks is one row
// away for that.
import Quickshell
import QtQuick
import qs.config
import qs.services
SettingsPage {
id: root
objectName: "storage"
title: "Storage"
lede: "How much space is left, what is using it, and whether the drive is healthy."
readonly property var rootFs: Disks.rootFilesystem
readonly property var drive: Disks.primaryDrive
// The measured folders, as a share of the largest one, so the bars compare
// against each other rather than against a total they do not sum to.
readonly property real largestFolder: {
let largest = 0;
for (const folder of Disks.folders)
largest = Math.max(largest, Number(folder.bytes ?? 0));
return largest;
}
readonly property var removableDrives: Disks.drives.filter(entry => entry.removable)
Component.onCompleted: Disks.refresh()
TextRow {
visible: Disks.lastError !== ""
label: "Storage needs attention"
detail: Disks.lastError
value: ""
divider: false
}
// ── Space ────────────────────────────────────────────────────────────────
SettingsCard {
title: "Free space"
subtitle: root.rootFs && (root.rootFs.mountpoints ?? []).length > 1
? "One filesystem is mounted at " + Disks.mountLabel(root.rootFs)
+ ", so they share the same space."
: "The filesystem this session runs from."
Column {
width: parent.width
spacing: 10
Row {
width: parent.width
spacing: 12
Text {
text: root.rootFs ? Disks.formatBytes(root.rootFs.availBytes) + " free" : "Reading…"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeTitle
font.weight: Font.DemiBold
}
Text {
anchors.verticalCenter: parent.verticalCenter
visible: root.rootFs !== null
text: root.rootFs
? "of " + Disks.formatBytes(root.rootFs.sizeBytes)
+ " · " + String(root.rootFs.fstype ?? "")
: ""
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
}
Rectangle {
width: parent.width
height: 10
radius: 5
color: Theme.alpha(Theme.fg, 0.09)
Rectangle {
width: Math.max(parent.width * Disks.usedFraction(root.rootFs), parent.height)
height: parent.height
radius: parent.radius
// Color is the only warning this bar gives, so it changes
// at the point where free space starts to be a problem
// rather than at a round number.
color: Disks.usedFraction(root.rootFs) > 0.92
? Theme.danger
: (Disks.usedFraction(root.rootFs) > 0.8 ? Theme.warn : Theme.accent)
}
}
Text {
text: root.rootFs
? Disks.formatBytes(root.rootFs.usedBytes) + " used · "
+ Math.round(Disks.usedFraction(root.rootFs) * 100) + "%"
: ""
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
}
SettingsCard {
title: "What is using it"
subtitle: Disks.foldersMeasured
? "Measured by walking each folder."
: "Measuring means walking every file, so it is not done automatically."
ActionRow {
visible: !Disks.foldersMeasured || Disks.scanning
label: Disks.scanning ? "Measuring…" : "Measure folders"
detail: Disks.scanning
? "Walking the largest folders. This can take a minute."
: "Walk the usual suspects and report what is biggest."
action: "Measure"
enabled: !Disks.scanning
divider: Disks.foldersMeasured
onTriggered: Disks.scan()
}
Repeater {
model: Disks.foldersMeasured ? Disks.folders : []
delegate: Column {
id: folderBlock
required property var modelData
required property int index
width: parent.width
TextRow {
width: folderBlock.width
label: String(folderBlock.modelData.label ?? "")
detail: String(folderBlock.modelData.path ?? "")
value: Disks.formatBytes(folderBlock.modelData.bytes ?? 0)
divider: false
}
Rectangle {
width: folderBlock.width
height: 4
radius: 2
color: Theme.alpha(Theme.fg, 0.08)
// Relative to the LARGEST folder, not to the drive. At this
// scale one folder holds almost everything, so bars drawn
// against the total would leave every other row invisible.
Rectangle {
width: root.largestFolder > 0
? Math.max(parent.width * (Number(folderBlock.modelData.bytes ?? 0) / root.largestFolder), 2)
: 0
height: parent.height
radius: parent.radius
color: Theme.accent
}
}
Item { width: 1; height: 10 }
}
}
TextRow {
visible: Disks.foldersMeasured && Disks.folderScanTruncated
label: "Some folders were not measured"
detail: "The walk ran out of time before reaching them, so this list is incomplete."
value: ""
divider: Disks.containers !== null
}
ActionRow {
visible: Disks.containers !== null
&& Number(Disks.containers?.reclaimableBytes ?? 0) > 0
label: "Unused container images"
detail: Disks.containers
? Disks.formatBytes(Disks.containers.reclaimableBytes)
+ " of " + Disks.formatBytes(Disks.containers.totalBytes)
+ " is not used by any container"
: ""
action: "Show"
divider: false
// Reclaiming is not offered here on purpose: pruning images can
// destroy work that lives outside this desktop, and a settings pane
// should not put that one click deep. This opens a terminal showing
// what would be reclaimed, and leaves the decision there.
onTriggered: Quickshell.execDetached(
["kitty", "--hold", "-e", "podman", "system", "df"])
}
}
// ── The device ───────────────────────────────────────────────────────────
SettingsCard {
title: "Drive"
subtitle: root.drive
? String(root.drive.model) + (root.drive.encrypted ? " · encrypted" : "")
: "Reading…"
TextRow {
visible: root.drive !== null
label: "Health"
detail: root.drive && root.drive.selfTest !== ""
? "Last self-test: " + String(root.drive.selfTest)
: "Reported by the drive itself"
value: Disks.healthSummary(root.drive)
}
TextRow {
visible: root.drive !== null && root.drive.temperatureC !== null
label: "Temperature"
detail: "Measured by the drive controller"
value: root.drive && root.drive.temperatureC !== null
? String(root.drive.temperatureC) + " °C" : ""
}
TextRow {
visible: root.drive !== null && root.drive.powerOnHours !== null
label: "Powered on"
detail: "Total hours this drive has been running"
value: root.drive && root.drive.powerOnHours !== null
? String(root.drive.powerOnHours) + " hours" : ""
}
TextRow {
visible: root.drive !== null && root.drive.encrypted
label: "Encryption"
detail: "The filesystem is encrypted and unlocked at boot"
value: "LUKS"
}
ActionRow {
label: "Partitioning and formatting"
detail: "Deliberately not here. Disks is the tool that owns erasing a drive."
action: "Open Disks"
divider: false
onTriggered: Quickshell.execDetached(["gapplication", "launch", "org.gnome.DiskUtility"])
}
}
SettingsCard {
title: "Filesystems"
subtitle: "Every mounted filesystem, grouped by the device behind it."
Repeater {
model: Disks.filesystems
delegate: TextRow {
id: filesystemRow
required property var modelData
required property int index
width: parent.width
label: Disks.mountLabel(filesystemRow.modelData)
detail: String(filesystemRow.modelData.fstype ?? "")
+ " on " + String(filesystemRow.modelData.device ?? "")
value: Disks.formatBytes(filesystemRow.modelData.availBytes) + " free of "
+ Disks.formatBytes(filesystemRow.modelData.sizeBytes)
divider: filesystemRow.index < Disks.filesystems.length - 1
}
}
}
// Named here because this is the page somebody opens when space is short,
// and container images are usually the largest thing nobody remembers
// having. Only shown when there is actually something to reclaim.
SettingsCard {
visible: Containers.available && Containers.reclaimable > 0
title: "Containers are holding " + Containers.formatBytes(Containers.reclaimable)
ActionRow {
label: "Images and volumes nothing references"
detail: Containers.unusedImages.length + " image"
+ (Containers.unusedImages.length === 1 ? "" : "s") + " and "
+ Containers.unusedVolumes.length + " volume"
+ (Containers.unusedVolumes.length === 1 ? "" : "s")
action: "Review"
divider: false
onTriggered: ShellState.settingsPage = "containers"
}
}
SettingsCard {
title: "Removable drives"
subtitle: "USB drives and memory cards."
Repeater {
model: root.removableDrives
delegate: ActionRow {
id: removableRow
required property var modelData
width: parent.width
label: String(removableRow.modelData.model ?? removableRow.modelData.name)
detail: Disks.formatBytes(removableRow.modelData.sizeBytes)
+ " · " + String(removableRow.modelData.path)
action: removableRow.modelData.ejectable ? "Eject" : "Unmount"
divider: false
onTriggered: removableRow.modelData.ejectable
? Disks.eject(String(removableRow.modelData.path))
: Disks.unmount(String(removableRow.modelData.path))
}
}
TextRow {
visible: root.removableDrives.length === 0
label: "None connected"
detail: "Drives you plug in appear here, with a way to unmount them safely."
value: ""
divider: false
}
}
SettingsCard {
visible: Disks.swap.length > 0
title: "Swap"
subtitle: "Compressed swap lives in memory, not on the drive."
Repeater {
model: Disks.swap
delegate: TextRow {
id: swapRow
required property var modelData
required property int index
width: parent.width
label: String(swapRow.modelData.name ?? "")
detail: String(swapRow.modelData.kind) === "zram"
? "Compressed swap in RAM" : "Swap"
value: Disks.formatBytes(swapRow.modelData.sizeBytes)
divider: swapRow.index < Disks.swap.length - 1
}
}
}
}
@@ -0,0 +1,50 @@
// A toggle that is NOT schema-bound.
//
// ToggleRow reads and writes a preference key. Some switches govern system
// state instead -- an account flag, a systemd unit -- which lives outside
// Panama's settings file and is read back from the system that owns it.
import QtQuick
import qs.config
SettingRow {
id: root
property bool checked: false
property bool enabled: true
signal toggled(value: bool)
controlWidth: 54
Rectangle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
width: 44
height: 26
radius: height / 2
color: root.checked ? Theme.accent : Theme.alpha(Theme.fg, 0.14)
opacity: root.enabled ? 1 : 0.45
Rectangle {
x: root.checked ? parent.width - width - 3 : 3
anchors.verticalCenter: parent.verticalCenter
width: 20
height: 20
radius: height / 2
color: root.checked ? Theme.bgDark : Theme.fg
// Short and non-repeating: this animates only when someone acts.
Behavior on x {
NumberAnimation { duration: 120; easing.type: Easing.OutCubic }
}
}
MouseArea {
anchors.fill: parent
enabled: root.enabled
cursorShape: Qt.PointingHandCursor
onClicked: root.toggled(!root.checked)
}
}
}
@@ -0,0 +1,88 @@
// A row whose trailing control is a free-text field, NOT bound to a schema key.
//
// TextFieldRow {
// label: "Full name"
// text: Accounts.me.realName
// onAccepted: value => Accounts.setRealName("gib", value)
// }
//
// TextEntryRow is the schema-bound one, and is the right choice for anything
// that is a Panama setting. This is for values that live in the system rather
// than in settings.json -- an account's real name, the machine's hostname --
// where the label, the validation, and the write all belong to whoever owns
// that value.
//
// Committed on Enter or when focus leaves, never per keystroke: these drive
// privileged calls that prompt, and prompting once per typed character would be
// unusable.
import QtQuick
import qs.config
SettingRow {
id: root
property string text: ""
property string placeholder: ""
property bool enabled: true
signal accepted(value: string)
controlWidth: 220
// An external change replaces what is shown, unless it would yank the field
// out from under someone mid-edit.
onTextChanged: if (!input.activeFocus) input.text = root.text
Rectangle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
width: root.controlWidth
height: 32
radius: 9
color: Theme.alpha(Theme.fg, root.enabled ? 0.06 : 0.03)
border.width: input.activeFocus ? 2 : 1
border.color: input.activeFocus
? Theme.alpha(Theme.accent, 0.55)
: Theme.alpha(Theme.fg, 0.1)
opacity: root.enabled ? 1 : 0.5
TextInput {
id: input
anchors.fill: parent
anchors.leftMargin: 11
anchors.rightMargin: 11
enabled: root.enabled
activeFocusOnTab: true
text: root.text
color: Theme.fg
selectionColor: Theme.alpha(Theme.accent, 0.5)
selectedTextColor: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
verticalAlignment: TextInput.AlignVCenter
clip: true
onAccepted: if (input.text !== root.text) root.accepted(input.text)
onActiveFocusChanged: {
if (input.activeFocus)
return;
if (input.text !== root.text)
root.accepted(input.text);
else
input.text = root.text;
}
Text {
anchors.fill: parent
visible: input.text === ""
text: root.placeholder
color: Theme.fgMuted
font: input.font
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
}
}
}
@@ -0,0 +1,310 @@
// Software updates.
//
// Three sources that fail independently -- packages, applications, firmware --
// so each is counted and applied on its own. Blending them into one number
// would hide the case that matters: a flatpak mirror being down says nothing
// about whether a security fix is waiting.
//
// Applying packages takes a snapshot first, named after what is about to
// happen, so the Snapshots page shows "before 32 package updates" rather than a
// timestamp. That is the thing neither macOS nor Windows does cleanly, and it
// is nearly free here.
import Quickshell
import QtQuick
import qs.config
import qs.services
SettingsPage {
id: root
objectName: "updates"
title: "Software Update"
lede: "Packages, applications, and firmware, each from the place it actually comes from."
property string expandedSource: ""
Component.onCompleted: {
Updates.refresh();
// A first visit with nothing cached should not show a confident "up to
// date" it has no basis for, so it goes and finds out.
if (!Updates.everChecked && !Updates.checking)
Updates.check();
}
TextRow {
visible: Updates.lastError !== ""
label: "Updates need attention"
detail: Updates.lastError
value: ""
divider: false
}
TextRow {
visible: Updates.lastApplied !== null
label: "Updated"
detail: Updates.lastApplied
? Updates.sourceLabel(String(Updates.lastApplied.source ?? ""))
+ (String(Updates.lastApplied.restorePoint ?? "") !== ""
? " · a snapshot was taken first, number "
+ String(Updates.lastApplied.restorePoint)
: "")
: ""
value: ""
divider: false
}
// ── The headline ─────────────────────────────────────────────────────────
SettingsCard {
Column {
width: parent.width
spacing: 10
Row {
width: parent.width
spacing: 12
Text {
text: Updates.checking ? "Checking…" : Updates.summary()
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeTitle
font.weight: Font.DemiBold
}
Text {
anchors.verticalCenter: parent.verticalCenter
visible: Updates.securityCount > 0
text: Updates.securityCount + " carry a security advisory"
color: Theme.warn
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
}
Text {
text: Updates.lastCheckedText()
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
ActionRow {
label: "Check for updates"
detail: "Refreshes package metadata, application remotes, and firmware. Takes a few seconds."
action: Updates.checking ? "Checking…" : "Check now"
enabled: !Updates.busy
divider: Updates.rebootNeeded
onTriggered: Updates.check()
}
// The honest version of "restart required": the running kernel is not
// the newest installed one, so a reboot would change which kernel runs.
TextRow {
visible: Updates.rebootNeeded
label: "Restart to finish"
detail: "A newer kernel is installed than the one running. "
+ String(Updates.kernel?.running ?? "") + " → "
+ String(Updates.kernel?.newestInstalled ?? "")
value: "Restart needed"
divider: false
}
}
// ── One card per source ──────────────────────────────────────────────────
SettingsCard {
title: "System packages"
subtitle: Updates.dnf?.available === false
? "dnf is not available on this machine."
: (Number(Updates.dnf?.count ?? 0) === 0
? "Nothing waiting."
: Updates.dnf.count + " package"
+ (Updates.dnf.count === 1 ? "" : "s") + " ready to install"
+ (Updates.securityCount > 0
? ", " + Updates.securityCount + " carrying an advisory" : ""))
ActionRow {
visible: Number(Updates.dnf?.count ?? 0) > 0
label: "Install package updates"
detail: "Asks for your password, and takes a snapshot first so this can be undone"
action: Updates.applying ? "Working…" : "Install"
enabled: !Updates.busy
onTriggered: Updates.apply("dnf")
}
ActionRow {
visible: Number(Updates.dnf?.count ?? 0) > 0
label: "What would change"
detail: root.expandedSource === "dnf"
? "Every package that would be replaced"
: Updates.dnf.count + " packages"
action: root.expandedSource === "dnf" ? "Hide" : "Show"
divider: root.expandedSource === "dnf"
onTriggered: root.expandedSource = root.expandedSource === "dnf" ? "" : "dnf"
}
Repeater {
model: root.expandedSource === "dnf" ? (Updates.dnf?.packages ?? []) : []
delegate: TextRow {
required property var modelData
required property int index
width: parent.width
label: String(modelData.name ?? "")
detail: String(modelData.repository ?? "")
value: String(modelData.version ?? "")
divider: index < (Updates.dnf?.packages ?? []).length - 1
}
}
TextRow {
visible: Number(Updates.dnf?.count ?? 0) === 0 && Updates.everChecked
label: "Packages are current"
detail: "Nothing from the system repositories is waiting"
value: ""
divider: false
}
}
SettingsCard {
title: "Applications"
subtitle: Updates.flatpak?.available === false
? "Flatpak is not installed."
: (Number(Updates.flatpak?.count ?? 0) === 0
? "Nothing waiting."
: Updates.flatpak.count + " application"
+ (Updates.flatpak.count === 1 ? "" : "s") + " ready to update")
Repeater {
model: Updates.flatpak?.applications ?? []
delegate: TextRow {
required property var modelData
required property int index
width: parent.width
label: String(modelData.id ?? "")
detail: "Flatpak"
value: String(modelData.version ?? "")
divider: true
}
}
ActionRow {
visible: Number(Updates.flatpak?.count ?? 0) > 0
label: "Update applications"
detail: "Needs no password: these are installed for your account"
action: Updates.applying ? "Working…" : "Update"
enabled: !Updates.busy
onTriggered: Updates.apply("flatpak")
}
SwitchRow {
label: "Update applications automatically"
detail: "Once a day, in the background. Applications are not a security boundary the way packages are, so this is safe to leave on; packages still ask."
checked: Updates.automatic?.flatpakEnabled === true
enabled: !Updates.busy && Updates.automatic?.flatpakAvailable === true
divider: false
onToggled: value => Updates.setAutomaticFlatpak(value)
}
}
SettingsCard {
title: "Firmware"
subtitle: Updates.firmware?.available === false
? "Firmware updating is not available on this machine."
: (Number(Updates.firmware?.count ?? 0) === 0
? "No firmware updates are offered for this hardware."
: Updates.firmware.count + " device"
+ (Updates.firmware.count === 1 ? "" : "s") + " have firmware available")
Repeater {
model: Updates.firmware?.devices ?? []
delegate: TextRow {
required property var modelData
required property int index
width: parent.width
label: String(modelData.name ?? "")
detail: String(modelData.version ?? "") + " → " + String(modelData.target ?? "")
+ (modelData.needsReboot ? " · installs on restart" : "")
value: ""
divider: true
}
}
ActionRow {
visible: Number(Updates.firmware?.count ?? 0) > 0
label: "Install firmware"
detail: "Some devices only finish updating after a restart"
action: Updates.applying ? "Working…" : "Install"
enabled: !Updates.busy
divider: false
onTriggered: Updates.apply("firmware")
}
// Offered only when the machinery exists. When it does not, this says
// so rather than showing a switch that could not do anything --
// installing software is not a settings action.
SwitchRow {
visible: Updates.automatic?.dnfAutomaticAvailable === true
label: "Download package updates automatically"
detail: "Fetches them in the background each morning so installing is quick. It does not install them: a machine that updates packages unattended can reboot into a kernel nobody chose."
checked: Updates.automatic?.dnfAutomaticEnabled === true
enabled: !Updates.busy
divider: false
onToggled: value => Updates.setAutomaticDnf(value)
}
TextRow {
visible: Updates.automatic?.dnfAutomaticAvailable === false
label: "Automatic package updates"
detail: "Not set up. dnf-automatic is not installed, and Settings does not install software."
value: "Off"
divider: false
}
}
// Worth showing because automatic updates leave no other trace. Something
// that installed itself overnight is invisible until you look here.
SettingsCard {
title: "Recently installed"
subtitle: Updates.historyLoaded
? "Packages and applications, newest first, from both sources at once."
: "Asks dnf and flatpak for their transaction logs."
ActionRow {
visible: !Updates.historyLoaded
label: "Show what has been installed"
detail: "Not loaded with the page: it reads the whole history from both sources"
action: Updates.loadingHistory ? "Reading…" : "Show"
enabled: !Updates.loadingHistory
divider: false
onTriggered: Updates.loadHistory()
}
Repeater {
model: Updates.history
delegate: TextRow {
required property var modelData
required property int index
width: parent.width
label: String(modelData.summary ?? "")
detail: Updates.agoText(Number(modelData.at ?? 0))
value: Updates.describeHistory(modelData)
divider: index < Updates.history.length - 1
}
}
TextRow {
visible: Updates.historyLoaded && Updates.history.length === 0
label: "Nothing recorded"
detail: "Neither dnf nor flatpak has a transaction history here."
value: ""
divider: false
}
}
}
@@ -0,0 +1,403 @@
// Your account, and anyone else who signs in to this machine.
//
// The layout puts your own account first because that is what someone opens
// this page for, and the avatar leads because it is the thing that shows up
// elsewhere in the desktop -- the Control Center draws it, and the lock screen
// and login screen read the same file.
//
// Everything privileged here prompts through polkit. A prompt that is dismissed
// is a normal outcome and says so plainly rather than reporting a failure.
import Quickshell
import Quickshell.Widgets
import QtQuick
import qs.config
import qs.services
SettingsPage {
id: root
objectName: "users"
title: "Users"
lede: "Your account, and anyone else who signs in to this machine."
// One panel open at a time: changing a password and adding an account are
// both multi-field, and two open at once reads as a form with no shape.
property string openPanel: ""
property string newPassword: ""
property string confirmPassword: ""
property string newUserName: ""
property string newRealName: ""
property bool newUserIsAdministrator: false
property string confirmingRemoval: ""
readonly property var me: UserAccounts.me
readonly property string passwordProblem: {
if (root.newPassword === "")
return "";
if (root.newPassword.length < 6)
return "Use at least six characters.";
if (root.confirmPassword !== "" && root.newPassword !== root.confirmPassword)
return "The two entries do not match.";
return "";
}
readonly property bool passwordReady: root.newPassword.length >= 6
&& root.newPassword === root.confirmPassword
function closePanels(): void {
root.openPanel = "";
root.newPassword = "";
root.confirmPassword = "";
root.newUserName = "";
root.newRealName = "";
root.newUserIsAdministrator = false;
}
Component.onCompleted: UserAccounts.refresh()
TextRow {
visible: UserAccounts.lastError !== ""
label: "Accounts need attention"
detail: UserAccounts.lastError
value: ""
divider: false
}
// ── You ──────────────────────────────────────────────────────────────────
// Chosen but not yet framed. While this is set the cropper replaces the
// account card, because framing is a decision to finish, not a setting to
// leave half-made.
property string pendingPicture: ""
SettingsCard {
visible: root.pendingPicture !== ""
title: "Frame the picture"
AvatarCropper {
width: parent.width
source: root.pendingPicture
onCropped: (x, y, size) => {
UserAccounts.setIconCropped(String(root.me?.userName ?? ""),
root.pendingPicture, x, y, size);
root.pendingPicture = "";
}
onCancelled: root.pendingPicture = ""
}
}
SettingsCard {
visible: root.me !== null && root.pendingPicture === ""
Row {
width: parent.width
spacing: 20
Item {
width: 96
height: 96
ClippingRectangle {
anchors.fill: parent
radius: width / 2
color: Theme.alpha(Theme.fg, 0.08)
Image {
anchors.fill: parent
source: UserAccounts.avatarUrl
visible: UserAccounts.avatarUrl !== ""
fillMode: Image.PreserveAspectCrop
// accountsservice replaces the file in place, so the
// path never changes. cache:false is not enough on its
// own -- an unchanged source is never re-read at all --
// which is why avatarUrl carries a revision fragment.
cache: false
asynchronous: true
sourceSize.width: 192
sourceSize.height: 192
}
Text {
anchors.centerIn: parent
visible: UserAccounts.avatarUrl === ""
text: UserAccounts.displayName(root.me).slice(0, 1).toUpperCase()
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: 38
font.weight: Font.DemiBold
}
}
}
Column {
width: parent.width - 116
spacing: 4
anchors.verticalCenter: parent.verticalCenter
Text {
text: UserAccounts.displayName(root.me)
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeTitle
font.weight: Font.DemiBold
}
Text {
text: String(root.me?.userName ?? "") + " · "
+ (root.me?.administrator ? "Administrator" : "Standard account")
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
Item { width: 1; height: 6 }
SettingsButton {
text: "Change picture…"
enabled: !UserAccounts.busy
onClicked: avatarPicker.open()
}
}
}
}
SettingsCard {
title: "Account"
visible: root.me !== null
TextFieldRow {
label: "Full name"
detail: "Shown on the lock screen and in the Control Center"
text: String(root.me?.realName ?? "")
placeholder: "Your name"
enabled: !UserAccounts.busy
onAccepted: value => UserAccounts.setRealName(String(root.me?.userName ?? ""), value)
}
TextRow {
label: "Username"
detail: "Fixed when the account was created, because files and permissions are keyed to it"
value: String(root.me?.userName ?? "")
}
SegmentRow {
label: "Account type"
detail: root.me?.administrator && UserAccounts.administratorCount <= 1
? "This is the only administrator, so it cannot be changed"
: "Administrators can install software and manage other accounts"
options: [
{ value: "standard", label: "Standard" },
{ value: "administrator", label: "Administrator" }
]
value: root.me?.administrator ? "administrator" : "standard"
enabled: !UserAccounts.busy
&& !(root.me?.administrator && UserAccounts.administratorCount <= 1)
onSelected: value => UserAccounts.setAccountType(String(root.me?.userName ?? ""), value)
}
ActionRow {
label: "Password"
detail: root.openPanel === "password"
? "Changing it asks for authorization first"
: "Change the password used to sign in and to unlock the screen"
action: root.openPanel === "password" ? "Cancel" : "Change…"
enabled: !UserAccounts.busy
divider: root.openPanel === "password"
onTriggered: {
if (root.openPanel === "password")
root.closePanels();
else {
root.closePanels();
root.openPanel = "password";
}
}
}
Column {
width: parent.width
visible: root.openPanel === "password"
PasswordRow {
width: parent.width
label: "New password"
detail: "At least six characters"
onChanged: value => root.newPassword = value
}
PasswordRow {
width: parent.width
label: "Confirm"
detail: root.passwordProblem !== ""
? root.passwordProblem
: "Type it a second time"
onChanged: value => root.confirmPassword = value
}
ActionRow {
width: parent.width
label: "Set this password"
detail: "You will be asked to authorize the change"
action: "Set password"
enabled: root.passwordReady && !UserAccounts.busy
divider: false
onTriggered: {
UserAccounts.setPassword(String(root.me?.userName ?? ""), root.newPassword);
root.closePanels();
}
}
}
SwitchRow {
label: "Automatic login"
detail: "Sign in without typing a password. The login keyring stays locked when this is on, so stored passwords are unavailable until something asks for them."
checked: root.me?.automaticLogin === true
enabled: !UserAccounts.busy
divider: false
onToggled: value => UserAccounts.setAutomaticLogin(String(root.me?.userName ?? ""), value)
}
}
// ── Everyone else ────────────────────────────────────────────────────────
SettingsCard {
title: "Other accounts"
subtitle: UserAccounts.others.length === 0
? "Only your account exists on this machine."
: UserAccounts.others.length + " other account"
+ (UserAccounts.others.length === 1 ? "" : "s")
Repeater {
model: UserAccounts.others
delegate: Column {
id: otherBlock
required property var modelData
width: parent.width
readonly property string userName: String(otherBlock.modelData.userName ?? "")
readonly property bool confirming: root.confirmingRemoval === otherBlock.userName
SettingRow {
width: otherBlock.width
label: UserAccounts.displayName(otherBlock.modelData)
detail: otherBlock.userName + " · "
+ (otherBlock.modelData.administrator ? "Administrator" : "Standard account")
controlWidth: 210
divider: false
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
SettingsButton {
text: otherBlock.confirming ? "Keep" : "Remove…"
enabled: !UserAccounts.busy
onClicked: root.confirmingRemoval = otherBlock.confirming
? "" : otherBlock.userName
}
SettingsButton {
visible: otherBlock.confirming
text: "Delete account and files"
tone: "danger"
enabled: !UserAccounts.busy
onClicked: {
root.confirmingRemoval = "";
UserAccounts.deleteUser(otherBlock.userName, true);
}
}
}
}
TextRow {
width: otherBlock.width
visible: otherBlock.confirming
label: "This cannot be undone"
detail: "Their home directory and everything in it is deleted."
value: ""
divider: false
}
Item { width: 1; height: 6 }
}
}
ActionRow {
label: "Add an account"
detail: "Creating an account asks for authorization first"
action: root.openPanel === "newUser" ? "Cancel" : "Add…"
enabled: !UserAccounts.busy
divider: root.openPanel === "newUser"
onTriggered: {
if (root.openPanel === "newUser")
root.closePanels();
else {
root.closePanels();
root.openPanel = "newUser";
}
}
}
Column {
width: parent.width
visible: root.openPanel === "newUser"
TextFieldRow {
width: parent.width
label: "Full name"
placeholder: "Their name"
detail: "Shown on the login screen"
text: root.newRealName
onAccepted: value => root.newRealName = value
}
TextFieldRow {
width: parent.width
label: "Username"
placeholder: "lowercase, no spaces"
detail: "Their home directory is named after this and cannot be changed later"
text: root.newUserName
onAccepted: value => root.newUserName = value
}
SegmentRow {
width: parent.width
label: "Account type"
detail: "Standard accounts cannot install software or manage other accounts"
options: [
{ value: "standard", label: "Standard" },
{ value: "administrator", label: "Administrator" }
]
value: root.newUserIsAdministrator ? "administrator" : "standard"
onSelected: value => root.newUserIsAdministrator = value === "administrator"
}
ActionRow {
width: parent.width
label: "Create the account"
detail: "They set their own password the first time they sign in"
action: "Create"
enabled: !UserAccounts.busy && /^[a-z_][a-z0-9_-]*$/.test(root.newUserName)
divider: false
onTriggered: {
UserAccounts.createUser(root.newUserName, root.newRealName,
root.newUserIsAdministrator ? "administrator" : "standard");
root.closePanels();
}
}
}
}
// Picking a picture goes through the desktop portal, which is the same
// chooser every other application gets and needs no privilege of its own.
AvatarPicker {
id: avatarPicker
onPicked: path => root.pendingPicture = path
}
}
@@ -96,11 +96,19 @@ Item {
source: "file://" + tile.modelData
fillMode: Image.PreserveAspectCrop
asynchronous: true
cache: false
// Decode to roughly the size actually drawn. Without this a
// grid of 12MB photographs decodes at full resolution.
sourceSize.width: 400
sourceSize.height: 240
// Cached, because these are slow: the largest of these files
// takes over two seconds to decode even at this size, and
// without a cache scrolling back up pays that again for
// every tile. What is held is the scaled thumbnail, not the
// original, so the cost of keeping it is small. The tradeoff
// is that a wallpaper replaced in place under the same name
// shows its old thumbnail until the shell restarts -- worth
// it for a directory of files that are added, not edited.
cache: true
// Several of these are 8-12MB originals, so a tile can sit
// empty for a second or two. Fading in on ready makes that
@@ -1,7 +1,15 @@
module qs.modules.settings
AboutPage 1.0 AboutPage.qml
AppearancePage 1.0 AppearancePage.qml
AvatarPicker 1.0 AvatarPicker.qml
ConnectivityPage 1.0 ConnectivityPage.qml
FirewallPage 1.0 FirewallPage.qml
ContainersPage 1.0 ContainersPage.qml
SshKeysPage 1.0 SshKeysPage.qml
AvatarCropper 1.0 AvatarCropper.qml
PickerRow 1.0 PickerRow.qml
SettingsTabs 1.0 SettingsTabs.qml
GamingPage 1.0 GamingPage.qml
HomePhonePage 1.0 HomePhonePage.qml
HomeFavoriteCard 1.0 HomeFavoriteCard.qml
AvailableLightRow 1.0 AvailableLightRow.qml
@@ -9,10 +17,13 @@ DesktopPage 1.0 DesktopPage.qml
DisplaysPage 1.0 DisplaysPage.qml
HomePage 1.0 HomePage.qml
NotificationsPage 1.0 NotificationsPage.qml
PasswordRow 1.0 PasswordRow.qml
PrintersPage 1.0 PrintersPage.qml
ScreenIntelligencePage 1.0 ScreenIntelligencePage.qml
HealthPage 1.0 HealthPage.qml
HealthSummary 1.0 HealthSummary.qml
HealthCheckRow 1.0 HealthCheckRow.qml
SegmentRow 1.0 SegmentRow.qml
SettingRow 1.0 SettingRow.qml
SettingsCard 1.0 SettingsCard.qml
SettingsButton 1.0 SettingsButton.qml
@@ -20,9 +31,13 @@ SettingsShell 1.0 SettingsShell.qml
SettingsSidebar 1.0 SettingsSidebar.qml
SettingsToggle 1.0 SettingsToggle.qml
SettingsWindow 1.0 SettingsWindow.qml
SharingPage 1.0 SharingPage.qml
ShortcutsPage 1.0 ShortcutsPage.qml
SnapshotsPage 1.0 SnapshotsPage.qml
SoundPage 1.0 SoundPage.qml
SettingsPage 1.0 SettingsPage.qml
StoragePage 1.0 StoragePage.qml
SwitchRow 1.0 SwitchRow.qml
ToggleRow 1.0 ToggleRow.qml
SliderRow 1.0 SliderRow.qml
ChoiceRow 1.0 ChoiceRow.qml
@@ -33,6 +48,8 @@ LockScreenPreview 1.0 LockScreenPreview.qml
PowerPage 1.0 PowerPage.qml
DateTimePage 1.0 DateTimePage.qml
AccessibilityPage 1.0 AccessibilityPage.qml
UpdatesPage 1.0 UpdatesPage.qml
UsersPage 1.0 UsersPage.qml
WallpaperPicker 1.0 WallpaperPicker.qml
WallpaperControls 1.0 WallpaperControls.qml
ApplicationsPage 1.0 ApplicationsPage.qml
@@ -62,3 +79,4 @@ RegionPage 1.0 RegionPage.qml
SearchPicker 1.0 SearchPicker.qml
OnlineAccountsPage 1.0 OnlineAccountsPage.qml
AccentPicker 1.0 AccentPicker.qml
TextFieldRow 1.0 TextFieldRow.qml
@@ -22,7 +22,11 @@ PanelWindow {
screen: root.modelData
anchors.top: true
margins.top: Theme.barHeight + Theme.barGap * 2
// The gap ALONE, not the bar height plus the gap. exclusiveZone 0 means
// "reserve nothing, but respect what others reserved", so this surface
// already begins below the bar's zone -- adding the bar height here counted
// it twice and left the surface floating 48px under the bar instead of 12.
margins.top: Theme.barGap * 2
exclusiveZone: 0
implicitWidth: root.showingEvent ? 388 : 438
implicitHeight: root.showingEvent ? 76 : 132
+14 -2
View File
@@ -121,7 +121,19 @@ emit "Memory" "$(awk '/^MemTotal:/ { printf "%.1f GiB", $2 / 1048576 }' /proc/me
swap="$(awk '/^SwapTotal:/ { if ($2 > 0) printf "%.1f GiB", $2 / 1048576 }' /proc/meminfo 2>/dev/null)"
emit "Swap" "$swap"
read -r size used avail <<<"$(df -h --output=size,used,avail / 2>/dev/null | tail -1)"
[[ -n "${size:-}" ]] && emit "Disk" "$avail free of $size"
# Decimal units with explicit GB/TB labels, matching the Storage page and the
# way drives are actually sold. `df -h` is binary but prints a bare "G", so the
# same drive read 488G here and 523 GB there.
read -r size avail <<<"$(df -B1 --output=size,avail / 2>/dev/null | tail -1)"
if [[ -n "${size:-}" ]]; then
emit "Disk" "$(awk -v a="$avail" -v s="$size" '
function human(v, units, i) {
split("B KB MB GB TB PB", units, " ")
i = 1
while (v >= 1000 && i < 6) { v /= 1000; i++ }
return sprintf("%.*f %s", (v < 10 && i > 2) ? 1 : 0, v, units[i])
}
BEGIN { printf "%s free of %s", human(a), human(s) }')"
fi
printf '[%s]\n' "$(IFS=,; printf '%s' "${facts[*]}")"
@@ -68,6 +68,11 @@ def describe(obj):
"path": obj.get_object_path(),
"provider": account.props.provider_type,
"providerName": account.props.provider_name,
# GOA hands back a serialised GThemedIcon: ". GThemedIcon name1 name2 …",
# a preference-ordered fallback chain. Passed on as that list rather than
# resolved here, because which of those names exists is a property of the
# icon theme in use, which this has no business deciding.
"providerIcons": themed_icon_names(account.props.provider_icon),
# PresentationIdentity is the human one (an email address); Identity is
# the internal handle and is not always readable.
"identity": account.props.presentation_identity or account.props.identity,
@@ -79,6 +84,26 @@ def describe(obj):
}
def themed_icon_names(icon) -> list[str]:
"""The icon names out of a serialised GThemedIcon, best first.
The string form is ". GThemedIcon mail-unread-symbolic mail-symbolic mail",
where the leading "." and the type name are structure rather than content.
Anything that is not that shape yields nothing, so a caller gets an empty
list rather than a name that will never resolve.
"""
if icon is None:
return []
try:
text = icon.to_string()
except Exception:
text = str(icon)
parts = str(text or "").split()
if len(parts) < 3 or parts[0] != "." or parts[1] != "GThemedIcon":
return []
return [name for name in parts[2:] if name]
def find(client, path):
for obj in client.get_accounts():
if obj.get_object_path() == path:
+14 -1
View File
@@ -109,6 +109,19 @@ case "$action" in
clipboard) qs ipc call clipboard open ;;
overview) qs ipc call overview open ;;
settings) qs ipc call settings open ;;
# Jump straight to one settings page. The launcher's per-page commands are
# the only caller, and they are generated from the page list itself, so the
# pattern below is a boundary check rather than a whitelist: an unknown page
# would leave the settings window showing nothing at all.
settings-page)
page="${2:-}"
if [[ ! "$page" =~ ^[a-z][a-z-]*$ ]]; then
printf 'Usage: panama-action settings-page PAGE\n' >&2
exit 2
fi
qs ipc call settings page "$page"
;;
health) qs ipc call health open ;;
dnd)
@@ -149,7 +162,7 @@ case "$action" in
;;
*)
printf 'Usage: panama-action {%s}\n' \
'control-center|notifications|calendar|clipboard|overview|settings|health|dnd|caffeine|night-light|focus-start|focus-end|capture|intelligence|screenshot|microphone|gallery|restart-shell' >&2
'control-center|notifications|calendar|clipboard|overview|settings|settings-page PAGE|health|dnd|caffeine|night-light|focus-start|focus-end|capture|intelligence|screenshot|microphone|gallery|restart-shell' >&2
exit 2
;;
esac
+566
View File
@@ -0,0 +1,566 @@
#!/usr/bin/env python3
"""Rootless containers, grouped by the project that defines them.
Every container on this machine is created by podman-compose and labelled with
the project it belongs to, so the grouping is read from the labels rather than
invented. Acting on a group is then done with plain `podman` over the labelled
set -- never `podman-compose down`, which would REMOVE the containers. Nothing
here creates, recreates, or removes a container: the compose file is the source
of truth for what exists, and it belongs to the repository, not to this tool.
Rootless throughout, so nothing here needs privilege.
The one exception to "does not touch the compose file" is `bind-local`, which
exists because a development database published on every interface is worth
closing and the fix is a single token. It prepends a loopback bind address and
leaves the rest of the line byte-for-byte -- variables, quoting and style
intact -- then re-parses to confirm only that value moved. Anything it cannot
read unambiguously it refuses rather than guesses.
panama-containers snapshot
panama-containers start NAME | stop NAME | restart NAME
panama-containers project-start NAME | project-stop NAME | project-restart NAME
panama-containers prune-images | prune-volumes
panama-containers bind-local PROJECT SERVICE
"""
from __future__ import annotations
import json
import os
import re
import subprocess
import sys
from pathlib import Path
import yaml
# A container or project name as podman and compose accept them. Deliberately
# strict: these values reach an argv, and nothing legitimate needs more.
NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$")
# Project names that say nothing, because podman-compose defaults to the
# directory holding the compose file. "docker" is a directory, not a project.
ANONYMOUS = {"docker", "compose", "containers", "container", "db", "dev", "local", "src"}
# Substituted before a port mapping is split, because ${POSTGRES_PORT:-5432}
# contains a colon and would otherwise be torn in half.
INTERPOLATION = re.compile(r"\$\{[^}]*\}")
# Host addresses that mean "every interface", and so mean reachable.
EVERY_INTERFACE = {"", "0.0.0.0", "::", "[::]", "*"}
LOOPBACK = "127.0.0.1"
class BoundaryError(RuntimeError):
"""A user-visible validation or podman failure."""
def podman_binary() -> str:
"""The podman to call.
Overridable so that stopping a container and removing an image can be
tested without stopping a real container or removing a real image. The
containers on this machine are a working development database; a test suite
has no business touching them.
"""
return os.environ.get("PANAMA_CONTAINERS_PODMAN", "podman")
def run(command: list[str], timeout: float = 60.0) -> subprocess.CompletedProcess:
try:
return subprocess.run(command, capture_output=True, text=True, timeout=timeout)
except FileNotFoundError as error:
raise BoundaryError("podman is not installed.") from error
except subprocess.TimeoutExpired as error:
raise BoundaryError("podman did not respond.") from error
def podman_json(arguments: list[str], timeout: float = 60.0) -> list | dict:
result = run([podman_binary(), *arguments], timeout=timeout)
if result.returncode != 0:
detail = (result.stderr or "").strip().splitlines()
raise BoundaryError(detail[-1] if detail else "podman could not be read.")
try:
return json.loads(result.stdout or "[]")
except json.JSONDecodeError as error:
raise BoundaryError("podman returned something unreadable.") from error
def podman_do(arguments: list[str], failure: str, timeout: float = 120.0) -> None:
result = run([podman_binary(), *arguments], timeout=timeout)
if result.returncode != 0:
detail = (result.stderr or "").strip().splitlines()
raise BoundaryError(detail[-1] if detail else failure)
def require(pattern: re.Pattern[str], value: str, message: str) -> str:
if not pattern.match(value or ""):
raise BoundaryError(message)
return value
# ── reading ──────────────────────────────────────────────────────────────────
def split_mapping(mapping: str) -> list[str]:
"""The parts of a compose port mapping, with interpolations kept whole."""
masked: list[str] = []
placeholder = "\x00{}\x00"
def keep(match: re.Match[str]) -> str:
masked.append(match.group(0))
return placeholder.format(len(masked) - 1)
stand_in = INTERPOLATION.sub(keep, mapping)
return [
re.sub(r"\x00(\d+)\x00", lambda m: masked[int(m.group(1))], part)
for part in stand_in.split(":")
]
def health_of(status: str) -> str:
"""The health podman prints inside the status line, when it prints one."""
match = re.search(r"\((healthy|unhealthy|starting)\)", status or "")
return match.group(1) if match else ""
def containers() -> list[dict]:
raw = podman_json(["ps", "-a", "--format", "json"])
result: list[dict] = []
for entry in raw if isinstance(raw, list) else []:
labels = entry.get("Labels") or {}
names = entry.get("Names") or []
status = str(entry.get("Status") or "")
ports = [
{
"hostIp": str(port.get("host_ip") or ""),
"hostPort": int(port.get("host_port") or 0),
"containerPort": int(port.get("container_port") or 0),
"protocol": str(port.get("protocol") or "tcp"),
"range": int(port.get("range") or 1),
}
for port in (entry.get("Ports") or [])
]
result.append({
"id": str(entry.get("Id") or "")[:12],
"name": names[0] if names else str(entry.get("Id") or "")[:12],
"image": str(entry.get("Image") or ""),
"state": str(entry.get("State") or ""),
"status": status,
"health": health_of(status),
"exitCode": int(entry.get("ExitCode") or 0),
"startedAt": int(entry.get("StartedAt") or 0),
"restarts": int(entry.get("Restarts") or 0),
"project": str(labels.get("com.docker.compose.project") or ""),
"service": str(labels.get("com.docker.compose.service") or ""),
"configFile": str(labels.get("com.docker.compose.project.config_files") or ""),
"workingDir": str(labels.get("com.docker.compose.project.working_dir") or ""),
# Published ports whose host address is every interface.
"ports": ports,
})
return result
def resolve_config(path: str, working_dir: str) -> str:
"""The compose file a label points at, normalised.
podman-compose records the path as it was invoked, which is how a perfectly
valid label ends up containing "/scripts/../compose.yml".
"""
if not path:
return ""
first = path.split(",")[0].strip()
if not first:
return ""
candidate = Path(first)
if not candidate.is_absolute() and working_dir:
candidate = Path(working_dir) / candidate
try:
return str(candidate.resolve(strict=False))
except OSError:
return str(candidate)
def repository_name(config_file: str) -> str:
"""The repository a compose file lives in, for naming a project sensibly."""
if not config_file:
return ""
result = run(
["git", "-C", str(Path(config_file).parent), "rev-parse", "--show-toplevel"],
timeout=10.0,
)
if result.returncode != 0:
return ""
return Path(result.stdout.strip()).name if result.stdout.strip() else ""
def display_name(project: str, config_file: str) -> str:
"""A project name worth showing.
podman-compose names a project after the directory holding its compose
file, so a database stack can end up called "docker". Where the name says
nothing, the repository it lives in says more.
"""
if project.lower() not in ANONYMOUS:
return project
return repository_name(config_file) or project
def projects_of(entries: list[dict]) -> list[dict]:
"""Containers grouped by the compose project that declares them."""
grouped: dict[str, dict] = {}
for container in entries:
key = container["project"]
if not key:
continue
group = grouped.get(key)
if group is None:
config_file = resolve_config(container["configFile"], container["workingDir"])
group = grouped[key] = {
"name": key,
"title": display_name(key, config_file),
"configFile": config_file,
"workingDir": container["workingDir"],
"containers": [],
}
group["containers"].append(container)
result = []
for group in grouped.values():
group["containers"].sort(key=lambda c: (c["state"] != "running", c["name"]))
group["running"] = sum(1 for c in group["containers"] if c["state"] == "running")
group["total"] = len(group["containers"])
result.append(group)
# Whatever is running comes first; a project nobody is using can wait.
result.sort(key=lambda g: (-g["running"], g["title"].lower()))
return result
def exposures(entries: list[dict]) -> list[dict]:
"""Published ports any machine on the network can reach.
A container is only reachable if it is running AND publishes on an address
that is not loopback. A stopped container publishes nothing, whatever its
compose file says -- so it is reported as something that WILL expose, not
something that does.
"""
found: list[dict] = []
for container in entries:
for port in container["ports"]:
if port["hostIp"] not in EVERY_INTERFACE:
continue
if port["hostPort"] <= 0:
continue
found.append({
"container": container["name"],
"project": container["project"],
"service": container["service"],
"configFile": resolve_config(container["configFile"], container["workingDir"]),
"image": container["image"],
"hostPort": port["hostPort"],
"containerPort": port["containerPort"],
"protocol": port["protocol"],
"running": container["state"] == "running",
})
found.sort(key=lambda e: (not e["running"], e["hostPort"]))
return found
def disk() -> dict:
"""What the container store costs, and what of it nothing references."""
usage = podman_json(["system", "df", "--format", "json"])
totals = {
str(row.get("Type") or ""): row
for row in (usage if isinstance(usage, list) else [])
}
def raw(kind: str, field: str) -> int:
return int((totals.get(kind) or {}).get(field) or 0)
images = podman_json(["images", "--format", "json"])
unused = []
for image in images if isinstance(images, list) else []:
if int(image.get("Containers") or 0) > 0:
continue
tags = image.get("Names") or image.get("RepoTags") or []
unused.append({
"id": str(image.get("Id") or "")[:12],
"name": tags[0] if tags else "<untagged>",
"size": int(image.get("Size") or 0),
})
unused.sort(key=lambda i: -i["size"])
# Podman's own answer to "does anything reference this volume", rather
# than MountCount, which is a runtime lock counter: it reads zero for a
# volume that a running container has mounted this second, and using it
# here would offer to delete a live database.
volumes = podman_json(["volume", "ls", "--filter", "dangling=true", "--format", "json"])
idle = [
{"name": str(volume.get("Name") or "")}
for volume in (volumes if isinstance(volumes, list) else [])
if volume.get("Name")
]
idle.sort(key=lambda v: v["name"])
return {
"imagesSize": raw("Images", "RawSize"),
"imagesReclaimable": raw("Images", "RawReclaimable"),
"containersSize": raw("Containers", "RawSize"),
"volumesSize": raw("Local Volumes", "RawSize"),
"volumesReclaimable": raw("Local Volumes", "RawReclaimable"),
"unusedImages": unused,
"unusedVolumes": idle,
}
def snapshot() -> dict:
entries = containers()
grouped = projects_of(entries)
return {
"available": True,
"projects": grouped,
"loose": [c for c in entries if not c["project"]],
"running": sum(1 for c in entries if c["state"] == "running"),
"total": len(entries),
"exposed": exposures(entries),
"disk": disk(),
"error": "",
}
def unavailable(message: str) -> dict:
return {
"available": False, "projects": [], "loose": [], "running": 0, "total": 0,
"exposed": [], "disk": {
"imagesSize": 0, "imagesReclaimable": 0, "containersSize": 0,
"volumesSize": 0, "volumesReclaimable": 0,
"unusedImages": [], "unusedVolumes": [],
},
"error": message,
}
# ── acting ───────────────────────────────────────────────────────────────────
def find_container(name: str) -> dict:
for container in containers():
if container["name"] == name or container["id"] == name:
return container
raise BoundaryError(f"There is no container called {name}.")
def find_project(name: str) -> dict:
for project in projects_of(containers()):
if project["name"] == name:
return project
raise BoundaryError(f"There is no project called {name}.")
def act_on_container(verb: str, name: str) -> None:
container = find_container(name)
if verb == "start" and container["state"] == "running":
raise BoundaryError(f"{container['name']} is already running.")
if verb == "stop" and container["state"] != "running":
raise BoundaryError(f"{container['name']} is not running.")
podman_do([verb, container["name"]], f"{container['name']} could not be {verb}ed.")
def act_on_project(verb: str, name: str) -> None:
"""The whole stack, one container at a time, with plain podman.
Deliberately not `podman-compose down`: that removes containers, and this
tool does not remove what the compose file created.
"""
project = find_project(name)
wanted = "running" if verb == "stop" else "not running"
targets = [
container["name"] for container in project["containers"]
if (container["state"] == "running") == (wanted == "running")
] if verb != "restart" else [
container["name"] for container in project["containers"]
if container["state"] == "running"
]
if not targets:
raise BoundaryError(f"Nothing in {project['title']} needs to be {verb}ed.")
failures: list[str] = []
for target in targets:
result = run([podman_binary(), verb, target], timeout=120.0)
if result.returncode != 0:
failures.append(target)
if failures:
raise BoundaryError(f"Could not {verb} {', '.join(failures)}.")
def prune_images() -> None:
"""Remove images nothing references.
Scoped to exactly what the snapshot showed as unused, by id, so that an
image which gained a container between the panel rendering and the button
being pressed is not swept up by a blanket prune.
"""
unused = disk()["unusedImages"]
if not unused:
raise BoundaryError("Every image is in use.")
failures = []
for image in unused:
result = run([podman_binary(), "rmi", image["id"]], timeout=120.0)
if result.returncode != 0:
failures.append(image["name"])
if failures:
raise BoundaryError(f"Could not remove {len(failures)} image(s): {', '.join(failures[:3])}.")
def prune_volumes() -> None:
idle = disk()["unusedVolumes"]
if not idle:
raise BoundaryError("Every volume is in use.")
failures = []
for volume in idle:
result = run([podman_binary(), "volume", "rm", volume["name"]], timeout=120.0)
if result.returncode != 0:
failures.append(volume["name"])
if failures:
raise BoundaryError(f"Could not remove {len(failures)} volume(s): {', '.join(failures[:3])}.")
# ── the compose edit ─────────────────────────────────────────────────────────
def rewrite_mapping(mapping: str) -> str:
"""A published port bound to loopback, with everything else left alone.
Only the address is added. The host port keeps whatever form it had --
literal, ${VAR}, or ${VAR:-default} -- because rewriting it to the number
podman happens to report today would silently delete the variable that lets
the port be configured at all.
"""
parts = split_mapping(mapping.strip())
if len(parts) == 3:
raise BoundaryError(f"{mapping} already names an address.")
if len(parts) != 2:
raise BoundaryError(f"{mapping} is not a mapping this can read.")
return f"{LOOPBACK}:{parts[0]}:{parts[1]}"
def compose_ports(document: dict, service: str) -> list[str]:
services = document.get("services")
if not isinstance(services, dict) or service not in services:
raise BoundaryError(f"{service} is not in that compose file.")
definition = services.get(service)
if not isinstance(definition, dict):
raise BoundaryError(f"{service} is not readable in that compose file.")
ports = definition.get("ports")
if ports is None:
raise BoundaryError(f"{service} does not publish any ports.")
if not isinstance(ports, list) or not all(isinstance(p, str) for p in ports):
raise BoundaryError(f"The ports of {service} are not in a form this can edit.")
return ports
def bind_local(project_name: str, service: str) -> None:
"""Bind a service's published ports to loopback, in place.
Read to understand, edit as text so the file keeps its comments, quoting
and layout, then read back to confirm that exactly the intended values
moved and nothing else did.
"""
project = find_project(project_name)
path = Path(project["configFile"])
if not project["configFile"] or not path.is_file():
raise BoundaryError("The compose file for that project could not be found.")
original = path.read_text(encoding="utf-8")
try:
document = yaml.safe_load(original)
except yaml.YAMLError as error:
raise BoundaryError("That compose file could not be parsed.") from error
if not isinstance(document, dict):
raise BoundaryError("That compose file is not a mapping.")
current = compose_ports(document, service)
wanted = [rewrite_mapping(mapping) for mapping in current]
# Replace each mapping where it is written, not the line it sits on, so
# flow style, block style and inline comments all survive untouched. The
# mapping text is searched for within the service's own span only.
updated = original
for before, after in zip(current, wanted):
needle = before.strip()
occurrences = updated.count(needle)
if occurrences == 0:
raise BoundaryError(f"Could not find {needle} in the compose file.")
if occurrences > 1:
raise BoundaryError(
f"{needle} appears {occurrences} times in that file; "
"it is not clear which one belongs to this service.")
updated = updated.replace(needle, after)
if updated == original:
raise BoundaryError("That compose file already binds these ports to loopback.")
path.write_text(updated, encoding="utf-8")
# Read back. A change that was not applied, or that broke the document, is
# worse than no change at all.
try:
reread = yaml.safe_load(path.read_text(encoding="utf-8"))
except yaml.YAMLError as error:
path.write_text(original, encoding="utf-8")
raise BoundaryError("The edit would have broken that compose file; it was undone.") from error
if not isinstance(reread, dict) or compose_ports(reread, service) != wanted:
path.write_text(original, encoding="utf-8")
raise BoundaryError("The edit did not take effect; it was undone.")
# Everything except this service's ports must be identical.
before_document = yaml.safe_load(original)
before_document["services"][service]["ports"] = wanted
if before_document != reread:
path.write_text(original, encoding="utf-8")
raise BoundaryError("The edit changed more than those ports; it was undone.")
def main(arguments: list[str]) -> int:
try:
if arguments == ["snapshot"]:
print(json.dumps(snapshot(), separators=(",", ":")))
return 0
if len(arguments) == 2 and arguments[0] in ("start", "stop", "restart"):
act_on_container(arguments[0], require(NAME, arguments[1], "That is not a container name."))
elif len(arguments) == 2 and arguments[0] in ("project-start", "project-stop", "project-restart"):
verb = arguments[0].split("-", 1)[1]
act_on_project(verb, require(NAME, arguments[1], "That is not a project name."))
elif arguments == ["prune-images"]:
prune_images()
elif arguments == ["prune-volumes"]:
prune_volumes()
elif len(arguments) == 3 and arguments[0] == "bind-local":
bind_local(
require(NAME, arguments[1], "That is not a project name."),
require(NAME, arguments[2], "That is not a service name."),
)
else:
raise BoundaryError(
"Usage: panama-containers snapshot | start NAME | stop NAME | restart NAME | "
"project-start NAME | project-stop NAME | project-restart NAME | "
"prune-images | prune-volumes | bind-local PROJECT SERVICE")
except BoundaryError as error:
try:
state = snapshot()
except BoundaryError:
state = unavailable("")
state["error"] = str(error)
print(json.dumps(state, separators=(",", ":")))
return 0
print(json.dumps(snapshot(), separators=(",", ":")))
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
+158 -16
View File
@@ -14,14 +14,46 @@ import sys
import tempfile
# A role owns a FAMILY of types, not one representative.
#
# Each role used to carry a single mime type, so setting "images" changed
# image/png and left image/jpeg wherever it happened to land. That is exactly
# how this machine ended up opening PNGs in a pixel-art editor, MP3s in a video
# transcoder and PDFs in GIMP: nobody chose any of it, the applications
# registered themselves and the roles only ever governed one type each.
#
# The FIRST entry in each list is the one queried when reporting the current
# handler; all of them are written when the role is set, so a family cannot
# drift apart again.
ROLE_TARGETS = {
"browser": ("settings", "default-web-browser"),
"mail": ("mime", "x-scheme-handler/mailto"),
"files": ("mime", "inode/directory"),
"terminal": ("mime", "x-scheme-handler/terminal"),
"music": ("mime", "audio/mpeg"),
"images": ("mime", "image/png"),
"video": ("mime", "video/mp4"),
"browser": ("settings", ["default-web-browser"]),
"mail": ("mime", ["x-scheme-handler/mailto"]),
"files": ("mime", ["inode/directory"]),
"terminal": ("mime", ["x-scheme-handler/terminal"]),
"music": ("mime", [
"audio/mpeg", "audio/flac", "audio/x-vorbis+ogg", "audio/ogg",
"audio/x-wav", "audio/mp4", "audio/aac", "audio/opus",
]),
"images": ("mime", [
"image/png", "image/jpeg", "image/gif", "image/webp",
"image/tiff", "image/bmp", "image/svg+xml", "image/avif",
]),
"video": ("mime", [
"video/mp4", "video/x-matroska", "video/webm", "video/quicktime",
"video/x-msvideo", "video/mpeg",
]),
"documents": ("mime", [
"application/pdf", "application/epub+zip",
]),
"text": ("mime", [
"text/plain", "text/markdown", "text/x-python", "text/x-csrc",
"text/x-chdr", "text/x-c++src", "text/x-shellscript",
"application/json", "application/x-yaml", "text/xml",
]),
"archives": ("mime", [
"application/zip", "application/x-tar", "application/gzip",
"application/x-7z-compressed", "application/vnd.rar",
]),
}
DESKTOP_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+-]*\.desktop$")
EXEC_CMD = re.compile(r"hl\.exec_cmd\(\s*(\"(?:\\.|[^\"\\])*\")\s*\)")
@@ -70,7 +102,9 @@ def run(command: list[str]) -> str:
def query_handlers() -> dict[str, str]:
handlers: dict[str, str] = {}
for role, (kind, target) in ROLE_TARGETS.items():
for role, (kind, targets) in ROLE_TARGETS.items():
# The first type represents the family when reporting.
target = targets[0]
command = (
["xdg-settings", "get", target]
if kind == "settings"
@@ -179,13 +213,85 @@ def set_default(role: str, desktop_id: str) -> None:
if target is None:
raise BoundaryError("That default application role is not supported.")
require_desktop_id(desktop_id, discovered=discovered_desktop_ids())
kind, setting = target
command = (
["xdg-settings", "set", setting, desktop_id]
if kind == "settings"
else ["xdg-mime", "default", desktop_id, setting]
)
run(command)
kind, settings = target
if kind == "settings":
run(["xdg-settings", "set", settings[0], desktop_id])
return
# Every type in the family, so a role cannot be half-applied. xdg-mime
# accepts several types in one call, but they are written individually so a
# type this system does not know about cannot fail the whole role.
for setting in settings:
run(["xdg-mime", "default", desktop_id, setting])
# What this desktop opens a file with, when nobody has said otherwise.
#
# Applications register themselves for every type they can technically read, so
# an unattended machine decides these by installation order: a pixel-art editor
# claims PNG, a video transcoder claims MP3, an image editor claims PDF. None of
# that is a choice anyone made, and it is only discovered by double-clicking.
#
# Each role lists candidates best-first; the first one installed wins. A role
# with no candidate installed is left alone rather than forced.
PREFERRED_HANDLERS = {
"images": ["org.gnome.Loupe.desktop", "org.gnome.eog.desktop"],
"music": ["org.gnome.Decibels.desktop", "io.bassi.Amberol.desktop", "io.mpv.Mpv.desktop"],
"video": ["io.mpv.Mpv.desktop", "mpv.desktop", "org.gnome.Totem.desktop"],
"documents": ["org.gnome.Papers.desktop", "org.gnome.Evince.desktop"],
"text": ["panama-nvim.desktop"],
"archives": ["org.gnome.Nautilus.desktop", "org.gnome.FileRoller.desktop"],
}
def user_mimeapps() -> Path:
config_home = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
return config_home / "mimeapps.list"
def chosen_types() -> set[str]:
"""Types the person using this machine has already assigned by hand.
A type listed under [Default Applications] in the user's own mimeapps.list
got there because someone picked "Open With" and made it stick, or because
the settings page wrote it. Seeding must never overrule that.
"""
path = user_mimeapps()
if not path.is_file():
return set()
chosen: set[str] = set()
section = ""
try:
lines = path.read_text(encoding="utf-8").splitlines()
except (OSError, UnicodeError):
return set()
for line in lines:
stripped = line.strip()
if stripped.startswith("[") and stripped.endswith("]"):
section = stripped[1:-1]
continue
if section != "Default Applications" or "=" not in line or stripped.startswith("#"):
continue
chosen.add(line.split("=", 1)[0].strip())
return chosen
def seed() -> None:
"""Apply Panama's curated defaults to roles nobody has chosen for."""
discovered = discovered_desktop_ids()
already = chosen_types()
for role, candidates in PREFERRED_HANDLERS.items():
kind, targets = ROLE_TARGETS[role]
if kind != "mime":
continue
if any(target in already for target in targets):
print(f"{role}: keeping the existing choice")
continue
preferred = next((entry for entry in candidates if entry in discovered), None)
if preferred is None:
print(f"{role}: no preferred application installed, leaving it alone")
continue
set_default(role, preferred)
print(f"{role}: {preferred}")
def with_hidden(original: str, *, hidden: bool) -> str:
@@ -246,6 +352,38 @@ def update_hidden(path: Path, *, hidden: bool) -> None:
write_atomic(path, with_hidden(original, hidden=hidden), mode=mode)
def remove_autostart(desktop_id: str) -> None:
"""Delete a user autostart entry.
Disabling writes Hidden=true and is reversible; this is not, so it is
confined to files this directory owns. A symlink is refused rather than
followed, because deleting through one would remove whatever it points at --
which is somewhere else entirely, and not ours.
"""
if not DESKTOP_ID.fullmatch(desktop_id):
raise BoundaryError("That is not an autostart entry name.")
directory = autostart_directory()
target = directory / desktop_id
# Resolved and compared, so a name like "../../.bashrc" cannot escape.
try:
resolved = target.resolve(strict=True)
except OSError as error:
raise BoundaryError("That autostart entry no longer exists.") from error
if resolved.parent != directory.resolve(strict=False):
raise BoundaryError("That autostart entry is not in the autostart directory.")
if target.is_symlink() or not target.is_file():
raise BoundaryError("That autostart entry is not a file this can remove.")
if target.suffix != ".desktop":
raise BoundaryError("That autostart entry is not a desktop file.")
try:
target.unlink()
except OSError as error:
raise BoundaryError("That autostart entry could not be removed.") from error
def add_autostart(desktop_id: str) -> None:
desktop_files = discovered_desktop_files()
require_desktop_id(desktop_id, discovered=set(desktop_files))
@@ -294,15 +432,19 @@ def main(arguments: list[str]) -> int:
try:
if arguments == ["snapshot"]:
print(json.dumps(snapshot(), separators=(",", ":")))
elif arguments == ["seed"]:
seed()
elif len(arguments) == 3 and arguments[0] == "set-default":
set_default(arguments[1], arguments[2])
elif len(arguments) == 3 and arguments[0] == "set-autostart":
set_autostart(arguments[1], arguments[2])
elif len(arguments) == 2 and arguments[0] == "remove-autostart":
remove_autostart(arguments[1])
elif len(arguments) == 2 and arguments[0] == "add-autostart":
add_autostart(arguments[1])
else:
raise BoundaryError(
"Usage: panama-default-apps snapshot | set-default ROLE DESKTOP_ID | "
"Usage: panama-default-apps snapshot | seed | set-default ROLE DESKTOP_ID | "
"set-autostart DESKTOP_ID true|false | add-autostart DESKTOP_ID"
)
except BoundaryError as error:
+372
View File
@@ -0,0 +1,372 @@
#!/usr/bin/env python3
"""Read storage layout, usage, and drive health for Panama's Storage page.
Two boundaries, deliberately separate:
snapshot topology, usage, health, removable media. Cheap -- lsblk and a
single udisks call -- so the page can open with it.
scan what is actually filling the drive. Expensive: measuring a folder
means walking it, and this machine has a 1.2 TiB Steam library.
The page asks for this on demand and remembers the answer.
unmount PATH / eject PATH removable media only, by explicit request.
Deliberately absent: partitioning and formatting. A settings pane is the wrong
place to hand someone a way to erase a disk in two clicks; GNOME Disks is one
button away on the page for that.
"""
from __future__ import annotations
import json
import os
from pathlib import Path
import re
import shutil
import subprocess
import sys
import time
# Bounded so a pathological tree cannot hang the page. A folder that cannot be
# measured in this long is reported as unmeasured rather than silently omitted,
# because a missing row reads as "this folder is small".
SCAN_TIMEOUT_SECONDS = 90
# Where "what is filling my drive" actually gets answered. Ordered so the most
# likely culprits are measured first; the walk stops when the budget runs out.
SCAN_TARGETS = [
("Steam library", "~/.local/share/Steam"),
("Documents", "~/Documents"),
("Downloads", "~/Downloads"),
("Videos", "~/Videos"),
("Pictures", "~/Pictures"),
("Music", "~/Music"),
("Flatpak applications", "~/.var/app"),
("Caches", "~/.cache"),
("Trash", "~/.local/share/Trash"),
]
class BoundaryError(RuntimeError):
"""A user-visible validation or command failure."""
def run(command: list[str], timeout: float = 15.0) -> str:
try:
completed = subprocess.run(command, check=False, capture_output=True,
text=True, timeout=timeout)
except (OSError, subprocess.TimeoutExpired) as error:
raise BoundaryError(f"{command[0]} did not answer") from error
if completed.returncode != 0:
raise BoundaryError(completed.stderr.strip() or f"{command[0]} failed")
return completed.stdout
def lsblk() -> list[dict]:
"""The block device tree.
PANAMA_DISKS_LSBLK substitutes a recorded tree for the real one. It exists
so the refusal rules below can be tested against a removable drive that is
not plugged in -- the alternative is a test that runs unmount against
whatever is actually mounted, which is not a test anyone should write.
"""
fixture = os.environ.get("PANAMA_DISKS_LSBLK")
if fixture:
try:
return json.loads(Path(fixture).read_text(encoding="utf-8"))["blockdevices"]
except (OSError, json.JSONDecodeError, KeyError) as error:
raise BoundaryError("could not read the recorded block device layout") from error
fields = ("NAME,PATH,SIZE,TYPE,FSTYPE,MOUNTPOINTS,MODEL,SERIAL,RM,HOTPLUG,"
"ROTA,FSSIZE,FSUSED,FSAVAIL")
try:
return json.loads(run(["lsblk", "-J", "-b", "-o", fields]))["blockdevices"]
except (json.JSONDecodeError, KeyError) as error:
raise BoundaryError("could not read the block device layout") from error
def udisks_objects() -> dict:
"""Every udisks object in one call, or nothing if udisks is not running.
Health is a nice-to-have: a machine without udisks still gets its layout and
usage, which is most of the page. Failing the whole snapshot because a
temperature is unavailable would be the wrong trade.
"""
if not shutil.which("busctl"):
return {}
try:
raw = run(["busctl", "--system", "--json=short", "call",
"org.freedesktop.UDisks2", "/org/freedesktop/UDisks2",
"org.freedesktop.DBus.ObjectManager", "GetManagedObjects"])
payload = json.loads(raw)
except (BoundaryError, json.JSONDecodeError):
return {}
# busctl wraps the reply as {"type": "...", "data": [ {path: {iface: {prop: {"type","data"}}}} ]}
data = payload.get("data") or []
return data[0] if data else {}
def unwrap(value):
"""busctl encodes every value as {"type": t, "data": v}."""
if isinstance(value, dict) and "data" in value and "type" in value:
return value["data"]
return value
def drive_health(objects: dict) -> dict[str, dict]:
"""Health keyed by drive serial, which is the only stable join to lsblk."""
health: dict[str, dict] = {}
for path, interfaces in objects.items():
if "/drives/" not in path:
continue
drive = {key: unwrap(value)
for key, value in (interfaces.get("org.freedesktop.UDisks2.Drive") or {}).items()}
serial = str(drive.get("Serial") or "")
if not serial:
continue
entry = {
"ejectable": bool(drive.get("Ejectable")),
"removable": bool(drive.get("Removable")),
"temperatureC": None,
"powerOnHours": None,
"healthy": None,
"warnings": [],
"selfTest": "",
}
nvme = {key: unwrap(value) for key, value in
(interfaces.get("org.freedesktop.UDisks2.NVMe.Controller") or {}).items()}
ata = {key: unwrap(value) for key, value in
(interfaces.get("org.freedesktop.UDisks2.Drive.Ata") or {}).items()}
if nvme:
kelvin = nvme.get("SmartTemperature")
if isinstance(kelvin, (int, float)) and kelvin > 0:
entry["temperatureC"] = round(kelvin - 273.15, 1)
hours = nvme.get("SmartPowerOnHours")
if isinstance(hours, (int, float)):
entry["powerOnHours"] = int(hours)
warnings = nvme.get("SmartCriticalWarning") or []
entry["warnings"] = [str(item) for item in warnings]
entry["healthy"] = not entry["warnings"]
entry["selfTest"] = str(nvme.get("SmartSelftestStatus") or "")
elif ata:
kelvin = ata.get("SmartTemperature")
if isinstance(kelvin, (int, float)) and kelvin > 0:
entry["temperatureC"] = round(kelvin - 273.15, 1)
seconds = ata.get("SmartPowerOnSeconds")
if isinstance(seconds, (int, float)) and seconds > 0:
entry["powerOnHours"] = int(seconds // 3600)
failing = ata.get("SmartFailing")
if isinstance(failing, bool):
entry["healthy"] = not failing
entry["warnings"] = ["failing"] if failing else []
entry["selfTest"] = str(ata.get("SmartSelftestStatus") or "")
health[serial] = entry
return health
def walk(node: dict, depth: int = 0):
yield node, depth
for child in node.get("children") or []:
yield from walk(child, depth + 1)
def mountpoints(node: dict) -> list[str]:
return [point for point in (node.get("mountpoints") or []) if point and point != "[SWAP]"]
def snapshot() -> dict:
tree = lsblk()
health = drive_health(udisks_objects())
drives = []
swap = []
filesystems: dict[str, dict] = {}
for root in tree:
if root.get("type") != "disk":
continue
# zram is compressed swap in RAM. It is a block device and it is not
# storage; listing it as a drive would be actively misleading about how
# much of this machine is disk.
if str(root.get("name", "")).startswith("zram"):
swap.append({
"name": root.get("name"),
"sizeBytes": root.get("size") or 0,
"kind": "zram",
})
continue
serial = str(root.get("serial") or "")
drive_health_entry = health.get(serial, {})
partitions = []
encrypted = False
for node, depth in walk(root):
if depth == 0:
continue
if node.get("fstype") == "crypto_LUKS":
encrypted = True
if node.get("type") in ("part", "crypt"):
partitions.append({
"name": node.get("name"),
"path": node.get("path"),
"sizeBytes": node.get("size") or 0,
"fstype": node.get("fstype") or "",
"type": node.get("type"),
"mountpoints": mountpoints(node),
})
for point in mountpoints(node):
if node.get("fssize") is None:
continue
# Keyed by device: btrfs subvolumes mounted at / and /home are
# ONE filesystem with one pool of free space. Reporting them as
# two independent bars, which is what df does, doubles the free
# space on screen.
key = str(node.get("path"))
entry = filesystems.setdefault(key, {
"device": node.get("path"),
"fstype": node.get("fstype") or "",
"sizeBytes": int(node.get("fssize") or 0),
"usedBytes": int(node.get("fsused") or 0),
"availBytes": int(node.get("fsavail") or 0),
"mountpoints": [],
"encrypted": node.get("type") == "crypt",
})
entry["mountpoints"].append(point)
entry["mountpoints"].sort(key=lambda mount: (mount != "/", mount))
drives.append({
"name": root.get("name"),
"path": root.get("path"),
"model": (root.get("model") or "").strip() or str(root.get("name")),
"serial": serial,
"sizeBytes": root.get("size") or 0,
"rotational": bool(root.get("rota")),
"removable": bool(root.get("rm")) or bool(root.get("hotplug"))
or bool(drive_health_entry.get("removable")),
"ejectable": bool(drive_health_entry.get("ejectable")),
"encrypted": encrypted,
"partitions": partitions,
"temperatureC": drive_health_entry.get("temperatureC"),
"powerOnHours": drive_health_entry.get("powerOnHours"),
"healthy": drive_health_entry.get("healthy"),
"warnings": drive_health_entry.get("warnings", []),
"selfTest": drive_health_entry.get("selfTest", ""),
})
ordered = sorted(filesystems.values(),
key=lambda entry: (0 if "/" in entry["mountpoints"] else 1,
entry["mountpoints"][0] if entry["mountpoints"] else ""))
return {"drives": drives, "filesystems": ordered, "swap": swap}
def measure(path: Path, budget: float) -> tuple[int | None, float]:
"""Bytes used by a folder, and what is left of the time budget."""
if not path.is_dir():
return None, budget
started = time.monotonic()
try:
completed = subprocess.run(["du", "-sxb", str(path)], check=False,
capture_output=True, text=True, timeout=budget)
except (OSError, subprocess.TimeoutExpired):
# A folder that ran out of time has consumed the whole budget by
# definition; the caller stops rather than starting another walk.
return None, 0.0
left = max(budget - (time.monotonic() - started), 0.0)
if completed.returncode != 0:
return None, left
match = re.match(r"^(\d+)", completed.stdout)
return (int(match.group(1)) if match else None), left
def container_reclaimable() -> dict | None:
"""Container images nothing is using. Absent when podman is not installed."""
if not shutil.which("podman"):
return None
try:
raw = run(["podman", "system", "df", "--format", "json"], timeout=20.0)
entries = json.loads(raw)
except (BoundaryError, json.JSONDecodeError):
return None
for entry in entries if isinstance(entries, list) else []:
if not str(entry.get("Type", "")).lower().startswith("image"):
continue
# Raw* are integers; Size and Reclaimable are display strings like
# "10.8GB" and "5.31GB (49%)". Reading the display strings is how this
# first crashed, so only the raw fields are trusted, and an old podman
# that lacks them reports nothing rather than a wrong number.
total = entry.get("RawSize")
reclaimable = entry.get("RawReclaimable")
if not isinstance(total, int) or not isinstance(reclaimable, int):
return None
return {"totalBytes": total, "reclaimableBytes": reclaimable}
return None
def scan() -> dict:
folders = []
remaining = float(SCAN_TIMEOUT_SECONDS)
truncated = False
for label, target in SCAN_TARGETS:
path = Path(os.path.expanduser(target))
if remaining <= 1.0:
truncated = True
break
size, remaining = measure(path, remaining)
if size is None:
continue
folders.append({"label": label, "path": str(path), "bytes": size})
folders.sort(key=lambda item: item["bytes"], reverse=True)
return {
"folders": folders,
"truncated": truncated,
"containers": container_reclaimable(),
}
def removable_device(path: str) -> dict:
"""Resolve a device path, refusing anything that is not removable.
Unmounting the root filesystem is not a feature. The check is on the device
rather than the button because the caller is a UI that can be wrong.
"""
if not re.fullmatch(r"/dev/[A-Za-z0-9/_-]+", path or ""):
raise BoundaryError("That is not a device path.")
for root in lsblk():
for node, _ in walk(root):
if node.get("path") != path:
continue
top = root
if not (bool(top.get("rm")) or bool(top.get("hotplug"))):
raise BoundaryError("That drive is not removable.")
return node
raise BoundaryError("No such device.")
def main(arguments: list[str]) -> int:
try:
if arguments == ["snapshot"]:
print(json.dumps(snapshot(), separators=(",", ":")))
elif arguments == ["scan"]:
print(json.dumps(scan(), separators=(",", ":")))
elif len(arguments) == 2 and arguments[0] in ("unmount", "eject"):
removable_device(arguments[1])
action = "unmount" if arguments[0] == "unmount" else "power-off"
flag = "-b" if arguments[0] == "unmount" else "-b"
run(["udisksctl", action, flag, arguments[1]], timeout=30.0)
else:
raise BoundaryError(
"Usage: panama-disks snapshot | scan | unmount DEVICE | eject DEVICE")
except BoundaryError as error:
print(str(error), file=sys.stderr)
return 2
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
+58 -2
View File
@@ -13,6 +13,7 @@ import re
import secrets
import signal
import shutil
import time
import subprocess
import sys
from concurrent.futures import ThreadPoolExecutor
@@ -115,7 +116,7 @@ CHECK_ORDER = (
"desktop.hyprpaper", "desktop.hypridle", "desktop.hyprlock", "desktop.vicinae", "input.pipewire",
"input.clipboard", "input.wallpaper", "input.capture", "input.ocr", "input.brightness",
"integration.nextcloud", "integration.rustdesk", "integration.kdeconnect", "integration.bluebubbles",
"integration.home-assistant", "integration.calendar", "panama.runtime-links", "panama.vicinae-commands",
"integration.home-assistant", "integration.calendar", "panama.updates", "panama.runtime-links", "panama.vicinae-commands",
"panama.selected-terminal", "panama.selected-launcher", "panama.processes", "panama.caffeine",
)
@@ -485,6 +486,61 @@ def check_calendar(config: DoctorConfig) -> Check:
return Check("integration.calendar", "integrations", "Calendar", "ok", f"{enabled_sources} enabled calendar source{'s' if enabled_sources != 1 else ''} configured.")
def check_updates(config: DoctorConfig) -> Check:
"""Whether the machine is current, and whether it is running what it installed.
Two different questions with two different answers. A kernel that has been
installed but not booted into is the one people miss: everything reports
success, nothing looks wrong, and the security fix they installed last week
is sitting on disk unused. That is reported as its own state rather than
folded into "updates available".
Read from the Updates page's cache rather than by scanning: a health check
that took nine seconds of network work would make opening System Health feel
broken. A stale cache is reported as stale.
"""
cache = Path(os.environ.get("XDG_CACHE_HOME", config.home / ".cache")) / "panama" / "updates.json"
running = os.uname().release
newest = running
rpm_query = run_command(("rpm", "-q", "kernel", "--qf", "%{VERSION}-%{RELEASE}.%{ARCH}\\n"), config)
if rpm_query.state == "ok":
installed = [line.strip() for line in rpm_query.stdout.splitlines() if line.strip()]
if installed:
newest = installed[-1]
if newest != running:
return Check("panama.updates", "panama-tools", "Software updates", "warning",
f"A newer kernel is installed than the one running ({running} → {newest}). Restart to use it.",
action=Action("open", "Open Software Update"))
try:
payload = json.loads(cache.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return Check("panama.updates", "panama-tools", "Software updates", "unconfigured",
"Updates have not been checked yet.",
action=Action("open", "Open Software Update"))
checked_at = int(payload.get("checkedAt", 0))
age_days = (time.time() - checked_at) / 86400 if checked_at else 999
security = int(payload.get("dnf", {}).get("securityCount", 0))
total = sum(int(payload.get(source, {}).get("count", 0))
for source in ("dnf", "flatpak", "firmware"))
if security > 0:
return Check("panama.updates", "panama-tools", "Software updates", "warning",
f"{security} pending update{'' if security == 1 else 's'} carry a security advisory.",
action=Action("open", "Open Software Update"))
if age_days > 7:
return Check("panama.updates", "panama-tools", "Software updates", "unconfigured",
"Updates have not been checked in over a week.",
action=Action("open", "Open Software Update"))
if total > 0:
return Check("panama.updates", "panama-tools", "Software updates", "ok",
f"{total} update{'' if total == 1 else 's'} available, none carrying a security advisory.")
return Check("panama.updates", "panama-tools", "Software updates", "ok",
"Everything is current.")
def check_runtime_links(config: DoctorConfig) -> Check:
def valid_link(name: str, relative_source: Path) -> bool:
destination = config.config_home / name
@@ -601,7 +657,7 @@ def collect_checks(config: DoctorConfig) -> list[Check]:
"desktop.hyprpaper": lambda: service_check("desktop.hyprpaper", "Hyprpaper", "hyprpaper", config, Action("repair", "Restart Hyprpaper")), "desktop.hypridle": lambda: service_check("desktop.hypridle", "Hypridle", "hypridle", config, Action("repair", "Restart Hypridle")), "desktop.hyprlock": lambda: check_hyprlock(config), "desktop.vicinae": lambda: service_check("desktop.vicinae", "Vicinae", "vicinae", config, Action("repair", "Restart Vicinae")), "input.pipewire": lambda: service_check("input.pipewire", "PipeWire", "pipewire", config),
"input.clipboard": lambda: simple_ipc_check("input.clipboard", "Clipboard", "clipboard", config), "input.wallpaper": lambda: simple_ipc_check("input.wallpaper", "Wallpaper", "wallpaper", config), "input.capture": lambda: simple_ipc_check("input.capture", "Capture", "capture", config), "input.ocr": lambda: executable_check("input.ocr", "OCR", "tesseract", config), "input.brightness": lambda: check_brightness(config),
"integration.nextcloud": lambda: check_nextcloud(config), "integration.rustdesk": lambda: check_rustdesk(config), "integration.kdeconnect": lambda: check_kdeconnect(config), "integration.bluebubbles": lambda: check_bluebubbles(config), "integration.home-assistant": lambda: check_home_assistant(config), "integration.calendar": lambda: check_calendar(config),
"panama.runtime-links": lambda: check_runtime_links(config), "panama.vicinae-commands": lambda: check_vicinae_commands(config), "panama.selected-terminal": lambda: executable_check("panama.selected-terminal", "Selected terminal", "kitty", config), "panama.selected-launcher": lambda: executable_check("panama.selected-launcher", "Selected launcher", "vicinae", config), "panama.processes": lambda: check_processes(config), "panama.caffeine": lambda: check_caffeine(config),
"panama.updates": lambda: check_updates(config), "panama.runtime-links": lambda: check_runtime_links(config), "panama.vicinae-commands": lambda: check_vicinae_commands(config), "panama.selected-terminal": lambda: executable_check("panama.selected-terminal", "Selected terminal", "kitty", config), "panama.selected-launcher": lambda: executable_check("panama.selected-launcher", "Selected launcher", "vicinae", config), "panama.processes": lambda: check_processes(config), "panama.caffeine": lambda: check_caffeine(config),
}
with ThreadPoolExecutor(max_workers=8) as executor:
futures = {check_id: executor.submit(probes[check_id]) for check_id in CHECK_ORDER}
+371
View File
@@ -0,0 +1,371 @@
#!/usr/bin/env python3
"""The firewall, answered as "what can another machine reach?"
Listing zones and services is what firewall-cmd already does. The question it
does not answer is the one that matters, because it needs both halves at once: a
port is reachable only when something is LISTENING on a network address AND the
firewall permits it. Either alone tells you nothing -- which is how a tidy set of
rules coexists with an exposed database, as it does on this machine, where
Fedora Workstation's zone opens every port above 1024 and rootless containers
publish on all interfaces.
Changes go through firewall-cmd, which is polkit-aware, so they prompt.
panama-firewall snapshot
panama-firewall add-service NAME | remove-service NAME
panama-firewall add-port PORT/PROTO | remove-port PORT/PROTO
panama-firewall set-zone INTERFACE ZONE
panama-firewall set-default-zone ZONE
"""
from __future__ import annotations
import json
import re
import os
import shutil
import subprocess
import sys
from pathlib import Path
ZONE = re.compile(r"^[A-Za-z0-9_-]{1,32}$")
SERVICE = re.compile(r"^[A-Za-z0-9_.-]{1,64}$")
INTERFACE = re.compile(r"^[A-Za-z0-9_.:-]{1,32}$")
PORT_SPEC = re.compile(r"^(\d{1,5})(?:-(\d{1,5}))?/(tcp|udp)$")
# Things whose exposure is worth saying out loud. Not a judgement about the
# software -- a database on the network is simply a different risk from a
# printer, and someone should know which they have.
DATA_STORES = {
5432: "PostgreSQL", 3306: "MySQL", 3307: "MySQL", 6379: "Redis",
27017: "MongoDB", 5984: "CouchDB", 9200: "Elasticsearch", 11211: "memcached",
5433: "PostgreSQL", 1433: "SQL Server", 8086: "InfluxDB", 7000: "Cassandra",
}
# Ports whose purpose is worth naming when nothing else identifies them.
WELL_KNOWN = {
22: "SSH", 3389: "Remote desktop", 5353: "mDNS", 5355: "LLMNR",
1716: "KDE Connect", 631: "Printing", 139: "Samba", 445: "Samba",
3000: "Development server", 8080: "HTTP alternate",
}
class BoundaryError(RuntimeError):
"""A user-visible validation or firewall failure."""
def run(command: list[str], timeout: float = 30.0) -> subprocess.CompletedProcess:
try:
return subprocess.run(command, capture_output=True, text=True,
timeout=timeout, check=False)
except (OSError, subprocess.TimeoutExpired) as error:
raise BoundaryError(f"{command[0]} did not answer.") from error
def firewall(*arguments: str, timeout: float = 30.0) -> str:
if not shutil.which("firewall-cmd"):
raise BoundaryError("firewalld is not installed.")
result = run(["firewall-cmd", *arguments], timeout=timeout)
if result.returncode != 0:
message = (result.stderr or result.stdout).strip().splitlines()
text = message[-1] if message else "The firewall could not be read."
if "not authorized" in text.lower() or "dismissed" in text.lower():
raise BoundaryError("That firewall change was not authorized.")
raise BoundaryError(text[:200])
return result.stdout.strip()
def zone_detail(name: str) -> dict:
try:
raw = firewall(f"--zone={name}", "--list-all")
except BoundaryError:
return {}
detail = {"name": name, "interfaces": [], "services": [], "ports": [],
"richRules": [], "target": ""}
key = ""
for line in raw.splitlines()[1:]:
if ":" not in line:
continue
key, _, value = line.strip().partition(":")
value = value.strip()
if key == "interfaces":
detail["interfaces"] = value.split()
elif key == "services":
detail["services"] = value.split()
elif key == "ports":
detail["ports"] = value.split()
elif key == "target":
detail["target"] = value
elif key == "rich rules":
detail["richRules"] = [rule for rule in value.splitlines() if rule.strip()]
return detail
def service_ports(name: str) -> list[str]:
"""The ports a named service stands for, from firewalld's own definition."""
recorded = fixture()
if recorded is not None:
return list(recorded.get("servicePorts", {}).get(name, []))
try:
return firewall("--permanent", f"--service={name}", "--get-ports").split()
except BoundaryError:
return []
def allowed_ports(zone: dict) -> list[tuple[int, int, str, str]]:
"""Every port the zone permits, as (low, high, protocol, what allowed it)."""
allowed = []
for spec in zone.get("ports", []):
found = PORT_SPEC.match(spec)
if found:
low = int(found.group(1))
high = int(found.group(2) or found.group(1))
allowed.append((low, high, found.group(3), "the open port range"))
for service in zone.get("services", []):
for spec in service_ports(service):
found = PORT_SPEC.match(spec)
if found:
low = int(found.group(1))
high = int(found.group(2) or found.group(1))
allowed.append((low, high, found.group(3), f"the {service} service"))
return allowed
# Above this, a UDP socket is almost certainly the local end of an outbound
# conversation -- a browser talking to the internet -- rather than a service
# waiting to be contacted. Listing those as "exposed" buries the two rows that
# matter under twenty that do not.
EPHEMERAL_FLOOR = 32768
def fixture() -> dict | None:
"""A recorded firewall and set of listeners, for testing the crossing.
The rule this page exists for -- exposed means listening AND permitted --
cannot be tested against a machine whose firewall permits everything, and
the blocked case needs a listener below port 1024, which needs root to
create. So the inputs can be supplied instead. Live behaviour is unchanged
when the variable is unset.
"""
path = os.environ.get("PANAMA_FIREWALL_FIXTURE")
if not path:
return None
try:
return json.loads(Path(path).read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as error:
raise BoundaryError("The recorded firewall state could not be read.") from error
def listeners() -> list[dict]:
"""Sockets a machine on the network could actually connect TO.
TCP listeners are unambiguous: they are in LISTEN state because something
intends to accept connections. UDP has no such state, so a browser's
outbound socket looks identical to a service -- which is why an ephemeral
UDP port with no well-known meaning is left out rather than reported as an
exposure someone should worry about.
"""
recorded = fixture()
if recorded is not None:
return list(recorded.get("listeners", []))
result = run(["ss", "-tulpnH"])
if result.returncode != 0:
return []
found = {}
for line in result.stdout.splitlines():
parts = line.split()
if len(parts) < 5:
continue
protocol = parts[0]
address = parts[4]
# Loopback is not reachable from anywhere else, so it is not exposure.
if address.startswith(("127.", "[::1]")):
continue
port_text = address.rsplit(":", 1)[-1]
if not port_text.isdigit():
continue
process = ""
owner = re.search(r'users:\(\("([^"]+)"', line)
if owner:
process = owner.group(1)
port = int(port_text)
if (protocol.startswith("udp") and port >= EPHEMERAL_FLOOR
and port not in WELL_KNOWN and port not in DATA_STORES):
continue
key = (port, protocol)
# A port bound on several addresses is one exposure, not four.
if key not in found or (process and not found[key]["process"]):
found[key] = {"port": port, "protocol": protocol, "process": process,
"address": address}
return sorted(found.values(), key=lambda entry: entry["port"])
def describe(entry: dict) -> tuple[str, str]:
"""A name for what is listening, and how much it matters."""
port = entry["port"]
if port in DATA_STORES:
return DATA_STORES[port], "data"
if port in WELL_KNOWN:
return WELL_KNOWN[port], "known"
process = entry.get("process") or ""
return (process or f"port {port}"), "other"
def snapshot() -> dict:
recorded = fixture()
if recorded is not None:
zones = list(recorded.get("zones", []))
permitted = []
for zone in zones:
permitted.extend(allowed_ports(zone))
exposed = []
for entry in listeners():
allowed_by = ""
for low, high, protocol, reason in permitted:
if str(entry.get("protocol", "")).startswith(protocol) and low <= int(entry["port"]) <= high:
if not allowed_by or reason != "the open port range":
allowed_by = reason
if not allowed_by:
continue
name, kind = describe(entry)
exposed.append({"name": name, "port": int(entry["port"]),
"protocol": entry.get("protocol", ""),
"process": entry.get("process", ""),
"allowedBy": allowed_by, "kind": kind})
return {"running": True, "enabledAtBoot": True, "available": True,
"defaultZone": zones[0]["name"] if zones else "", "allZones": [],
"activeZones": {}, "zones": zones, "exposed": exposed,
"exposedDataStores": [e for e in exposed if e["kind"] == "data"],
"sshSessions": 0, "error": ""}
running = run(["systemctl", "is-active", "firewalld"]).stdout.strip() == "active"
enabled = run(["systemctl", "is-enabled", "firewalld"]).stdout.strip() == "enabled"
if not shutil.which("firewall-cmd") or not running:
return {"running": running, "enabledAtBoot": enabled, "available": False,
"zones": [], "activeZones": {}, "defaultZone": "", "exposed": [],
"allZones": [], "error": ""}
default_zone = firewall("--get-default-zone")
all_zones = firewall("--get-zones").split()
active = {}
current = ""
for line in firewall("--get-active-zones").splitlines():
if not line.startswith(" "):
current = line.split()[0] if line.split() else ""
continue
if "interfaces:" in line and current:
active[current] = line.split(":", 1)[1].split()
zones = [zone_detail(name) for name in active] or [zone_detail(default_zone)]
zones = [zone for zone in zones if zone]
# The cross-reference: listening AND permitted.
permitted = []
for zone in zones:
permitted.extend(allowed_ports(zone))
exposed = []
for entry in listeners():
allowed_by = ""
for low, high, protocol, reason in permitted:
if entry["protocol"].startswith(protocol) and low <= entry["port"] <= high:
# A named service is a better explanation than a range.
if not allowed_by or reason != "the open port range":
allowed_by = reason
if not allowed_by:
continue
name, kind = describe(entry)
exposed.append({
"name": name,
"port": entry["port"],
"protocol": entry["protocol"],
"process": entry["process"],
"allowedBy": allowed_by,
"kind": kind,
})
return {
"running": running,
"enabledAtBoot": enabled,
"available": True,
"defaultZone": default_zone,
"allZones": all_zones,
"activeZones": active,
"zones": zones,
"exposed": exposed,
# Counted separately so the page can lead with it.
"exposedDataStores": [entry for entry in exposed if entry["kind"] == "data"],
"sshSessions": len([line for line in run(
["ss", "-tnH", "state", "established", "( sport = :22 )"]).stdout.splitlines()
if line.strip()]),
"error": "",
}
def require(pattern: re.Pattern, value: str, message: str) -> str:
if not pattern.fullmatch(value or ""):
raise BoundaryError(message)
return value
def change(zone: str, *arguments: str) -> None:
"""Apply to the running firewall and to the stored configuration.
Both, because a change that survives a reboot but is not in effect -- or the
reverse -- is a firewall nobody can reason about.
"""
firewall(f"--zone={zone}", *arguments, timeout=120)
firewall("--permanent", f"--zone={zone}", *arguments, timeout=120)
def active_zone() -> str:
state = snapshot()
zones = state.get("zones") or []
return zones[0]["name"] if zones else state.get("defaultZone", "")
def main(arguments: list[str]) -> int:
try:
if arguments == ["snapshot"]:
print(json.dumps(snapshot(), separators=(",", ":")))
return 0
if len(arguments) == 2 and arguments[0] in ("add-service", "remove-service"):
name = require(SERVICE, arguments[1], "That is not a service name.")
verb = "--add-service" if arguments[0] == "add-service" else "--remove-service"
change(active_zone(), f"{verb}={name}")
elif len(arguments) == 2 and arguments[0] in ("add-port", "remove-port"):
spec = require(PORT_SPEC, arguments[1], "That is not a port.")
verb = "--add-port" if arguments[0] == "add-port" else "--remove-port"
change(active_zone(), f"{verb}={spec}")
elif len(arguments) == 3 and arguments[0] == "set-zone":
interface = require(INTERFACE, arguments[1], "That is not a network interface.")
zone = require(ZONE, arguments[2], "That is not a zone.")
firewall(f"--zone={zone}", f"--change-interface={interface}", timeout=120)
firewall("--permanent", f"--zone={zone}", f"--change-interface={interface}", timeout=120)
elif len(arguments) == 2 and arguments[0] == "set-default-zone":
zone = require(ZONE, arguments[1], "That is not a zone.")
firewall(f"--set-default-zone={zone}", timeout=120)
else:
raise BoundaryError(
"Usage: panama-firewall snapshot | add-service NAME | remove-service NAME | "
"add-port PORT/PROTO | remove-port PORT/PROTO | set-zone INTERFACE ZONE | "
"set-default-zone ZONE")
except BoundaryError as error:
try:
state = snapshot()
except BoundaryError:
state = {"running": False, "available": False, "zones": [], "exposed": []}
state["error"] = str(error)
print(json.dumps(state, separators=(",", ":")))
return 0
print(json.dumps(snapshot(), separators=(",", ":")))
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
+345
View File
@@ -0,0 +1,345 @@
#!/usr/bin/env python3
"""Gaming: what the machine is doing, and what it should do while you play.
The interesting part is not reporting gamemode's state -- it is reacting to it.
gamemode can run a script when a game asks for it and another when the game
exits, so `hook start` and `hook end` are what let Panama switch the power
profile and silence notifications for exactly the duration of a game, and put
both back afterwards. Everything else here is honest reporting.
panama-gaming snapshot
panama-gaming set-overlay true|false
panama-gaming set-overlay-preset fps|detailed
panama-gaming install-hooks | remove-hooks
panama-gaming hook start|end (called by gamemode, not by a person)
"""
from __future__ import annotations
import json
import os
import re
import shutil
import subprocess
import sys
from pathlib import Path
MANGOHUD_CONFIG = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "MangoHud" / "MangoHud.conf"
GAMEMODE_CONFIG = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "gamemode.ini"
ENVIRONMENT_CONFIG = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "environment.d" / "panama-mangohud.conf"
# What the overlay shows. Deliberately two presets rather than exposing every
# MangoHud key: this is a settings page, not a config file with a nicer font.
PRESETS = {
"fps": ["fps", "frametime=0", "no_display=0", "position=top-left",
"font_size=22", "background_alpha=0.4", "toggle_hud=Shift_R+F12"],
"detailed": ["fps", "frametime", "gpu_stats", "gpu_temp", "gpu_power",
"cpu_stats", "cpu_temp", "ram", "vram", "position=top-left",
"font_size=20", "background_alpha=0.4", "toggle_hud=Shift_R+F12"],
}
class BoundaryError(RuntimeError):
"""A user-visible failure."""
def run(command: list[str], timeout: float = 20.0) -> subprocess.CompletedProcess:
try:
return subprocess.run(command, capture_output=True, text=True,
timeout=timeout, check=False)
except (OSError, subprocess.TimeoutExpired) as error:
raise BoundaryError(f"{command[0]} did not answer.") from error
def read_int(path: Path) -> int | None:
try:
return int(path.read_text().strip())
except (OSError, ValueError):
return None
def gpus() -> list[dict]:
"""Every AMD/Intel GPU with a hwmon node, warmest first.
Read from sysfs rather than a tool: it costs nothing, needs no daemon, and
a page that polls while it is open should not be spawning processes.
"""
found = []
for hwmon in sorted(Path("/sys/class/hwmon").glob("hwmon*")):
try:
name = (hwmon / "name").read_text().strip()
except OSError:
continue
if name not in ("amdgpu", "i915", "xe", "nouveau"):
continue
device = (hwmon / "device").resolve()
temperature = read_int(hwmon / "temp1_input")
power = read_int(hwmon / "power1_average")
used = read_int(device / "mem_info_vram_used")
total = read_int(device / "mem_info_vram_total")
model = ""
try:
model = (device / "product_name").read_text().strip()
except OSError:
model = ""
found.append({
"driver": name,
"model": model,
"temperatureC": round(temperature / 1000, 1) if temperature else None,
"watts": round(power / 1000000, 1) if power else None,
"vramUsedBytes": used or 0,
"vramTotalBytes": total or 0,
# A card with no VRAM reported is the integrated one sharing system
# memory; saying "0 of 0 GB" would look broken.
"discrete": bool(total and total > 1073741824),
})
found.sort(key=lambda entry: (not entry["discrete"], entry["driver"]))
return found
def governor() -> str:
try:
return Path("/sys/devices/system/cpu/cpu0/cpufreq/scaling_governor").read_text().strip()
except OSError:
return ""
def game_mode() -> dict:
available = bool(shutil.which("gamemoded"))
state = {"available": available, "active": False, "daemonRunning": False,
"governorWhileGaming": "", "governorNow": governor()}
if not available:
return state
status = run(["gamemoded", "-s"]).stdout.strip()
state["active"] = "is active" in status
state["daemonRunning"] = "gamemode is" in status
# What it would switch the governor to, from its own configuration.
for path in (GAMEMODE_CONFIG, Path("/usr/share/gamemode/gamemode.ini")):
try:
text = path.read_text()
except OSError:
continue
found = re.search(r"^\s*desiredgov\s*=\s*(\S+)", text, re.M)
if found:
state["governorWhileGaming"] = found.group(1)
break
return state
def hooks_installed() -> bool:
try:
text = GAMEMODE_CONFIG.read_text()
except OSError:
return False
return "panama-gaming hook start" in text
def overlay() -> dict:
installed = bool(shutil.which("mangohud"))
preset = ""
if MANGOHUD_CONFIG.is_file():
try:
body = MANGOHUD_CONFIG.read_text()
preset = "detailed" if "gpu_stats" in body else "fps"
except OSError:
preset = ""
return {
"installed": installed,
"configured": MANGOHUD_CONFIG.is_file(),
"preset": preset or "fps",
# Global enablement is an environment variable read at session start, so
# a change here does not affect anything already running.
"globallyEnabled": ENVIRONMENT_CONFIG.is_file(),
"toggleKey": "Shift_R+F12",
}
def library() -> dict:
root = Path.home() / ".local/share/Steam"
games = len(list((root / "steamapps").glob("*.acf"))) if (root / "steamapps").is_dir() else 0
tools = []
for directory in (root / "compatibilitytools.d", root / "steamapps/common"):
if not directory.is_dir():
continue
for entry in sorted(directory.iterdir()):
if entry.is_dir() and entry.name.lower().startswith("proton"):
tools.append({
"name": entry.name,
"community": directory.name == "compatibilitytools.d",
})
return {
"path": str(root),
"games": games,
"protonBuilds": tools,
"gamescope": bool(shutil.which("gamescope")),
"steam": bool(shutil.which("steam")),
}
def snapshot() -> dict:
return {
"gameMode": {**game_mode(), "hooksInstalled": hooks_installed()},
"gpus": gpus(),
"overlay": overlay(),
"library": library(),
"error": "",
}
def set_overlay_preset(preset: str) -> None:
if preset not in PRESETS:
raise BoundaryError("That is not an overlay preset.")
MANGOHUD_CONFIG.parent.mkdir(parents=True, exist_ok=True)
header = ("# Written by Panama's Gaming settings.\n"
"# Edits here are replaced when the preset changes.\n")
MANGOHUD_CONFIG.write_text(header + "\n".join(PRESETS[preset]) + "\n", encoding="utf-8")
def set_overlay(enabled: bool) -> None:
if not shutil.which("mangohud"):
raise BoundaryError("MangoHud is not installed.")
if enabled:
if not MANGOHUD_CONFIG.is_file():
set_overlay_preset("fps")
ENVIRONMENT_CONFIG.parent.mkdir(parents=True, exist_ok=True)
ENVIRONMENT_CONFIG.write_text(
"# Written by Panama's Gaming settings.\n"
"# Read when the session starts, so this reaches applications\n"
"# launched afterwards rather than ones already running.\n"
"MANGOHUD=1\n", encoding="utf-8")
else:
try:
ENVIRONMENT_CONFIG.unlink()
except OSError:
pass
def install_hooks() -> None:
"""Point gamemode's start and end hooks at this script.
Written by editing rather than replacing: gamemode.ini is the user's file
and may hold settings this page does not manage.
"""
script = str(Path(__file__).resolve())
lines = []
if GAMEMODE_CONFIG.is_file():
lines = [line for line in GAMEMODE_CONFIG.read_text().splitlines()
if "panama-gaming hook" not in line]
text = "\n".join(lines)
if "[custom]" not in text:
text += "\n\n[custom]\n"
text = re.sub(r"\[custom\]\n",
f"[custom]\nstart={script} hook start\nend={script} hook end\n",
text, count=1)
GAMEMODE_CONFIG.parent.mkdir(parents=True, exist_ok=True)
GAMEMODE_CONFIG.write_text(text.strip() + "\n", encoding="utf-8")
def remove_hooks() -> None:
if not GAMEMODE_CONFIG.is_file():
return
lines = [line for line in GAMEMODE_CONFIG.read_text().splitlines()
if "panama-gaming hook" not in line]
GAMEMODE_CONFIG.write_text("\n".join(lines).strip() + "\n", encoding="utf-8")
def hook(phase: str) -> None:
"""Called by gamemode when a game starts and when it stops.
Reads what the user asked for from the settings file directly: the shell may
not be running, and a hook that depends on a running shell would silently do
nothing for someone who restarted it mid-session.
"""
if phase not in ("start", "end"):
raise BoundaryError("That is not a hook phase.")
settings_path = (Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
/ "panama" / "settings.json")
try:
settings = json.loads(settings_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
settings = {}
scripts = Path(__file__).resolve().parent
# What was true before the game started, so ending it restores that rather
# than imposing a default. Without this, finishing a game would silently
# undo a power profile or a Do Not Disturb the user had chosen themselves.
state_path = Path(os.environ.get("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}")) / "panama-gaming.json"
if phase == "start":
before = {}
if settings.get("gamingPerformanceProfile", True):
current = run([str(scripts / "panama-power-profile"), "list"], timeout=15)
try:
before["profile"] = json.loads(current.stdout or "{}").get("active", "")
except json.JSONDecodeError:
before["profile"] = ""
run([str(scripts / "panama-power-profile"), "set", "performance"], timeout=15)
# Reported, not acted on. Silencing is a focus mode's job now, and two
# things writing Do Not Disturb would each restore whatever the other
# happened to leave behind. The hook knows a game started; what that
# should mean is decided in one place.
if shutil.which("qs"):
run(["qs", "ipc", "call", "focus", "gameStarted"], timeout=10)
try:
state_path.write_text(json.dumps(before), encoding="utf-8")
except OSError:
pass
if settings.get("gamingNotifyOnStart", False) and shutil.which("notify-send"):
run(["notify-send", "-a", "Panama", "Game Mode",
"Performance profile engaged."], timeout=10)
return
# end
try:
before = json.loads(state_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
before = {}
if before.get("profile"):
run([str(scripts / "panama-power-profile"), "set", before["profile"]], timeout=15)
if shutil.which("qs"):
run(["qs", "ipc", "call", "focus", "gameEnded"], timeout=10)
try:
state_path.unlink()
except OSError:
pass
def main(arguments: list[str]) -> int:
try:
if arguments == ["snapshot"]:
print(json.dumps(snapshot(), separators=(",", ":")))
return 0
if len(arguments) == 2 and arguments[0] == "hook":
hook(arguments[1])
return 0
if len(arguments) == 2 and arguments[0] == "set-overlay":
set_overlay(arguments[1] == "true")
elif len(arguments) == 2 and arguments[0] == "set-overlay-preset":
set_overlay_preset(arguments[1])
elif arguments == ["install-hooks"]:
install_hooks()
elif arguments == ["remove-hooks"]:
remove_hooks()
else:
raise BoundaryError(
"Usage: panama-gaming snapshot | set-overlay true|false | "
"set-overlay-preset fps|detailed | install-hooks | remove-hooks | "
"hook start|end")
except BoundaryError as error:
state = snapshot()
state["error"] = str(error)
print(json.dumps(state, separators=(",", ":")))
return 0
print(json.dumps(snapshot(), separators=(",", ":")))
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
+34 -20
View File
@@ -7,7 +7,6 @@ from __future__ import annotations
import json
import pathlib
import re
import shlex
import subprocess
import sys
from collections.abc import Callable
@@ -88,33 +87,44 @@ def normalize_device_line(
}
# busctl properties are read with --json=short, not in its default text form.
#
# The text form escapes every non-ASCII byte in octal, and it escapes them into
# the OUTPUT rather than into a quoted string a shell-style parser can undo -- so
# a phone named "Gib's iPhone" with a typographic apostrophe arrived as the
# literal characters "Gib\342\200\231s iPhone" and was displayed that way.
# That is not specific to apostrophes: any name with an accent, an emoji, a
# quote, or a backslash was affected the same way.
#
# The JSON form returns real UTF-8 and needs no unescaping, which is why these
# parse a document rather than splitting words.
def parse_property(output: str, expected: str) -> object | None:
"""The value of a busctl --json=short property read, or None if it is not
the type asked for."""
try:
payload = json.loads(output)
except (json.JSONDecodeError, TypeError):
return None
if not isinstance(payload, dict) or payload.get("type") != expected:
return None
return payload.get("data")
def parse_loaded_plugins(output: str) -> list[str]:
try:
parts = shlex.split(output)
except ValueError:
data = parse_property(output, "as")
if not isinstance(data, list):
return []
if len(parts) < 2 or parts[0] != "as":
return []
try:
count = int(parts[1])
except ValueError:
return []
return parts[2 : 2 + max(0, count)]
return [str(item) for item in data]
def parse_string_property(output: str) -> str:
try:
parts = shlex.split(output)
except ValueError:
return ""
return parts[1] if len(parts) == 2 and parts[0] == "s" else ""
data = parse_property(output, "s")
return data if isinstance(data, str) else ""
def parse_bool_property(output: str) -> bool | None:
parts = output.split()
if len(parts) != 2 or parts[0] != "b" or parts[1] not in {"true", "false"}:
return None
return parts[1] == "true"
data = parse_property(output, "b")
return data if isinstance(data, bool) else None
def run_command(
@@ -141,6 +151,7 @@ def loaded_plugins(device_id: str, runner: Runner = subprocess.run) -> list[str]
[
"busctl",
"--user",
"--json=short",
"call",
"org.kde.kdeconnect",
device_object(device_id),
@@ -157,6 +168,7 @@ def supported_plugins(device_id: str, runner: Runner = subprocess.run) -> list[s
[
"busctl",
"--user",
"--json=short",
"get-property",
"org.kde.kdeconnect",
device_object(device_id),
@@ -178,6 +190,7 @@ def reported_type(device_id: str, runner: Runner = subprocess.run) -> str:
[
"busctl",
"--user",
"--json=short",
"get-property",
"org.kde.kdeconnect",
device_object(device_id),
@@ -198,6 +211,7 @@ def device_property(
[
"busctl",
"--user",
"--json=short",
"get-property",
"org.kde.kdeconnect",
device_object(device_id),
+142 -2
View File
@@ -33,9 +33,11 @@ Usage:
panama-keyring unlock -> raises the password prompt; prints the new state
"""
import hashlib
import json
import os
import re
import subprocess
import sys
@@ -147,10 +149,127 @@ def report(service, Secret):
}
# Attributes worth showing. The rest are storage plumbing -- schema names,
# internal ids -- and listing them turns a readable row into a debug dump.
INTERESTING_ATTRIBUTES = (
"server", "host", "domain", "user", "username", "account", "protocol",
"service", "object", "port", "goa-identity", "application",
)
# How long a copied password stays on the clipboard.
CLIPBOARD_SECONDS = 45
def item_summary(item) -> dict:
"""Everything about a stored secret EXCEPT the secret.
Nothing in here reads the value. That is not an accident of implementation
but the contract of this function: enumerating the keyring must never
require the keyring to hand over what it is protecting.
"""
attributes = dict(item.get_attributes() or {})
shown = {key: value for key, value in attributes.items()
if key in INTERESTING_ATTRIBUTES and value}
return {
"path": item.get_object_path(),
"label": item.get_label() or "Unnamed",
"schema": attributes.get("xdg:schema", ""),
"attributes": shown,
"created": int(item.get_created() or 0),
"modified": int(item.get_modified() or 0),
"locked": bool(item.get_locked()),
}
def items_report(service, Secret) -> dict:
collections = []
for collection in service.get_collections():
entries = collection.get_items() or []
collections.append({
"label": collection.get_label() or "Unnamed keyring",
"path": collection.get_object_path(),
"locked": bool(collection.get_locked()),
# A locked collection reports no items rather than an empty one:
# "nothing stored here" and "cannot look" are different answers.
"readable": not collection.get_locked(),
"items": [item_summary(item) for item in entries],
})
# Empty, unnamed collections are the session store and similar plumbing.
collections = [entry for entry in collections
if entry["items"] or entry["label"] != "Unnamed keyring"]
collections.sort(key=lambda entry: (entry["label"] != "Login", entry["label"]))
return {"collections": collections, "error": ""}
def resolve_item(service, Secret, path: str):
"""An item by its D-Bus path, refusing anything that is not one.
The caller is a settings page, and a settings page can be wrong or stale --
an item deleted in another window leaves a path that no longer resolves.
"""
if not re.fullmatch(r"/org/freedesktop/secrets/collection/[A-Za-z0-9_]+/[0-9]+", path or ""):
raise ValueError("That is not a stored secret.")
for collection in service.get_collections():
for item in collection.get_items() or []:
if item.get_object_path() == path:
return item
raise ValueError("That secret no longer exists.")
def copy_secret(service, Secret, path: str) -> None:
"""Put one stored secret on the clipboard, and nowhere else.
The value is read in this process and handed to wl-copy on STDIN. It is
never an argument -- argv is world-readable through /proc, so passing a
password there would publish it to every process on the machine -- and it
is never printed, logged, or included in an error message.
"""
item = resolve_item(service, Secret, path)
item.load_secret_sync(None)
value = item.get_secret()
if value is None:
raise ValueError("That secret could not be read.")
secret = value.get_text()
if secret is None:
raise ValueError("That secret is not text.")
encoded = secret.encode("utf-8")
completed = subprocess.run(["wl-copy"], input=encoded, capture_output=True)
if completed.returncode != 0:
# Deliberately does not echo the tool's stderr: a clipboard tool that
# fails while holding a password should not get to decide what lands in
# a log.
raise ValueError("The clipboard is not available.")
# Clear it again, but only if it is still the thing we put there. The guard
# compares a HASH, so the reminder process never holds the password -- and
# a clipboard the user has since replaced is left alone.
digest = hashlib.sha256(encoded).hexdigest()
subprocess.Popen(
["sh", "-c",
'sleep "$1"; current="$(wl-paste --no-newline 2>/dev/null | sha256sum | cut -d" " -f1)";'
' [ "$current" = "$2" ] && wl-copy --clear',
"sh", str(CLIPBOARD_SECONDS), digest],
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
start_new_session=True,
)
def forget_secret(service, Secret, path: str) -> None:
"""Delete one stored secret. Irreversible, and the UI confirms first."""
item = resolve_item(service, Secret, path)
item.delete_sync(None)
def main():
action = sys.argv[1] if len(sys.argv) > 1 else "status"
if action not in ("status", "unlock"):
print("usage: panama-keyring [status|unlock]", file=sys.stderr)
target = sys.argv[2] if len(sys.argv) > 2 else ""
if action not in ("status", "unlock", "items", "copy", "forget"):
print("usage: panama-keyring [status|unlock|items|copy PATH|forget PATH]",
file=sys.stderr)
return 2
if action in ("copy", "forget") and not target:
print(f"usage: panama-keyring {action} PATH", file=sys.stderr)
return 2
try:
@@ -165,6 +284,27 @@ def main():
}))
return 0
# These three answer with the item list, so a page never has to ask twice
# to find out what changed.
if action in ("items", "copy", "forget"):
try:
if action == "copy":
copy_secret(service, Secret, target)
elif action == "forget":
forget_secret(service, Secret, target)
Secret, service = load_service()
except Exception as error: # noqa: BLE001
# The message is this module's own, never the underlying tool's:
# an exception raised while a secret is in hand must not get to
# decide what text reaches a log or a settings page.
payload = items_report(service, Secret)
payload["error"] = (str(error) if isinstance(error, ValueError)
else "That secret could not be used.")
print(json.dumps(payload))
return 0
print(json.dumps(items_report(service, Secret)))
return 0
if action == "unlock":
login = next(
(c for c in service.get_collections() if c.get_label() == "Login"), None)
+186
View File
@@ -0,0 +1,186 @@
#!/usr/bin/env python3
"""Which applications may use the camera and microphone.
Read from xdg-desktop-portal's permission store, which is where an application
that asks through the portal has its answer recorded. That is the whole of what
this can control, and the limit is worth stating plainly rather than implying a
protection that does not exist: a native binary opens /dev/video0 directly and
no desktop setting stands in its way. What this covers is Flatpaks and anything
else that goes through the portal -- which on this machine is most of what would
ever ask.
Devices with no recorded application are reported as empty rather than omitted,
so the page can say "nothing has asked" instead of showing nothing at all.
panama-permissions snapshot
panama-permissions set DEVICE APP_ID allow|deny
panama-permissions forget DEVICE APP_ID
"""
from __future__ import annotations
import json
import re
import subprocess
import sys
TABLE = "devices"
# The devices the portal arbitrates. Listed rather than discovered so a device
# nothing has asked for still appears, which is the difference between "no
# application uses your microphone" and a page that silently omits it.
DEVICES = (
("camera", "Camera"),
("microphone", "Microphone"),
("speakers", "Speakers"),
)
APP_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
DEVICE_ID = re.compile(r"^[a-z]+$")
ALLOWED = "yes"
DENIED = "no"
class BoundaryError(RuntimeError):
"""A user-visible validation or permission-store failure."""
def run(command: list[str], timeout: float = 15.0) -> subprocess.CompletedProcess:
try:
return subprocess.run(command, capture_output=True, text=True, timeout=timeout)
except FileNotFoundError as error:
raise BoundaryError("busctl is not available.") from error
except subprocess.TimeoutExpired as error:
raise BoundaryError("The permission store did not respond.") from error
def portal_call(method: str, signature: str, *arguments: str) -> dict | None:
"""One call to the permission store, as JSON.
--json=short rather than busctl's text output, which escapes non-ASCII into
octal and would mangle an application name.
"""
result = run([
"busctl", "--user", "--json=short", "call",
"org.freedesktop.impl.portal.PermissionStore",
"/org/freedesktop/impl/portal/PermissionStore",
"org.freedesktop.impl.portal.PermissionStore",
method, signature, *arguments,
])
if result.returncode != 0:
detail = (result.stderr or "").strip()
# A device nothing has ever asked for has no row at all, and the store
# says so as "No entry for camera". Matched on the store's actual words
# rather than a guess at them.
lowered = detail.lower()
if "no entry" in lowered or "not found" in lowered:
return None
raise BoundaryError(detail.splitlines()[-1] if detail else "The permission store refused that.")
try:
return json.loads(result.stdout or "null")
except json.JSONDecodeError as error:
raise BoundaryError("The permission store returned something unreadable.") from error
def available() -> bool:
result = run(["busctl", "--user", "list"])
return "org.freedesktop.impl.portal.PermissionStore" in (result.stdout or "")
def entries_for(device: str) -> list[dict]:
payload = portal_call("Lookup", "ss", TABLE, device)
if not payload:
return []
data = payload.get("data") or []
if not data or not isinstance(data[0], dict):
return []
found = []
for app_id, permissions in data[0].items():
values = [str(value) for value in (permissions or [])]
found.append({
"app": app_id,
# Anything that is not an explicit "yes" is treated as withheld:
# guessing generously about a camera is the wrong way to be wrong.
"allowed": ALLOWED in values,
"raw": ",".join(values),
})
found.sort(key=lambda entry: entry["app"].casefold())
return found
def snapshot() -> dict:
if not available():
return {
"available": False,
"devices": [],
"error": "The desktop portal's permission store is not running.",
}
devices = []
for device_id, label in DEVICES:
devices.append({
"id": device_id,
"label": label,
"applications": entries_for(device_id),
})
return {"available": True, "devices": devices, "error": ""}
def require(pattern: re.Pattern[str], value: str, message: str) -> str:
if not pattern.match(value or ""):
raise BoundaryError(message)
return value
def set_permission(device: str, app: str, allowed: bool) -> None:
require(DEVICE_ID, device, "That is not a device.")
require(APP_ID, app, "That is not an application.")
if not any(device == known for known, _ in DEVICES):
raise BoundaryError("That is not a device this manages.")
# Permissions are an array of strings, so busctl needs the element count
# before the element -- "1 yes", not "yes".
portal_call("SetPermission", "sbssas", TABLE, "true", device, app,
"1", ALLOWED if allowed else DENIED)
def forget(device: str, app: str) -> None:
"""Drop the recorded answer, so the application is asked again next time."""
require(DEVICE_ID, device, "That is not a device.")
require(APP_ID, app, "That is not an application.")
portal_call("DeletePermission", "sss", TABLE, device, app)
def main(arguments: list[str]) -> int:
try:
if arguments == ["snapshot"]:
print(json.dumps(snapshot(), separators=(",", ":")))
return 0
if len(arguments) == 4 and arguments[0] == "set":
if arguments[3] not in ("allow", "deny"):
raise BoundaryError("That is not allow or deny.")
set_permission(arguments[1], arguments[2], arguments[3] == "allow")
elif len(arguments) == 3 and arguments[0] == "forget":
forget(arguments[1], arguments[2])
else:
raise BoundaryError(
"Usage: panama-permissions snapshot | set DEVICE APP allow|deny | "
"forget DEVICE APP")
except BoundaryError as error:
try:
state = snapshot()
except BoundaryError:
state = {"available": False, "devices": []}
state["error"] = str(error)
print(json.dumps(state, separators=(",", ":")))
return 0
print(json.dumps(snapshot(), separators=(",", ":")))
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
+335
View File
@@ -0,0 +1,335 @@
#!/usr/bin/env python3
"""Panama's polkit authentication agent -- the D-Bus half.
This process never sees a password. It is worth being explicit about why the
work is split this way, because the split IS the security design:
* This half registers with polkitd, receives an authentication request, and
hands the non-secret parts of it to the shell: which action, what message,
which identities may answer, and the one-time cookie.
* The shell half draws the prompt, and when someone types a password it
spawns the setuid `polkit-agent-helper-1` itself and writes the password to
that helper's STDIN. The helper performs the PAM conversation and reports
the result to polkitd directly.
So the password exists only inside the shell process and the helper's stdin. It
never crosses D-Bus, never crosses this process, and never appears on a command
line -- argv is world-readable through /proc, which is why "just pass it as an
argument" is not an option anywhere in this codebase.
The request itself is handed over as a file in the runtime directory, created
0600, rather than as IPC arguments. The cookie is not a password, but it is a
capability, and capabilities do not belong in a process listing either.
"""
from __future__ import annotations
import json
import os
import signal
import subprocess
import sys
import time
from pathlib import Path
import gi
gi.require_version("Gio", "2.0")
gi.require_version("GLib", "2.0")
from gi.repository import Gio, GLib # noqa: E402
AGENT_PATH = "/org/panama/PolkitAgent"
AGENT_INTERFACE = "org.freedesktop.PolicyKit1.AuthenticationAgent"
AUTHORITY_NAME = "org.freedesktop.PolicyKit1"
AUTHORITY_PATH = "/org/freedesktop/PolicyKit1/Authority"
AUTHORITY_INTERFACE = "org.freedesktop.PolicyKit1.Authority"
# How long a prompt may stay on screen before this gives up and tells polkit the
# request failed. Without it, a shell that died mid-prompt would leave the
# caller (a package manager, say) waiting forever.
PROMPT_TIMEOUT_SECONDS = 300
INTROSPECTION = """
<node>
<interface name='org.freedesktop.PolicyKit1.AuthenticationAgent'>
<method name='BeginAuthentication'>
<arg type='s' name='action_id' direction='in'/>
<arg type='s' name='message' direction='in'/>
<arg type='s' name='icon_name' direction='in'/>
<arg type='a{ss}' name='details' direction='in'/>
<arg type='s' name='cookie' direction='in'/>
<arg type='a(sa{sv})' name='identities' direction='in'/>
</method>
<method name='CancelAuthentication'>
<arg type='s' name='cookie' direction='in'/>
</method>
</interface>
</node>
"""
def sweep_stale(base: Path) -> None:
"""Remove leftovers from an agent that did not exit cleanly.
A killed agent leaves its request and response behind. They are harmless --
a spent cookie and a one-word result -- but they accumulate, and a directory
of stale capabilities is a bad habit even when each one is inert.
"""
for path in base.glob("request-*"):
try:
if time.time() - path.stat().st_mtime > PROMPT_TIMEOUT_SECONDS:
path.unlink()
except OSError:
continue
def runtime_dir() -> Path:
base = Path(os.environ.get("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}")) / "panama-polkit"
base.mkdir(mode=0o700, parents=True, exist_ok=True)
# Enforced rather than assumed: an inherited directory with looser
# permissions would expose every request that passes through it.
os.chmod(base, 0o700)
sweep_stale(base)
return base
def log(message: str) -> None:
print(f"panama-polkit-agent: {message}", file=sys.stderr, flush=True)
class Agent:
def __init__(self) -> None:
self.bus = Gio.bus_get_sync(Gio.BusType.SYSTEM, None)
self.pending: dict[str, Path] = {}
self.loop = GLib.MainLoop()
# ── Registration ────────────────────────────────────────────────────────
def subject(self) -> GLib.Variant:
"""This login session, which is what the agent authenticates for.
XDG_SESSION_ID is set for a login shell but NOT in the systemd user
environment, so running as a unit has to ask logind which session this
process belongs to rather than trusting the variable to be there.
"""
session_id = os.environ.get("XDG_SESSION_ID", "")
if not session_id:
session_id = self.session_from_logind()
if not session_id:
raise RuntimeError("could not determine the login session to register for")
return GLib.Variant("(sa{sv})", ("unix-session",
{"session-id": GLib.Variant("s", session_id)}))
def session_from_logind(self) -> str:
"""This user's graphical session, asked of logind.
Not GetSessionByPID: a systemd USER unit runs under [email protected],
which belongs to no login session at all -- which is exactly why
XDG_SESSION_ID is missing when started that way. The user object's
Display property names the graphical session, which is the one whose
authentications this agent should answer.
"""
try:
user_path = self.bus.call_sync(
"org.freedesktop.login1", "/org/freedesktop/login1",
"org.freedesktop.login1.Manager", "GetUser",
GLib.Variant("(u)", (os.getuid(),)), GLib.VariantType("(o)"),
Gio.DBusCallFlags.NONE, 5000, None).unpack()[0]
display = self.bus.call_sync(
"org.freedesktop.login1", user_path,
"org.freedesktop.DBus.Properties", "Get",
GLib.Variant("(ss)", ("org.freedesktop.login1.User", "Display")),
GLib.VariantType("(v)"), Gio.DBusCallFlags.NONE, 5000, None).unpack()[0]
# (session-id, object-path)
return str(display[0]) if display and display[0] else ""
except Exception: # noqa: BLE001 - no session is a real answer
return ""
def register(self) -> None:
node = Gio.DBusNodeInfo.new_for_xml(INTROSPECTION)
# The object MUST be exported on the same connection that registers the
# agent. polkitd calls BeginAuthentication back on the unique name that
# called RegisterAuthenticationAgent -- so exporting on the session bus
# while registering from the system bus leaves polkitd calling a path
# that does not exist, and every request fails as "Not authorized"
# without ever prompting.
self.bus.register_object(
AGENT_PATH, node.interfaces[0], self.on_call, None, None)
self.bus.call_sync(
AUTHORITY_NAME, AUTHORITY_PATH, AUTHORITY_INTERFACE,
"RegisterAuthenticationAgent",
GLib.Variant.new_tuple(self.subject(),
GLib.Variant("s", "en_US.UTF-8"),
GLib.Variant("s", AGENT_PATH)),
None, Gio.DBusCallFlags.NONE, 10000, None)
log("registered with polkitd")
def unregister(self) -> None:
try:
self.bus.call_sync(
AUTHORITY_NAME, AUTHORITY_PATH, AUTHORITY_INTERFACE,
"UnregisterAuthenticationAgent",
GLib.Variant.new_tuple(self.subject(), GLib.Variant("s", AGENT_PATH)),
None, Gio.DBusCallFlags.NONE, 5000, None)
log("unregistered")
except Exception: # noqa: BLE001 - shutting down either way
pass
# ── The agent interface ─────────────────────────────────────────────────
def on_call(self, connection, sender, path, interface, method, parameters, invocation):
if method == "BeginAuthentication":
action_id, message, icon_name, details, cookie, identities = parameters.unpack()
self.begin(action_id, message, icon_name, cookie, identities, invocation)
return
if method == "CancelAuthentication":
(cookie,) = parameters.unpack()
self.cancel(cookie)
invocation.return_value(None)
return
invocation.return_error_literal(Gio.dbus_error_quark(),
Gio.DBusError.UNKNOWN_METHOD, "Unknown method")
def usernames(self, identities: list) -> list[str]:
"""Who polkit will accept an answer from, as names a person recognizes."""
names = []
for kind, attributes in identities:
if kind != "unix-user":
continue
uid = attributes.get("uid")
if uid is None:
continue
try:
import pwd
names.append(pwd.getpwuid(int(uid)).pw_name)
except (KeyError, ValueError):
continue
return names
def begin(self, action_id, message, icon_name, cookie, identities, invocation) -> None:
names = self.usernames(identities)
if not names:
invocation.return_error_literal(
Gio.dbus_error_quark(), Gio.DBusError.FAILED,
"No user is allowed to authenticate this request.")
return
request_path = runtime_dir() / f"request-{os.getpid()}-{int(time.time() * 1000)}.json"
payload = {
"actionId": action_id,
"message": message,
"iconName": icon_name,
"cookie": cookie,
"users": names,
"preferred": os.environ.get("USER", names[0]),
}
# 0600 before anything is written to it, so the cookie is never briefly
# world-readable.
handle = os.open(request_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(handle, "w", encoding="utf-8") as stream:
json.dump(payload, stream)
self.pending[cookie] = request_path
response_path = request_path.with_suffix(".response")
if not self.ask_shell(request_path):
self.finish(cookie, request_path, response_path)
invocation.return_error_literal(
Gio.dbus_error_quark(), Gio.DBusError.FAILED,
"The desktop could not show an authentication prompt.")
return
# Poll for the shell's answer rather than blocking the main loop, so a
# Cancel from polkit is still processed while a prompt is open.
started = time.monotonic()
def check() -> bool:
if cookie not in self.pending:
# Cancelled from the polkit side.
self.finish(cookie, request_path, response_path)
invocation.return_error_literal(
Gio.dbus_error_quark(), Gio.DBusError.FAILED, "Cancelled.")
return False
if response_path.exists():
try:
answer = json.loads(response_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
answer = {"result": "failed"}
self.finish(cookie, request_path, response_path)
if answer.get("result") == "ok":
# The helper has already told polkitd. Returning normally is
# what completes the request.
invocation.return_value(None)
else:
invocation.return_error_literal(
Gio.dbus_error_quark(), Gio.DBusError.FAILED,
"Authentication was not completed.")
return False
if time.monotonic() - started > PROMPT_TIMEOUT_SECONDS:
self.finish(cookie, request_path, response_path)
invocation.return_error_literal(
Gio.dbus_error_quark(), Gio.DBusError.FAILED, "Timed out.")
return False
return True
GLib.timeout_add(150, check)
def cancel(self, cookie: str) -> None:
request_path = self.pending.pop(cookie, None)
if request_path is None:
return
# A marker rather than a deletion: the shell watches for this to close a
# prompt that polkit no longer wants an answer to.
try:
request_path.with_suffix(".cancelled").touch(mode=0o600)
except OSError:
pass
def finish(self, cookie: str, request_path: Path, response_path: Path) -> None:
self.pending.pop(cookie, None)
for path in (request_path, response_path, request_path.with_suffix(".cancelled")):
try:
path.unlink()
except OSError:
pass
def ask_shell(self, request_path: Path) -> bool:
"""Tell the shell to prompt. Only the path travels, never the contents."""
try:
result = subprocess.run(
["qs", "ipc", "call", "polkit", "begin", str(request_path)],
capture_output=True, text=True, timeout=15, check=False)
except (OSError, subprocess.TimeoutExpired):
return False
if result.returncode != 0:
log(f"the shell refused the prompt: {result.stderr.strip()[:120]}")
return False
return True
def run(self) -> int:
self.register()
for received in (signal.SIGINT, signal.SIGTERM):
GLib.unix_signal_add(GLib.PRIORITY_DEFAULT, received, self.stop)
try:
self.loop.run()
finally:
self.unregister()
return 0
def stop(self) -> bool:
self.loop.quit()
return False
def main() -> int:
try:
return Agent().run()
except Exception as error: # noqa: BLE001
log(f"could not start: {error}")
return 1
if __name__ == "__main__":
raise SystemExit(main())
+286
View File
@@ -0,0 +1,286 @@
#!/usr/bin/env python3
"""Printers, through CUPS' own API rather than by parsing lpstat.
Driverless only, on purpose. This adds printers that describe their own
capabilities over IPP -- IPP Everywhere, which is every printer sold in roughly
the last decade -- and does not choose PPDs or download vendor drivers. Driver
selection is most of what the panel this replaces does, and getting it wrong
produces a printer that accepts jobs and silently prints nothing. A printer old
enough to need a PPD is better served by system-config-printer, and the page
says so rather than pretending.
panama-printers snapshot
panama-printers discover
panama-printers add URI NAME
panama-printers remove NAME
panama-printers set-default NAME
panama-printers pause NAME | resume NAME
panama-printers cancel JOB_ID
panama-printers test-page NAME
"""
from __future__ import annotations
import json
import re
import shutil
import subprocess
import sys
# What may be printed to. Everything else -- file:, pipe:, a shell fragment --
# is refused: this validates the URI rather than trusting a settings page,
# because a device URI is handed to a backend that runs as root.
SAFE_SCHEMES = ("ipp", "ipps", "socket", "dnssd", "http", "https")
URI = re.compile(r"^(%s)://[A-Za-z0-9._~:/?#\[\]@!$&'()*+,;=%%-]+$" % "|".join(SAFE_SCHEMES))
# CUPS queue names: no spaces, slashes, or '#'.
QUEUE = re.compile(r"^[A-Za-z0-9_.-]{1,127}$")
# The only model this adds. Naming it in one place means the driverless promise
# is checkable rather than scattered.
DRIVERLESS_MODEL = "everywhere"
class BoundaryError(RuntimeError):
"""A user-visible validation or CUPS failure."""
def connect():
try:
import cups
return cups, cups.Connection()
except Exception as error: # noqa: BLE001 - no cups is a legitimate state
raise BoundaryError("The printing service is not answering.") from error
def run(command: list[str], timeout: float = 30.0) -> subprocess.CompletedProcess:
try:
return subprocess.run(command, capture_output=True, text=True,
timeout=timeout, check=False)
except (OSError, subprocess.TimeoutExpired) as error:
raise BoundaryError(f"{command[0]} did not answer.") from error
def service_state() -> dict:
active = run(["systemctl", "is-active", "cups.service"]).stdout.strip()
enabled = run(["systemctl", "is-enabled", "cups.service"]).stdout.strip()
avahi = run(["systemctl", "is-active", "avahi-daemon.service"]).stdout.strip()
return {
"running": active == "active",
# "on demand" is a real answer and a reasonable configuration, not a
# problem to report: socket activation starts CUPS when something prints.
"startsAtBoot": enabled == "enabled",
"startMode": enabled or "unknown",
"discoveryAvailable": avahi == "active",
}
def printer_state(attributes: dict) -> str:
"""CUPS reports state as a number; people read words."""
return {3: "idle", 4: "printing", 5: "stopped"}.get(
int(attributes.get("printer-state", 0)), "unknown")
def snapshot() -> dict:
cups, connection = connect()
try:
raw = connection.getPrinters()
default = connection.getDefault()
jobs = connection.getJobs(which_jobs="not-completed", my_jobs=False,
requested_attributes=[
"job-id", "job-name", "job-printer-uri",
"job-state", "job-originating-user-name",
"job-impressions", "time-at-creation"])
except Exception as error: # noqa: BLE001
raise BoundaryError("The printing service could not be read.") from error
printers = []
for name, attributes in raw.items():
printers.append({
"name": name,
"description": str(attributes.get("printer-info") or name),
"location": str(attributes.get("printer-location") or ""),
"makeAndModel": str(attributes.get("printer-make-and-model") or ""),
"uri": str(attributes.get("device-uri") or ""),
"state": printer_state(attributes),
"stateMessage": str(attributes.get("printer-state-message") or ""),
"accepting": bool(attributes.get("printer-is-accepting-jobs", True)),
"shared": bool(attributes.get("printer-is-shared", False)),
"isDefault": name == default,
})
printers.sort(key=lambda entry: (not entry["isDefault"], entry["name"].lower()))
queue = []
for job in jobs.values() if isinstance(jobs, dict) else []:
printer_uri = str(job.get("job-printer-uri") or "")
queue.append({
"id": int(job.get("job-id") or 0),
"name": str(job.get("job-name") or "Untitled"),
"printer": printer_uri.rsplit("/", 1)[-1] if printer_uri else "",
"state": {3: "pending", 4: "held", 5: "processing",
6: "stopped", 7: "cancelled"}.get(int(job.get("job-state", 0)), "unknown"),
"user": str(job.get("job-originating-user-name") or ""),
"pages": int(job.get("job-impressions") or 0),
"createdAt": int(job.get("time-at-creation") or 0),
})
queue.sort(key=lambda entry: entry["createdAt"])
return {"printers": printers, "jobs": queue, "service": service_state(), "error": ""}
def discover() -> dict:
"""Printers announcing themselves on the network, driverless ones only.
A printer that does not advertise IPP Everywhere is reported as found but
not addable, rather than offered and then failing at the point of adding.
"""
if not shutil.which("avahi-browse"):
return {"found": [], "error": "Network discovery is not available."}
found: dict[str, dict] = {}
for service in ("_ipps._tcp", "_ipp._tcp"):
result = run(["avahi-browse", "-rtp", service], timeout=20)
for line in result.stdout.splitlines():
if not line.startswith("="):
continue
parts = line.split(";")
if len(parts) < 10:
continue
name, host, port, text = parts[3], parts[6], parts[8], parts[9]
scheme = "ipps" if service == "_ipps._tcp" else "ipp"
resource = ""
for field in re.findall(r"\"([^\"]*)\"", text):
if field.startswith("rp="):
resource = field[3:]
# Only IPP Everywhere. The attribute a driverless printer publishes
# is its PDF/JPEG support; without it, CUPS would need a driver.
driverless = "application/pdf" in text or "URF=" in text or "urf=" in text
uri = f"{scheme}://{host}:{port}/{resource}" if resource else f"{scheme}://{host}:{port}/"
found[uri] = {
"name": name.replace("\\032", " "),
"uri": uri,
"host": host,
"driverless": driverless,
}
return {"found": sorted(found.values(), key=lambda entry: entry["name"]), "error": ""}
def require_queue(name: str) -> str:
if not QUEUE.fullmatch(name or ""):
raise BoundaryError("That is not a printer name.")
return name
def add(uri: str, name: str) -> None:
if not URI.fullmatch(uri or ""):
raise BoundaryError("That address cannot be used to reach a printer.")
require_queue(name)
cups, connection = connect()
try:
# ppdname is the driverless model and nothing else. There is no branch
# here that selects a PPD, which is what keeps the promise checkable.
connection.addPrinter(name, device=uri, ppdname=DRIVERLESS_MODEL)
connection.enablePrinter(name)
connection.acceptJobs(name)
except Exception as error: # noqa: BLE001
message = str(error)
if "device-error" in message or "1284" in message:
raise BoundaryError(
"The printer did not answer, or does not support driverless printing.") from error
if "not-authorized" in message or "forbidden" in message.lower():
raise BoundaryError("Adding a printer was not authorized.") from error
raise BoundaryError("That printer could not be added.") from error
def remove(name: str) -> None:
require_queue(name)
cups, connection = connect()
try:
connection.deletePrinter(name)
except Exception as error: # noqa: BLE001
raise BoundaryError("That printer could not be removed.") from error
def set_default(name: str) -> None:
require_queue(name)
cups, connection = connect()
try:
connection.setDefault(name)
except Exception as error: # noqa: BLE001
raise BoundaryError("That printer could not be made the default.") from error
def set_paused(name: str, paused: bool) -> None:
require_queue(name)
cups, connection = connect()
try:
if paused:
connection.disablePrinter(name)
else:
connection.enablePrinter(name)
except Exception as error: # noqa: BLE001
raise BoundaryError("That printer could not be changed.") from error
def cancel(job_id: str) -> None:
if not job_id.isdigit():
raise BoundaryError("That is not a job.")
cups, connection = connect()
try:
connection.cancelJob(int(job_id))
except Exception as error: # noqa: BLE001
raise BoundaryError("That job could not be cancelled.") from error
def test_page(name: str) -> None:
require_queue(name)
result = run(["lp", "-d", name, "/usr/share/cups/data/testprint"], timeout=30)
if result.returncode != 0:
raise BoundaryError((result.stderr.strip() or "The test page could not be sent.")[:200])
def main(arguments: list[str]) -> int:
try:
if arguments == ["snapshot"]:
print(json.dumps(snapshot(), separators=(",", ":")))
return 0
if arguments == ["discover"]:
print(json.dumps(discover(), separators=(",", ":")))
return 0
if len(arguments) == 3 and arguments[0] == "add":
add(arguments[1], arguments[2])
elif len(arguments) == 2 and arguments[0] == "remove":
remove(arguments[1])
elif len(arguments) == 2 and arguments[0] == "set-default":
set_default(arguments[1])
elif len(arguments) == 2 and arguments[0] == "pause":
set_paused(arguments[1], True)
elif len(arguments) == 2 and arguments[0] == "resume":
set_paused(arguments[1], False)
elif len(arguments) == 2 and arguments[0] == "cancel":
cancel(arguments[1])
elif len(arguments) == 2 and arguments[0] == "test-page":
test_page(arguments[1])
else:
raise BoundaryError(
"Usage: panama-printers snapshot | discover | add URI NAME | remove NAME | "
"set-default NAME | pause NAME | resume NAME | cancel JOB_ID | test-page NAME")
except BoundaryError as error:
try:
state = snapshot()
except BoundaryError:
state = {"printers": [], "jobs": [], "service": service_state()}
state["error"] = str(error)
print(json.dumps(state, separators=(",", ":")))
return 0
print(json.dumps(snapshot(), separators=(",", ":")))
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
+203
View File
@@ -0,0 +1,203 @@
#!/usr/bin/env python3
"""Generate one launcher command per settings page.
Settings has a search index and the launcher has script commands, but they did
not know about each other: finding a setting meant opening Settings first and
searching there. These commands close that gap, so typing "night light" into the
launcher opens the page that owns it.
The vocabulary is derived from the same sources the in-app search uses --
the page list in SettingsSidebar.qml, the group routing in SettingsSearch.qml,
and the labels in PreferenceSchema.qml -- so a setting that is searchable inside
Settings is searchable from the launcher without anyone maintaining a second
list.
panama-settings-commands write the commands
panama-settings-commands --check fail if what is on disk is stale
Run it after adding a settings page or renaming a setting.
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
REPO = Path(__file__).resolve().parents[4]
SHELL = REPO / "config/dot/quickshell"
SIDEBAR = SHELL / "modules/settings/SettingsSidebar.qml"
SEARCH = SHELL / "services/SettingsSearch.qml"
SCHEMA = SHELL / "config/PreferenceSchema.qml"
OUTPUT_DIR = REPO / "config/local/share/vicinae/scripts"
# Home is what the plain "Open Settings" command already lands on, so a second
# command for it would be a duplicate under a different name.
SKIP_PAGES = {"home"}
# Enough vocabulary to find the page, not so much that one page matches
# everything. Ordered by the schema, so the settings at the top of a page --
# the ones it is named for -- are the ones that survive the cut.
MAX_KEYWORDS = 12
GENERATED_MARKER = "# Generated by scripts/panama-settings-commands -- do not edit by hand."
class ParseError(RuntimeError):
"""A source file did not look the way this generator expects."""
def read(path: Path) -> str:
try:
return path.read_text(encoding="utf-8")
except OSError as error:
raise ParseError(f"could not read {path}") from error
def pages() -> list[tuple[str, str]]:
"""The settings pages, in sidebar order, as (id, label)."""
source = read(SIDEBAR)
found = re.findall(r'\{ page: "([a-z-]+)", label: "([^"]+)"', source)
if not found:
raise ParseError("no pages found in SettingsSidebar.qml")
return [(page, label) for page, label in found if page not in SKIP_PAGES]
def group_pages() -> dict[str, str]:
source = read(SEARCH)
table = re.search(r"readonly property var groupPages: \(\{(.*?)\n \}\)", source, re.S)
if not table:
raise ParseError("groupPages not found in SettingsSearch.qml")
return dict(re.findall(r'"([A-Za-z]+)":\s*"([a-z-]+)"', table.group(1)))
def extra_labels() -> dict[str, list[str]]:
"""Settings the system owns rather than Panama, which have no schema entry."""
source = read(SEARCH)
table = re.search(r"readonly property var extraEntries: \[(.*?)\n \]", source, re.S)
if not table:
return {}
labels: dict[str, list[str]] = {}
for label, page in re.findall(r'\{ label: "([^"]+)".*?page: "([a-z-]+)" \}', table.group(1)):
labels.setdefault(page, []).append(label)
return labels
def schema_labels() -> list[tuple[str, str]]:
"""(group, label) for every user-facing setting, in schema order."""
source = read(SCHEMA)
entries: list[tuple[str, str]] = []
chunks = source.split("key: ")
for chunk in chunks[1:]:
# Internal state is not a setting anyone searches for.
if re.search(r"internal:\s*true", chunk[:600]):
continue
group = re.search(r'group:\s*"([A-Za-z]+)"', chunk[:600])
label = re.search(r'label:\s*"([^"]+)"', chunk[:600])
if group and label:
entries.append((group.group(1), label.group(1)))
if not entries:
raise ParseError("no labelled settings found in PreferenceSchema.qml")
return entries
def keywords_for(page: str, routing: dict[str, str], schema: list[tuple[str, str]],
extras: dict[str, list[str]]) -> list[str]:
words: list[str] = []
for group, label in schema:
if routing.get(group) == page:
lowered = label.lower()
if lowered not in words:
words.append(lowered)
for label in extras.get(page, []):
lowered = label.lower()
if lowered not in words:
words.append(lowered)
return words[:MAX_KEYWORDS]
def command_for(page: str, label: str, words: list[str]) -> str:
"""One launcher command, titled "Settings: <page>".
The qualifier is not product branding -- that was deliberately dropped from
every command title. It is here because a bare page label collides with the
feature of the same name: "Screen Intelligence" is both a thing you open and
a page of settings about it, and two commands sharing one title are
indistinguishable in a launcher. Qualifying the page also groups all of them
under one word, which is what typing "settings" is for.
"""
# "settings" is always a keyword so typing it lists every page at once.
vocabulary = ["settings"] + [word for word in words if word != "settings"]
keywords = ", ".join(f'"{word}"' for word in vocabulary)
return f"""#!/usr/bin/env bash
{GENERATED_MARKER}
# @vicinae.schemaVersion 1
# @vicinae.title Settings: {label}
# @vicinae.mode silent
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
# @vicinae.description Open {label} in Settings.
# @vicinae.keywords [{keywords}]
exec "$HOME/.config/quickshell/scripts/panama-action" settings-page {page}
"""
def build() -> dict[Path, str]:
routing = group_pages()
schema = schema_labels()
extras = extra_labels()
files: dict[Path, str] = {}
for page, label in pages():
words = keywords_for(page, routing, schema, extras)
files[OUTPUT_DIR / f"settings-{page}.sh"] = command_for(page, label, words)
return files
def existing() -> set[Path]:
return {path for path in OUTPUT_DIR.glob("settings-*.sh")}
def main(arguments: list[str]) -> int:
check = arguments == ["--check"]
if arguments and not check:
print(__doc__, file=sys.stderr)
return 2
try:
files = build()
except ParseError as error:
# Loud and empty-handed: writing a partial set would silently drop the
# pages whose source stopped parsing.
print(f"panama-settings-commands: {error}", file=sys.stderr)
return 2
stale = [path for path, body in files.items()
if not path.is_file() or path.read_text(encoding="utf-8") != body]
orphaned = sorted(existing() - set(files))
if check:
for path in stale:
print(f"stale: {path.name}", file=sys.stderr)
for path in orphaned:
print(f"orphaned: {path.name}", file=sys.stderr)
if stale or orphaned:
print("panama-settings-commands: run it without --check to regenerate",
file=sys.stderr)
return 1
print(f"panama-settings-commands: {len(files)} commands are current")
return 0
for path in orphaned:
path.unlink()
print(f"removed {path.name}")
for path, body in files.items():
path.write_text(body, encoding="utf-8")
path.chmod(0o755)
print(f"panama-settings-commands: wrote {len(files)} commands"
+ (f", removed {len(orphaned)}" if orphaned else ""))
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
+312
View File
@@ -0,0 +1,312 @@
#!/usr/bin/env python3
"""Carrying Panama's settings to another machine.
Different from panama-settings-backup, which snapshots this machine so it can be
put back exactly as it was, arrangement and all. This produces something meant
to travel: the preferences that describe taste rather than hardware.
Two decisions shape the whole thing.
The export is an ALLOW-LIST taken from the preference schema, not a deny-list of
things to strip. Anything the schema does not declare is dropped, so a key added
later that happens to hold a token cannot leak into a file somebody emails to
themselves. Being wrong in this direction loses a setting; being wrong the other
way publishes a secret.
The import validates every value against the schema again on arrival and skips
what does not fit, one key at a time, with a reason. A file from an older Panama
is a normal thing to have, and refusing it wholesale because one key changed
shape would make the feature useless exactly when it is most wanted.
panama-settings-sync export PATH
panama-settings-sync preview PATH
panama-settings-sync import PATH
"""
from __future__ import annotations
import json
import os
import re
import socket
import sys
import time
from pathlib import Path
HOME = Path(os.environ.get("HOME", str(Path.home())))
CONFIG_ROOT = Path(os.environ.get("XDG_CONFIG_HOME", str(HOME / ".config")))
SETTINGS = CONFIG_ROOT / "panama/settings.json"
SCHEMA = Path(__file__).resolve().parents[1] / "config" / "PreferenceSchema.qml"
FORMAT = "panama-settings-sync/1"
# Settings that describe this machine rather than how it should behave. Held
# separately from the allow-list because each needs a reason, and a reason is
# what stops the list growing by habit.
MACHINE_SPECIFIC = {
# Monitor arrangement, keyed by output names that mean nothing elsewhere.
"displays": "describes this machine's monitors",
# Which settings page was last open. Session noise.
"lastPage": "is where you happened to be looking",
# Governs migration of the store itself; importing one would mislabel it.
"schemaVersion": "belongs to the store, not to you",
}
# Settings that travel but may not land: an absolute path is only a setting on a
# machine where the file exists. Carried, then checked on arrival.
PATH_VALUED = {"wallpaperPath", "wallpaperSlideshowPaths"}
class BoundaryError(RuntimeError):
"""A user-visible validation or file failure."""
def schema() -> dict[str, dict]:
"""Every declared preference, by key.
Parsed from the schema rather than kept as a second list here, so a setting
added there is exportable without anyone remembering to update this.
"""
try:
source = SCHEMA.read_text(encoding="utf-8")
except OSError as error:
raise BoundaryError("The preference schema could not be read.") from error
entries: dict[str, dict] = {}
for chunk in source.split("key: ")[1:]:
head = chunk[:1600]
key = re.match(r'"([A-Za-z0-9_]+)"', chunk)
kind = re.search(r'type:\s*"([a-z]+)"', head)
if not (key and kind):
continue
entry = {"key": key.group(1), "type": kind.group(1)}
for bound in ("min", "max"):
found = re.search(rf"\b{bound}:\s*(-?[0-9.]+)", head)
if found:
entry[bound] = float(found.group(1))
pattern = re.search(r'pattern:\s*"((?:[^"\\]|\\.)*)"', head)
if pattern:
entry["pattern"] = pattern.group(1).replace("\\\\", "\\")
# Option values are quoted for a word and bare for a number -- vrrPolicy
# is an enum of 0..3. Capturing only the quoted form left numeric enums
# with no choices at all, which then read as unverifiable and were
# refused: a valid setting dropped on the way in.
options = [quoted if quoted else bare for quoted, bare
in re.findall(r'value:\s*(?:"([^"]*)"|(-?[0-9]+(?:\.[0-9]+)?))', head)]
if options:
entry["options"] = options
entries[entry["key"]] = entry
if not entries:
raise BoundaryError("The preference schema yielded no settings.")
return entries
def stored() -> dict:
if not SETTINGS.is_file():
return {}
try:
value = json.loads(SETTINGS.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as error:
raise BoundaryError("This machine's settings could not be read.") from error
return value if isinstance(value, dict) else {}
def fits(entry: dict, value) -> str:
""""" if the value is usable for this setting, else why it is not."""
kind = entry["type"]
if kind == "bool":
return "" if isinstance(value, bool) else "is not a yes or no"
if kind == "enum":
# Compared as text because an enum is words in some settings and numbers
# in others, and JSON gives back 3 where the schema wrote 3.
if isinstance(value, bool) or not isinstance(value, (str, int, float)):
return "is not one of this setting's choices"
value = str(value)
choices = entry.get("options") or []
# A schema entry whose choices could not be read is not grounds to
# accept anything: an unrecognised value would be written straight into
# the store and break whatever reads it.
if not choices:
return "cannot be checked against this setting's choices"
return "" if value in choices else "is not one of this setting's choices"
# "real" is what the schema calls a float. Spelling it "float" here meant
# fifteen settings were accepted without their range being checked at all.
if kind in ("int", "real", "float"):
if isinstance(value, bool) or not isinstance(value, (int, float)):
return "is not a number"
if "min" in entry and value < entry["min"]:
return f"is below the minimum of {entry['min']:g}"
if "max" in entry and value > entry["max"]:
return f"is above the maximum of {entry['max']:g}"
return ""
if kind == "string":
if not isinstance(value, str):
return "is not text"
if "options" in entry and entry["options"] and value not in entry["options"]:
return "is not one of the choices this setting allows"
if "pattern" in entry:
try:
if not re.match(entry["pattern"], value):
return "does not match the form this setting takes"
except re.error:
return ""
return ""
if kind == "json":
return "" if isinstance(value, (list, dict)) else "is not a list or an object"
return ""
def exportable() -> tuple[dict, list[dict]]:
known = schema()
current = stored()
carried: dict = {}
left: list[dict] = []
for key, value in sorted(current.items()):
if key in MACHINE_SPECIFIC:
left.append({"key": key, "reason": MACHINE_SPECIFIC[key]})
continue
if key not in known:
left.append({"key": key, "reason": "is not a setting this version declares"})
continue
problem = fits(known[key], value)
if problem:
left.append({"key": key, "reason": "holds a value that " + problem})
continue
carried[key] = value
return carried, left
def export(path: str) -> dict:
carried, left = exportable()
bundle = {
"format": FORMAT,
"exportedAt": int(time.time()),
"exportedFrom": socket.gethostname(),
"settings": carried,
}
target = Path(path).expanduser()
try:
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(json.dumps(bundle, indent=2, sort_keys=True) + "\n",
encoding="utf-8")
# Readable only by its owner: it is not secret, but it is a description
# of somebody's machine and there is no reason to hand it around.
target.chmod(0o600)
except OSError as error:
raise BoundaryError("That file could not be written.") from error
return {"path": str(target), "carried": len(carried), "left": left}
def read_bundle(path: str) -> dict:
source = Path(path).expanduser()
try:
bundle = json.loads(source.read_text(encoding="utf-8"))
except FileNotFoundError as error:
raise BoundaryError("That file does not exist.") from error
except (OSError, json.JSONDecodeError) as error:
raise BoundaryError("That file is not a settings export.") from error
if not isinstance(bundle, dict) or not str(bundle.get("format", "")).startswith("panama-settings-sync/"):
raise BoundaryError("That file is not a settings export.")
if not isinstance(bundle.get("settings"), dict):
raise BoundaryError("That export contains no settings.")
return bundle
def plan(path: str) -> dict:
"""What an import would do, without doing any of it."""
bundle = read_bundle(path)
known = schema()
current = stored()
apply: dict = {}
changes: list[dict] = []
skipped: list[dict] = []
for key, value in sorted(bundle["settings"].items()):
if key in MACHINE_SPECIFIC:
skipped.append({"key": key, "reason": MACHINE_SPECIFIC[key]})
continue
if key not in known:
skipped.append({"key": key, "reason": "is not a setting this version has"})
continue
problem = fits(known[key], value)
if problem:
skipped.append({"key": key, "reason": "the value " + problem})
continue
if key in PATH_VALUED:
missing = [p for p in (value if isinstance(value, list) else [value])
if isinstance(p, str) and p and not Path(p).expanduser().exists()]
if missing:
skipped.append({"key": key,
"reason": "points at a file this machine does not have"})
continue
if current.get(key) == value:
continue
apply[key] = value
changes.append({"key": key,
"from": current.get(key, None),
"to": value})
return {
"path": str(Path(path).expanduser()),
"exportedFrom": str(bundle.get("exportedFrom", "")),
"exportedAt": int(bundle.get("exportedAt", 0)),
"changes": changes,
"skipped": skipped,
"apply": apply,
}
def apply_import(path: str) -> dict:
"""Merge an export into this machine's settings.
Written whole through a temporary file and a rename, so a crash midway
leaves the old settings intact rather than half of each. Settings not named
by the export are untouched: this is a merge, not a replacement, because an
export from a machine that never changed a setting should not reset it here.
"""
prepared = plan(path)
if not prepared["apply"]:
return {**prepared, "applied": 0}
current = stored()
current.update(prepared["apply"])
try:
SETTINGS.parent.mkdir(parents=True, exist_ok=True)
temporary = SETTINGS.with_suffix(".sync-tmp")
temporary.write_text(json.dumps(current, indent=2, sort_keys=True) + "\n",
encoding="utf-8")
os.replace(temporary, SETTINGS)
except OSError as error:
raise BoundaryError("This machine's settings could not be written.") from error
return {**prepared, "applied": len(prepared["apply"])}
def main(arguments: list[str]) -> int:
try:
if len(arguments) == 2 and arguments[0] == "export":
result = export(arguments[1])
elif len(arguments) == 2 and arguments[0] == "preview":
result = plan(arguments[1])
elif len(arguments) == 2 and arguments[0] == "import":
result = apply_import(arguments[1])
else:
raise BoundaryError(
"Usage: panama-settings-sync export PATH | preview PATH | import PATH")
except BoundaryError as error:
print(json.dumps({"error": str(error)}, separators=(",", ":")))
return 0
result["error"] = ""
print(json.dumps(result, separators=(",", ":")))
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
+278
View File
@@ -0,0 +1,278 @@
#!/usr/bin/env python3
"""What this machine offers to other machines, and switches for it.
Every row reports what is actually true, including "the software for this is not
installed". GNOME's Sharing panel shows switches for services that are absent,
which is how a switch ends up doing nothing at all.
Enabling remote login is a system-wide change and goes through pkexec, which
prompts with the polkit agent this desktop already runs. Remote desktop is a
user service and needs no privilege.
panama-sharing snapshot
panama-sharing set-remote-login true|false
panama-sharing set-remote-desktop true|false
panama-sharing set-hostname NAME
"""
from __future__ import annotations
import json
import re
import shutil
import subprocess
import sys
from pathlib import Path
HOSTNAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9-]{0,62}$")
class BoundaryError(RuntimeError):
"""A user-visible validation or permission failure."""
def run(command: list[str], timeout: float = 25.0) -> subprocess.CompletedProcess:
try:
return subprocess.run(command, capture_output=True, text=True,
timeout=timeout, check=False)
except (OSError, subprocess.TimeoutExpired) as error:
raise BoundaryError(f"{command[0]} did not answer.") from error
def unit_state(unit: str, user: bool = False) -> dict:
scope = ["--user"] if user else []
active = run(["systemctl", *scope, "is-active", unit]).stdout.strip()
enabled = run(["systemctl", *scope, "is-enabled", unit]).stdout.strip()
return {
"installed": enabled not in ("", "not-found"),
"active": active == "active",
"enabled": enabled == "enabled",
}
def ssh_setting(name: str) -> str:
"""What sshd's own configuration says, or "" when it says nothing.
`sshd -T` would be authoritative but needs root. Reading the files means
reporting "not configured" rather than guessing a default -- which matters,
because claiming "keys only" on a machine that actually accepts passwords
would be a security claim this cannot back up.
"""
paths = [Path("/etc/ssh/sshd_config")]
paths.extend(sorted(Path("/etc/ssh/sshd_config.d").glob("*.conf"))
if Path("/etc/ssh/sshd_config.d").is_dir() else [])
pattern = re.compile(rf"^\s*{name}\s+(\S+)", re.IGNORECASE)
for path in paths:
try:
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
found = pattern.match(line)
if found:
return found.group(1)
except OSError:
continue
return ""
def remote_desktop() -> dict:
state = unit_state("gnome-remote-desktop.service", user=True)
state["available"] = bool(shutil.which("grdctl"))
state["rdpEnabled"] = False
state["port"] = ""
state["hasCredentials"] = False
state["viewOnly"] = False
if not state["available"]:
return state
status = run(["grdctl", "status"]).stdout
section = status.split("RDP:", 1)
if len(section) > 1:
block = section[1].split("VNC:", 1)[0]
state["rdpEnabled"] = re.search(r"Status:\s*enabled", block) is not None
port = re.search(r"Port:\s*(\d+)", block)
state["port"] = port.group(1) if port else ""
# grdctl prints "(hidden)" when a credential is stored and nothing when
# it is not, so this reads presence without ever reading the value.
state["hasCredentials"] = "(hidden)" in block
state["viewOnly"] = re.search(r"View-only:\s*yes", block) is not None
return state
def active_logins() -> list[dict[str, str]]:
"""Who is signed in from another machine right now.
Read from `who`, which names the user, when they arrived, and where from.
Only sessions with an origin are reported: a local seat has none, and
listing the person sitting at the keyboard as a remote login would be
alarming and wrong.
"""
result = run(["who"])
if result.returncode != 0:
return []
sessions: list[dict[str, str]] = []
for line in result.stdout.splitlines():
match = re.match(r"^(\S+)\s+(\S+)\s+(.+?)\s+\((.+)\)\s*$", line)
if not match:
continue
user, line_name, when, origin = match.groups()
# X displays appear in the same parenthesised field as a hostname.
if origin.startswith(":") or origin in ("localhost", ""):
continue
sessions.append({
"user": user,
"line": line_name,
"since": when.strip(),
"from": origin,
})
return sessions
def media_sharing() -> dict:
"""Rygel, which serves media to devices on the network over DLNA.
Reported as a running state rather than just "installed", because installed
and off is the normal case and is not the same thing as sharing. Turning it
on publishes media directories to every device on the network, which is why
the page says so next to the switch.
"""
if not shutil.which("rygel"):
return {"installed": False, "active": False, "enabled": False, "package": "rygel"}
state = unit_state("rygel.service", user=True)
state["installed"] = True
state["package"] = "rygel"
return state
def set_media_sharing(enabled: bool) -> None:
if not shutil.which("rygel"):
raise BoundaryError("Rygel is not installed.")
verb = "enable" if enabled else "disable"
result = run(["systemctl", "--user", verb, "--now", "rygel.service"])
if result.returncode != 0:
detail = (result.stderr or "").strip().splitlines()
raise BoundaryError(detail[-1] if detail else "Media sharing could not be changed.")
def snapshot() -> dict:
static_name = run(["hostnamectl", "--static"]).stdout.strip()
pretty_name = run(["hostnamectl", "--pretty"]).stdout.strip()
login = unit_state("sshd.service")
login["port"] = ssh_setting("Port") or "22"
login["passwordAuthentication"] = ssh_setting("PasswordAuthentication")
login["rootLogin"] = ssh_setting("PermitRootLogin")
login["sessions"] = active_logins()
return {
"hostname": static_name,
"prettyHostname": pretty_name,
"remoteLogin": login,
"remoteDesktop": remote_desktop(),
# Reported as absent rather than offered as a switch that would do
# nothing. Installing software is not this page's job.
"fileSharing": {"installed": bool(shutil.which("smbd")), "package": "samba"},
"mediaSharing": media_sharing(),
"error": "",
}
def set_remote_login(enabled: bool) -> None:
if not unit_state("sshd.service")["installed"]:
raise BoundaryError("OpenSSH server is not installed.")
action = ["enable", "--now"] if enabled else ["disable", "--now"]
result = run(["pkexec", "systemctl", *action, "sshd.service"], timeout=120)
if result.returncode != 0:
raise BoundaryError(_refusal(result, "Remote login could not be changed."))
def set_remote_desktop(enabled: bool) -> None:
state = remote_desktop()
if not state["available"]:
raise BoundaryError("Remote desktop support is not installed.")
if enabled and not state["hasCredentials"]:
raise BoundaryError("Set a remote desktop username and password first.")
toggle = run(["grdctl", "rdp", "enable" if enabled else "disable"])
if toggle.returncode != 0:
raise BoundaryError(_refusal(toggle, "Remote desktop could not be changed."))
action = ["enable", "--now"] if enabled else ["disable", "--now"]
result = run(["systemctl", "--user", *action, "gnome-remote-desktop.service"], timeout=60)
if result.returncode != 0:
raise BoundaryError(_refusal(result, "The remote desktop service could not be changed."))
def set_rdp_port(port: str) -> None:
if not port.isdigit() or not (1 <= int(port) <= 65535):
raise BoundaryError("That is not a port number.")
result = run(["grdctl", "rdp", "set-port", port])
if result.returncode != 0:
raise BoundaryError(_refusal(result, "The port could not be changed."))
def set_rdp_view_only(view_only: bool) -> None:
result = run(["grdctl", "rdp",
"enable-view-only" if view_only else "disable-view-only"])
if result.returncode != 0:
raise BoundaryError(_refusal(result, "That could not be changed."))
def clear_rdp_credentials() -> None:
result = run(["grdctl", "rdp", "clear-credentials"])
if result.returncode != 0:
raise BoundaryError(_refusal(result, "The credentials could not be cleared."))
def set_hostname(name: str) -> None:
if not HOSTNAME.fullmatch(name or ""):
raise BoundaryError("A name may use letters, digits and hyphens.")
result = run(["hostnamectl", "set-hostname", name], timeout=60)
if result.returncode != 0:
raise BoundaryError(_refusal(result, "The name could not be changed."))
def _refusal(result: subprocess.CompletedProcess, fallback: str) -> str:
text = (result.stderr or "").strip().splitlines()
if text and ("not authorized" in text[-1].lower() or "dismissed" in text[-1].lower()):
return "That change was not authorized."
return text[-1][:200] if text else fallback
def main(arguments: list[str]) -> int:
try:
if arguments == ["snapshot"]:
print(json.dumps(snapshot(), separators=(",", ":")))
return 0
if len(arguments) == 2 and arguments[0] == "set-media-sharing":
set_media_sharing(arguments[1] == "true")
elif len(arguments) == 2 and arguments[0] == "set-remote-login":
set_remote_login(arguments[1] == "true")
elif len(arguments) == 2 and arguments[0] == "set-remote-desktop":
set_remote_desktop(arguments[1] == "true")
elif len(arguments) == 2 and arguments[0] == "set-hostname":
set_hostname(arguments[1])
elif len(arguments) == 2 and arguments[0] == "set-rdp-port":
set_rdp_port(arguments[1])
elif len(arguments) == 2 and arguments[0] == "set-rdp-view-only":
set_rdp_view_only(arguments[1] == "true")
elif arguments == ["clear-rdp-credentials"]:
clear_rdp_credentials()
else:
raise BoundaryError(
"Usage: panama-sharing snapshot | set-remote-login true|false | "
"set-remote-desktop true|false | set-hostname NAME | "
"set-rdp-port PORT | set-rdp-view-only true|false | "
"clear-rdp-credentials")
except BoundaryError as error:
state = snapshot()
state["error"] = str(error)
print(json.dumps(state, separators=(",", ":")))
return 0
print(json.dumps(snapshot(), separators=(",", ":")))
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
+384
View File
@@ -0,0 +1,384 @@
#!/usr/bin/env python3
"""Snapshots, through snapper.
A point in time you can go back to, per btrfs subvolume. This machine already
had snapper running hourly, but only for / -- /home is a separate subvolume and
had no configuration at all, so six hundred snapshots existed and not one of
them contained a document.
Deliberately absent: rollback. snapper's rollback works by changing the btrfs
default subvolume, and this system's fstab pins subvol=root and subvol=home
explicitly, which overrides it -- so a rollback would appear to succeed and
change nothing after a reboot. Restoring files and folders out of a snapshot
needs no reboot, cannot affect booting, and covers the cases people actually
hit.
panama-snapshots snapshot
panama-snapshots create CONFIG DESCRIPTION
panama-snapshots delete CONFIG NUMBER
panama-snapshots set-retention CONFIG HOURLY DAILY WEEKLY
panama-snapshots set-timeline CONFIG true|false
panama-snapshots browse CONFIG NUMBER [RELATIVE_PATH]
panama-snapshots restore CONFIG NUMBER RELATIVE_PATH
"""
from __future__ import annotations
import json
import os
import re
import shutil
import subprocess
import sys
from pathlib import Path
CONFIG_NAME = re.compile(r"^[A-Za-z0-9_.-]{1,64}$")
# Directory listings are for choosing something to restore, not for browsing a
# terabyte. A folder with more entries than this is reported as truncated.
BROWSE_LIMIT = 400
class BoundaryError(RuntimeError):
"""A user-visible validation or snapper failure."""
def run(command: list[str], timeout: float = 30.0) -> subprocess.CompletedProcess:
try:
return subprocess.run(command, capture_output=True, text=True,
timeout=timeout, check=False)
except subprocess.TimeoutExpired as error:
raise BoundaryError("snapper did not answer in time.") from error
except OSError as error:
raise BoundaryError("snapper is not available.") from error
def require_config(name: str) -> str:
if not CONFIG_NAME.fullmatch(name or ""):
raise BoundaryError("That is not a snapshot configuration.")
return name
def require_number(value: str) -> int:
if not str(value).isdigit():
raise BoundaryError("That is not a snapshot.")
number = int(value)
# 0 is the live filesystem, not a snapshot, and must never be a target.
if number < 1:
raise BoundaryError("That is the current state, not a snapshot.")
return number
def config_names() -> list[str]:
result = run(["snapper", "list-configs"])
if result.returncode != 0:
return []
names = []
for line in result.stdout.splitlines()[2:]:
parts = [part.strip() for part in line.split("│")]
if len(parts) >= 2 and CONFIG_NAME.fullmatch(parts[0]):
names.append(parts[0])
return names
def config_settings(name: str) -> dict:
result = run(["snapper", "-c", name, "get-config"])
if result.returncode != 0:
return {}
values = {}
for line in result.stdout.splitlines()[2:]:
parts = [part.strip() for part in line.split("│")]
if len(parts) >= 2:
values[parts[0]] = parts[1]
return values
def snapshots_for(name: str) -> list[dict]:
result = run(["snapper", "-c", name, "list"], timeout=45)
if result.returncode != 0:
return []
entries = []
for line in result.stdout.splitlines()[2:]:
parts = [part.strip() for part in line.split("│")]
if len(parts) < 7 or not parts[0].isdigit():
continue
number = int(parts[0])
if number == 0:
continue
entries.append({
"number": number,
"kind": parts[1],
"date": parts[3],
"user": parts[4],
"cleanup": parts[5],
"description": parts[6],
# A snapshot with no cleanup algorithm is not on the timeline's
# list to remove, which is what "kept" means to someone reading it.
"kept": parts[5] == "",
})
entries.sort(key=lambda entry: entry["number"], reverse=True)
return entries
def btrfs_subvolumes() -> list[dict]:
"""Mounted btrfs subvolumes, so the page can name what is NOT protected."""
result = run(["findmnt", "-t", "btrfs", "-J", "-o", "TARGET,OPTIONS"])
if result.returncode != 0:
return []
try:
payload = json.loads(result.stdout)
except json.JSONDecodeError:
return []
found = []
def walk(nodes):
for node in nodes:
options = str(node.get("options", ""))
target = str(node.get("target", ""))
match = re.search(r"subvol=(/[^,]*)", options)
# .snapshots holds the snapshots themselves and is not a thing to
# protect; listing it would offer to snapshot the snapshots.
if match and "/.snapshots" not in target:
found.append({"path": target, "subvolume": match.group(1)})
walk(node.get("children", []))
walk(payload.get("filesystems", []))
return found
def free_space() -> dict:
try:
usage = shutil.disk_usage("/home")
except OSError:
return {"freeBytes": 0, "totalBytes": 0}
return {"freeBytes": usage.free, "totalBytes": usage.total}
def timeline_running() -> bool:
return run(["systemctl", "is-active", "snapper-timeline.timer"]).stdout.strip() == "active"
def snapshot() -> dict:
configs = []
protected_paths = set()
for name in config_names():
settings = config_settings(name)
subvolume = settings.get("SUBVOLUME", "")
protected_paths.add(subvolume)
configs.append({
"name": name,
"subvolume": subvolume,
"timelineEnabled": settings.get("TIMELINE_CREATE", "no") == "yes",
# Empty when this user cannot read the config at all, which is a
# different state from "no snapshots".
"readable": bool(settings),
"limits": {
"hourly": int(settings.get("TIMELINE_LIMIT_HOURLY") or 0),
"daily": int(settings.get("TIMELINE_LIMIT_DAILY") or 0),
"weekly": int(settings.get("TIMELINE_LIMIT_WEEKLY") or 0),
"monthly": int(settings.get("TIMELINE_LIMIT_MONTHLY") or 0),
"yearly": int(settings.get("TIMELINE_LIMIT_YEARLY") or 0),
},
"snapshots": snapshots_for(name) if settings else [],
})
configs.sort(key=lambda entry: entry["subvolume"])
unprotected = [entry for entry in btrfs_subvolumes()
if entry["path"] not in protected_paths]
return {
"configs": configs,
"unprotected": unprotected,
"timelineRunning": timeline_running(),
"space": free_space(),
"error": "",
}
def snapshot_root(config: str, number: int) -> Path:
settings = config_settings(config)
subvolume = settings.get("SUBVOLUME", "")
if not subvolume:
raise BoundaryError("That snapshot configuration cannot be read.")
path = Path(subvolume) / ".snapshots" / str(number) / "snapshot"
if not path.is_dir():
raise BoundaryError("That snapshot is not available.")
return path
def safe_relative(root: Path, relative: str) -> Path:
"""Resolve a path inside a snapshot, refusing anything that escapes it.
The caller is a settings page passing a path a person clicked, and ".." in
the wrong place would read or restore from outside the snapshot entirely.
"""
candidate = (root / relative.lstrip("/")).resolve()
if candidate != root.resolve() and root.resolve() not in candidate.parents:
raise BoundaryError("That path is not inside the snapshot.")
return candidate
def browse(config: str, number: str, relative: str) -> dict:
root = snapshot_root(require_config(config), require_number(number))
target = safe_relative(root, relative)
if not target.is_dir():
raise BoundaryError("That is not a folder in this snapshot.")
entries = []
truncated = False
try:
with os.scandir(target) as scan:
for item in scan:
if len(entries) >= BROWSE_LIMIT:
truncated = True
break
try:
is_dir = item.is_dir(follow_symlinks=False)
size = 0 if is_dir else item.stat(follow_symlinks=False).st_size
except OSError:
continue
entries.append({"name": item.name, "directory": is_dir, "bytes": size})
except PermissionError as error:
raise BoundaryError("That folder cannot be read from this snapshot.") from error
except OSError as error:
raise BoundaryError("That folder could not be listed.") from error
entries.sort(key=lambda entry: (not entry["directory"], entry["name"].lower()))
return {"path": relative, "entries": entries, "truncated": truncated, "error": ""}
def restore(config: str, number: str, relative: str) -> dict:
"""Copy something out of a snapshot, keeping whatever is there now.
The current version is moved aside rather than overwritten. A restore that
destroys the thing you were about to compare it against is how people lose
the work they were trying to save.
"""
name = require_config(config)
index = require_number(number)
root = snapshot_root(name, index)
source = safe_relative(root, relative)
if not source.exists():
raise BoundaryError("That is not in this snapshot.")
settings = config_settings(name)
live_root = Path(settings.get("SUBVOLUME", ""))
destination = safe_relative(live_root, relative)
kept = ""
if destination.exists():
kept = str(destination) + f".before-restore-{index}"
suffix = 1
while Path(kept).exists():
suffix += 1
kept = str(destination) + f".before-restore-{index}-{suffix}"
try:
os.rename(destination, kept)
except OSError as error:
raise BoundaryError("The current version could not be set aside.") from error
try:
if source.is_dir():
shutil.copytree(source, destination, symlinks=True)
else:
destination.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, destination, follow_symlinks=False)
except OSError as error:
raise BoundaryError("That could not be restored.") from error
return {"restored": str(destination), "keptAs": kept}
def create(config: str, description: str) -> None:
if len(description) > 200 or "\n" in description:
raise BoundaryError("That description cannot be used.")
result = run(["snapper", "-c", require_config(config), "create",
"--description", description or "manual snapshot"], timeout=120)
if result.returncode != 0:
raise BoundaryError(_refusal(result, "The snapshot could not be taken."))
def delete(config: str, number: str) -> None:
result = run(["snapper", "-c", require_config(config), "delete",
str(require_number(number))], timeout=120)
if result.returncode != 0:
raise BoundaryError(_refusal(result, "That snapshot could not be removed."))
def set_retention(config: str, hourly: str, daily: str, weekly: str) -> None:
values = []
for label, value in (("HOURLY", hourly), ("DAILY", daily), ("WEEKLY", weekly)):
if not str(value).isdigit() or int(value) > 999:
raise BoundaryError("Keep counts must be whole numbers.")
values.append(f"TIMELINE_LIMIT_{label}={int(value)}")
result = run(["snapper", "-c", require_config(config), "set-config", *values])
if result.returncode != 0:
raise BoundaryError(_refusal(result, "The keep counts could not be changed."))
def set_timeline(config: str, enabled: str) -> None:
result = run(["snapper", "-c", require_config(config), "set-config",
f"TIMELINE_CREATE={'yes' if enabled == 'true' else 'no'}"])
if result.returncode != 0:
raise BoundaryError(_refusal(result, "Automatic snapshots could not be changed."))
def _refusal(result: subprocess.CompletedProcess, fallback: str) -> str:
text = (result.stderr or result.stdout or "").strip().splitlines()
if not text:
return fallback
last = text[-1]
if "permission" in last.lower():
return "This account is not allowed to change that configuration."
return last[:200]
def main(arguments: list[str]) -> int:
try:
if arguments == ["snapshot"]:
print(json.dumps(snapshot(), separators=(",", ":")))
return 0
if len(arguments) in (3, 4) and arguments[0] == "browse":
print(json.dumps(browse(arguments[1], arguments[2],
arguments[3] if len(arguments) == 4 else ""),
separators=(",", ":")))
return 0
if len(arguments) == 4 and arguments[0] == "restore":
outcome = restore(arguments[1], arguments[2], arguments[3])
state = snapshot()
state["restored"] = outcome
print(json.dumps(state, separators=(",", ":")))
return 0
if len(arguments) == 3 and arguments[0] == "create":
create(arguments[1], arguments[2])
elif len(arguments) == 3 and arguments[0] == "delete":
delete(arguments[1], arguments[2])
elif len(arguments) == 5 and arguments[0] == "set-retention":
set_retention(arguments[1], arguments[2], arguments[3], arguments[4])
elif len(arguments) == 3 and arguments[0] == "set-timeline":
set_timeline(arguments[1], arguments[2])
else:
raise BoundaryError(
"Usage: panama-snapshots snapshot | create CONFIG DESCRIPTION | "
"delete CONFIG NUMBER | set-retention CONFIG HOURLY DAILY WEEKLY | "
"set-timeline CONFIG true|false | browse CONFIG NUMBER [PATH] | "
"restore CONFIG NUMBER PATH")
except BoundaryError as error:
try:
state = snapshot()
except BoundaryError:
state = {"configs": [], "unprotected": [], "timelineRunning": False,
"space": {"freeBytes": 0, "totalBytes": 0}}
state["error"] = str(error)
print(json.dumps(state, separators=(",", ":")))
return 0
print(json.dumps(snapshot(), separators=(",", ":")))
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
+306
View File
@@ -0,0 +1,306 @@
#!/usr/bin/env python3
"""SSH keys, the agent holding them, and the hosts this machine has met.
Nothing here ever reads a private key. Fingerprints and comments come from the
matching .pub file, and whether a key is encrypted is answered by asking
ssh-keygen to derive the PUBLIC key with an empty passphrase: it succeeds for an
unencrypted key and fails for an encrypted one, and either way the only thing it
can print is public material.
No passphrase passes through this tool at all. Adding an encrypted key to the
agent lets ssh-add prompt through the system's own askpass, which is where that
belongs -- a settings page collecting a passphrase and handing it on would be a
worse place for it to live, and putting one in argv would publish it to every
process on the machine.
panama-ssh-keys snapshot
panama-ssh-keys agent-add PATH | agent-remove PATH
panama-ssh-keys forget-host HOST
"""
from __future__ import annotations
import json
import os
import re
import subprocess
import sys
from pathlib import Path
SSH_DIR = Path.home() / ".ssh"
KNOWN_HOSTS = SSH_DIR / "known_hosts"
# A host as it may appear in known_hosts, including [host]:port forms.
HOST = re.compile(r"^[A-Za-z0-9._:\[\]-]{1,253}$")
# gnome-keyring's agent, which is what runs on this desktop. Only used when the
# environment has not already named one, so an ssh-agent started by hand wins.
KEYRING_SOCKET = Path(os.environ.get("XDG_RUNTIME_DIR", "/run/user/1000")) / "keyring" / "ssh"
class BoundaryError(RuntimeError):
"""A user-visible validation or ssh failure."""
def run(command: list[str], timeout: float = 15.0, env: dict | None = None):
try:
return subprocess.run(command, capture_output=True, text=True,
timeout=timeout, env=env)
except FileNotFoundError as error:
raise BoundaryError(f"{command[0]} is not installed.") from error
except subprocess.TimeoutExpired as error:
raise BoundaryError(f"{command[0]} did not respond.") from error
def agent_environment() -> dict:
"""The environment an ssh-add call should run in.
A settings window inherits whatever the shell was started with, which on
this desktop does not include SSH_AUTH_SOCK -- so without this the page
would report "no agent" while one is plainly running.
"""
environment = dict(os.environ)
if not environment.get("SSH_AUTH_SOCK") and KEYRING_SOCKET.is_socket():
environment["SSH_AUTH_SOCK"] = str(KEYRING_SOCKET)
return environment
def agent_state() -> dict:
environment = agent_environment()
socket = environment.get("SSH_AUTH_SOCK", "")
if not socket:
return {"available": False, "socket": "", "kind": "", "durableRemoval": False,
"fingerprints": [], "detail": "No SSH agent is running."}
result = run(["ssh-add", "-l"], env=environment)
# ssh-add exits 1 for "no identities" and 2 for "cannot connect", which are
# very different things to report.
if result.returncode == 2:
return {"available": False, "socket": socket, "kind": "", "durableRemoval": False,
"fingerprints": [], "detail": "An agent socket exists but could not be reached."}
fingerprints = []
for line in (result.stdout or "").splitlines():
parts = line.split()
if len(parts) >= 2 and parts[1].startswith("SHA256:"):
fingerprints.append(parts[1])
# gnome-keyring's agent enumerates whatever keys it finds in ~/.ssh, so
# `ssh-add -d` reports "Identity removed" and the key is still listed a
# second later -- it comes straight back from disk. A plain ssh-agent
# removes durably. Measured on this machine rather than assumed, because an
# Unload button that reports success and changes nothing is worse than no
# button at all.
keyring = "/keyring/" in socket
return {
"available": True,
"socket": socket,
"kind": "gnome-keyring" if keyring else "ssh-agent",
"durableRemoval": not keyring,
"fingerprints": fingerprints,
"detail": "" if fingerprints else "The agent is running but holds no keys.",
}
def encrypted(private: Path) -> bool | None:
"""Whether a private key needs a passphrase.
Asked by deriving the public key with an empty passphrase. That reads the
file, but the only thing it can ever emit is the public half, and the answer
is not obtainable any other way without parsing key material directly.
"""
result = run(["ssh-keygen", "-y", "-P", "", "-f", str(private)], timeout=10.0)
if result.returncode == 0:
return False
detail = (result.stderr or "").lower()
if "incorrect passphrase" in detail or "load failed" in detail:
return True
return None
def keys(agent: dict) -> list[dict]:
if not SSH_DIR.is_dir():
return []
held = set(agent.get("fingerprints") or [])
found = []
for public in sorted(SSH_DIR.glob("*.pub")):
private = public.with_suffix("")
described = run(["ssh-keygen", "-l", "-f", str(public)], timeout=10.0)
if described.returncode != 0:
continue
parts = (described.stdout or "").split()
if len(parts) < 3:
continue
bits, fingerprint = parts[0], parts[1]
kind = parts[-1].strip("()")
comment = " ".join(parts[2:-1]).strip()
found.append({
"name": private.name,
"path": str(private),
"publicPath": str(public),
"type": kind,
"bits": int(bits) if bits.isdigit() else 0,
"fingerprint": fingerprint,
"comment": comment if comment != "no" else "",
"hasPrivate": private.is_file(),
"encrypted": encrypted(private) if private.is_file() else None,
"loaded": fingerprint in held,
# Read so the page can say when a key is readable by other people;
# a private key must be 0600.
"mode": oct(private.stat().st_mode & 0o777)[2:] if private.is_file() else "",
})
return found
def hosts() -> list[dict]:
"""Hosts in known_hosts, grouped by name.
A hashed known_hosts cannot be listed -- that is the entire point of hashing
it -- so that is reported rather than shown as an empty list.
"""
if not KNOWN_HOSTS.is_file():
return []
grouped: dict[str, dict] = {}
try:
lines = KNOWN_HOSTS.read_text(encoding="utf-8", errors="replace").splitlines()
except OSError as error:
raise BoundaryError("known_hosts could not be read.") from error
for line in lines:
line = line.strip()
if not line or line.startswith("#"):
continue
parts = line.split()
if len(parts) < 3:
continue
names, kind = parts[0], parts[1]
if names.startswith("|1|"):
entry = grouped.setdefault("", {"host": "", "hashed": True, "types": [], "count": 0})
entry["count"] += 1
if kind not in entry["types"]:
entry["types"].append(kind)
continue
for name in names.split(","):
entry = grouped.setdefault(name, {"host": name, "hashed": False, "types": [], "count": 0})
entry["count"] += 1
if kind not in entry["types"]:
entry["types"].append(kind)
ordered = [entry for key, entry in sorted(grouped.items()) if key != ""]
if "" in grouped:
ordered.append(grouped[""])
return ordered
def snapshot() -> dict:
agent = agent_state()
return {
"available": SSH_DIR.is_dir(),
"directory": str(SSH_DIR),
"agent": agent,
"keys": keys(agent),
"hosts": hosts(),
"error": "",
}
def resolve_key(path: str) -> Path:
"""A key path, confined to ~/.ssh.
Resolved and compared against the directory so that a name cannot walk out
of it, and refused if it is not a file this tool put there.
"""
candidate = Path(path)
try:
resolved = candidate.resolve(strict=True)
except OSError as error:
raise BoundaryError("That key no longer exists.") from error
if resolved.parent != SSH_DIR.resolve(strict=False):
raise BoundaryError("That key is not in the SSH directory.")
if not resolved.is_file():
raise BoundaryError("That is not a key file.")
return resolved
def agent_add(path: str) -> None:
key = resolve_key(path)
environment = agent_environment()
if not environment.get("SSH_AUTH_SOCK"):
raise BoundaryError("No SSH agent is running.")
# No passphrase is supplied here on purpose. An encrypted key makes ssh-add
# prompt through the system's askpass, which is the right place for it.
result = run(["ssh-add", str(key)], timeout=120.0, env=environment)
if result.returncode != 0:
detail = (result.stderr or "").strip().splitlines()
raise BoundaryError(detail[-1] if detail else "That key could not be added.")
def agent_remove(path: str) -> None:
key = resolve_key(path)
environment = agent_environment()
if not environment.get("SSH_AUTH_SOCK"):
raise BoundaryError("No SSH agent is running.")
if not agent_state().get("durableRemoval", True):
raise BoundaryError(
"This desktop's agent lists every key in ~/.ssh, so removing one "
"does not stick. Move the key out of ~/.ssh to stop it being offered.")
result = run(["ssh-add", "-d", str(key)], env=environment)
if result.returncode != 0:
detail = (result.stderr or "").strip().splitlines()
raise BoundaryError(detail[-1] if detail else "That key could not be removed.")
def forget_host(host: str) -> None:
"""Drop a host's keys from known_hosts.
The reason anyone reaches for this is a host key that changed, which is
either a rebuilt machine or something worth being alarmed about -- so the
page says which before offering the button. ssh-keygen -R rewrites the file
and keeps a .old copy itself.
"""
if not HOST.match(host or ""):
raise BoundaryError("That is not a host name.")
if not KNOWN_HOSTS.is_file():
raise BoundaryError("There is no known_hosts file.")
result = run(["ssh-keygen", "-R", host, "-f", str(KNOWN_HOSTS)], timeout=20.0)
if result.returncode != 0:
detail = (result.stderr or "").strip().splitlines()
raise BoundaryError(detail[-1] if detail else "That host could not be removed.")
def main(arguments: list[str]) -> int:
try:
if arguments == ["snapshot"]:
print(json.dumps(snapshot(), separators=(",", ":")))
return 0
if len(arguments) == 2 and arguments[0] == "agent-add":
agent_add(arguments[1])
elif len(arguments) == 2 and arguments[0] == "agent-remove":
agent_remove(arguments[1])
elif len(arguments) == 2 and arguments[0] == "forget-host":
forget_host(arguments[1])
else:
raise BoundaryError(
"Usage: panama-ssh-keys snapshot | agent-add PATH | agent-remove PATH | "
"forget-host HOST")
except BoundaryError as error:
try:
state = snapshot()
except BoundaryError:
state = {"available": False, "directory": str(SSH_DIR),
"agent": {"available": False, "socket": "", "fingerprints": [], "detail": ""},
"keys": [], "hosts": []}
state["error"] = str(error)
print(json.dumps(state, separators=(",", ":")))
return 0
print(json.dumps(snapshot(), separators=(",", ":")))
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
@@ -204,6 +204,46 @@ for gtk_version in 3.0 4.0; do
fi
done
# ── libadwaita accent ────────────────────────────────────────────────────────
# Files, Papers, Loupe and every other libadwaita application read their accent
# from the Settings portal, not from anything Panama draws, so without this they
# render in GNOME blue no matter which accent the desktop is set to.
#
# GNOME's accent-color is a FIXED enum of nine names -- there is no hex here to
# match, only a nearest member to pick -- so this maps our eight to theirs. Kept
# in sync by hand with config/Theme.qml's `gnome` field, for the same reason
# accent_hex() above is: there is no shared source between QML and shell.
#
# The portal must route Settings to the gnome backend for this to reach
# anything; xdg-desktop-portal-gtk cannot serve accent-color at all. See
# config/dot/xdg-desktop-portal/hyprland-portals.conf.
gnome_accent() {
case "$1" in
orchid) printf 'purple' ;;
teal) printf 'teal' ;;
green) printf 'green' ;;
amber) printf 'yellow' ;;
orange) printf 'orange' ;;
rose) printf 'red' ;;
slate) printf 'slate' ;;
*) printf 'blue' ;;
esac
}
status_adwaita="skipped"
if command -v gsettings >/dev/null 2>&1; then
adwaita_accent="$(gnome_accent "$accent")"
# Writing the same value emits no change signal, so applications already
# showing this accent are not asked to restyle for nothing.
if [[ "$(gsettings get org.gnome.desktop.interface accent-color 2>/dev/null)" == "'$adwaita_accent'" ]]; then
status_adwaita="unchanged"
elif gsettings set org.gnome.desktop.interface accent-color "$adwaita_accent" 2>/dev/null; then
status_adwaita="$adwaita_accent"
else
status_adwaita="failed"
fi
fi
kitty_dir="${XDG_CONFIG_HOME:-$HOME/.config}/kitty"
theme_file="$kitty_dir/themes/tokyonight-moon.conf"
[[ "$scheme" == "light" ]] && theme_file="$kitty_dir/themes/tokyonight-day.conf"
@@ -250,4 +290,5 @@ jq -cn \
--arg btop "$status_btop" \
--arg tmux "$status_tmux" \
--arg hyprlock "$status_hyprlock" \
'{scheme: $scheme, kitty: $kitty, gtk: $gtk, btop: $btop, tmux: $tmux, hyprlock: $hyprlock}'
--arg adwaita "$status_adwaita" \
'{scheme: $scheme, kitty: $kitty, gtk: $gtk, btop: $btop, tmux: $tmux, hyprlock: $hyprlock, adwaita: $adwaita}'
+414
View File
@@ -0,0 +1,414 @@
#!/usr/bin/env python3
"""Software updates, from every source this machine actually uses.
Three of them, and they fail independently, so they are counted and applied
separately rather than blended into one number: packages (dnf), applications
(flatpak), and firmware (fwupd).
Checking costs about nine seconds of network and metadata work, which is too
long to spend every time a page opens. So `snapshot` is instant -- it reads the
last result plus the things that are free to compute -- and `check` is the scan
that refreshes it. The page shows when it last checked, the way every mature
updater does, instead of pretending the number is live.
panama-updates snapshot
panama-updates check
panama-updates apply dnf|flatpak|firmware
panama-updates set-auto-flatpak true|false
"""
from __future__ import annotations
import datetime
import json
import os
import re
import shutil
import subprocess
import sys
import time
from pathlib import Path
# The user timer this ships for keeping applications current. dnf has no
# equivalent here because dnf-automatic is not installed, and installing
# software is not this script's job.
FLATPAK_TIMER = "panama-flatpak-update.timer"
# Anything carrying an advisory of these severities is reported as a security
# fix. "none" is excluded deliberately: an advisory with no severity is a
# bugfix or enhancement, and calling it security would cry wolf.
SECURITY_SEVERITIES = "critical,important,moderate,low"
class BoundaryError(RuntimeError):
"""A user-visible failure."""
def run(command: list[str], timeout: float = 180.0) -> subprocess.CompletedProcess:
try:
return subprocess.run(command, capture_output=True, text=True,
timeout=timeout, check=False)
except subprocess.TimeoutExpired as error:
raise BoundaryError(f"{command[0]} did not finish in time.") from error
except OSError as error:
raise BoundaryError(f"{command[0]} is not available.") from error
def cache_path() -> Path:
base = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "panama"
base.mkdir(parents=True, exist_ok=True)
return base / "updates.json"
def read_cache() -> dict:
try:
return json.loads(cache_path().read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {}
def write_cache(payload: dict) -> None:
# Written atomically: a page reading this while it is half-written would
# report zero updates, which is the one wrong answer that looks fine.
target = cache_path()
temporary = target.with_suffix(".tmp")
try:
temporary.write_text(json.dumps(payload), encoding="utf-8")
temporary.replace(target)
except OSError:
pass
def kernel_state() -> dict:
"""Whether a reboot would change the kernel you are running.
This is the honest version of "restart required". Comparing the running
release against the newest installed one is exact, needs no plugin, and
takes no time -- and a machine that has already rebooted since the update
correctly reports nothing pending.
"""
running = os.uname().release
newest = running
result = run(["rpm", "-q", "kernel", "--qf", "%{VERSION}-%{RELEASE}.%{ARCH}\\n"], timeout=30)
if result.returncode == 0:
installed = [line.strip() for line in result.stdout.splitlines() if line.strip()]
if installed:
# rpm lists oldest first for equal names.
newest = installed[-1]
return {
"running": running,
"newestInstalled": newest,
"rebootNeeded": newest != running,
}
def dnf_updates() -> dict:
if not shutil.which("dnf5"):
return {"available": False, "count": 0, "packages": [], "securityCount": 0}
result = run(["dnf5", "check-upgrade", "--json"], timeout=180)
packages = []
# dnf5 exits 100 when upgrades exist, 0 when none do. Both are success.
if result.returncode in (0, 100):
try:
payload = json.loads(result.stdout or "{}")
for entry in payload.get("upgrades", []):
packages.append({
"name": str(entry.get("name", "")),
"version": str(entry.get("evr", "")),
"repository": str(entry.get("repository", "")),
})
except json.JSONDecodeError:
pass
security = 0
advisory = run(["dnf5", "check-upgrade",
f"--advisory-severities={SECURITY_SEVERITIES}", "--json"], timeout=180)
if advisory.returncode in (0, 100):
try:
security = len(json.loads(advisory.stdout or "{}").get("upgrades", []))
except json.JSONDecodeError:
security = 0
packages.sort(key=lambda item: item["name"])
return {"available": True, "count": len(packages), "packages": packages,
"securityCount": security}
def flatpak_updates() -> dict:
if not shutil.which("flatpak"):
return {"available": False, "count": 0, "applications": []}
result = run(["flatpak", "remote-ls", "--updates", "--columns=application,version"],
timeout=120)
applications = []
if result.returncode == 0:
for line in result.stdout.splitlines():
parts = [part.strip() for part in line.split("\t")]
if parts and parts[0]:
applications.append({"id": parts[0],
"version": parts[1] if len(parts) > 1 else ""})
return {"available": True, "count": len(applications), "applications": applications}
def firmware_updates() -> dict:
if not shutil.which("fwupdmgr"):
return {"available": False, "count": 0, "devices": []}
result = run(["fwupdmgr", "get-updates", "--json"], timeout=120)
devices = []
try:
payload = json.loads(result.stdout or "{}")
for device in payload.get("Devices", []):
releases = device.get("Releases", [])
devices.append({
"name": str(device.get("Name", "Unknown device")),
"version": str(device.get("Version", "")),
"target": str(releases[0].get("Version", "")) if releases else "",
# Firmware that needs a reboot to flash is worth saying up front.
"needsReboot": "needs-reboot" in json.dumps(device.get("Flags", [])),
})
except json.JSONDecodeError:
pass
return {"available": True, "count": len(devices), "devices": devices}
def automatic_state() -> dict:
flatpak_timer = run(["systemctl", "--user", "is-enabled", FLATPAK_TIMER], timeout=20)
dnf_timer = run(["systemctl", "is-enabled", "dnf5-automatic.timer"], timeout=20)
return {
"flatpakEnabled": flatpak_timer.stdout.strip() == "enabled",
"flatpakAvailable": flatpak_timer.stdout.strip() not in ("", "not-found"),
# Reported, never offered: dnf-automatic is a package this machine does
# not have, and installing software is not a settings action.
"dnfAutomaticEnabled": dnf_timer.stdout.strip() == "enabled",
"dnfAutomaticAvailable": dnf_timer.stdout.strip() not in ("", "not-found"),
}
def check() -> dict:
payload = {
"dnf": dnf_updates(),
"flatpak": flatpak_updates(),
"firmware": firmware_updates(),
"checkedAt": int(time.time()),
}
write_cache(payload)
return payload
def snapshot() -> dict:
cached = read_cache()
empty = {"available": True, "count": 0}
return {
"dnf": cached.get("dnf", {**empty, "packages": [], "securityCount": 0}),
"flatpak": cached.get("flatpak", {**empty, "applications": []}),
"firmware": cached.get("firmware", {**empty, "devices": []}),
# 0 means never checked, which the page says rather than showing a
# confident "0 updates" it has no basis for.
"checkedAt": int(cached.get("checkedAt", 0)),
"kernel": kernel_state(),
"automatic": automatic_state(),
"error": "",
}
def take_restore_point(reason: str) -> str:
"""A snapshot before the system changes, named after what is about to happen.
Best effort: if snapper is not configured, the update still proceeds. An
update that refuses to run because a nicety failed would be worse than one
without a restore point.
"""
if not shutil.which("snapper"):
return ""
result = run(["snapper", "-c", "root", "create", "--description", reason,
"--cleanup-algorithm", "number", "--print-number"], timeout=120)
return result.stdout.strip() if result.returncode == 0 else ""
def apply(source: str) -> dict:
if source == "flatpak":
if not shutil.which("flatpak"):
raise BoundaryError("Flatpak is not installed.")
result = run(["flatpak", "update", "-y", "--noninteractive"], timeout=3600)
if result.returncode != 0:
raise BoundaryError(_refusal(result, "The applications could not be updated."))
return {"restorePoint": ""}
if source == "dnf":
if not shutil.which("dnf5"):
raise BoundaryError("dnf is not installed.")
pending = read_cache().get("dnf", {}).get("count", 0)
restore_point = take_restore_point(
f"before {pending} package update{'' if pending == 1 else 's'}")
result = run(["pkexec", "dnf5", "upgrade", "-y"], timeout=7200)
if result.returncode != 0:
raise BoundaryError(_refusal(result, "The packages could not be updated."))
return {"restorePoint": restore_point}
if source == "firmware":
if not shutil.which("fwupdmgr"):
raise BoundaryError("Firmware updating is not available.")
result = run(["fwupdmgr", "update", "-y", "--no-reboot-check"], timeout=3600)
if result.returncode != 0:
raise BoundaryError(_refusal(result, "The firmware could not be updated."))
return {"restorePoint": ""}
raise BoundaryError("That is not an update source.")
def set_auto_dnf(enabled: bool) -> None:
"""Enable the packaging timer, which DOWNLOADS updates but does not apply them.
That is the shipped default (apply_updates = no) and it is the right one to
leave alone: a machine that installs packages unattended can reboot into a
kernel nobody chose. Downloading ahead of time makes the install quick when
someone does choose it.
"""
state = automatic_state()
if not state["dnfAutomaticAvailable"]:
raise BoundaryError("Automatic package updates are not installed.")
action = ["enable", "--now"] if enabled else ["disable", "--now"]
result = run(["pkexec", "systemctl", *action, "dnf5-automatic.timer"], timeout=120)
if result.returncode != 0:
raise BoundaryError(_refusal(result, "Automatic package updates could not be changed."))
def set_auto_flatpak(enabled: bool) -> None:
action = ["enable", "--now"] if enabled else ["disable", "--now"]
result = run(["systemctl", "--user", *action, FLATPAK_TIMER], timeout=60)
if result.returncode != 0:
raise BoundaryError(_refusal(result, "Automatic application updates could not be changed."))
def _refusal(result: subprocess.CompletedProcess, fallback: str) -> str:
text = ((result.stderr or "") + "\n" + (result.stdout or "")).strip().splitlines()
meaningful = [line for line in text if line.strip()]
if not meaningful:
return fallback
last = meaningful[-1]
if "not authorized" in last.lower() or "dismissed" in last.lower():
return "That update was not authorized."
return last[:200]
def flatpak_time(text: str) -> int:
"""flatpak prints "Aug 20 08:07:46" -- a time with no year in it.
Assumed to be this year, and rolled back one if that would put it in the
future, which is the only reading that makes sense for a history. Anything
unparseable sorts last rather than pretending to be the epoch, which would
put it at the top of a newest-first list.
"""
if not text.strip():
return 0
now = datetime.datetime.now()
for year in (now.year, now.year - 1):
try:
when = datetime.datetime.strptime(f"{year} {text.strip()}", "%Y %b %d %H:%M:%S")
except ValueError:
return 0
if when <= now + datetime.timedelta(days=1):
return int(when.timestamp())
return 0
def history(limit: int = 25) -> list[dict]:
"""What has actually been installed, newest first.
Both sources are asked in their own machine-readable form rather than by
parsing their tables: dnf5 prints JSON, and flatpak takes --json. The two
are merged on time so the answer reads as one history rather than as two
lists a person has to interleave themselves.
Automatic updates are the reason this is worth showing. Something that
installs itself overnight leaves no other trace a person would notice.
"""
entries: list[dict] = []
dnf = run(["dnf5", "history", "list", "--json"], timeout=30.0)
if dnf.returncode == 0:
try:
for row in json.loads(dnf.stdout or "[]"):
command = str(row.get("command_line") or "").strip()
entries.append({
"source": "dnf",
"at": int(row.get("start_time") or 0),
# The full argv is noise; what was done is the useful part.
"summary": command.split("/")[-1] if command else "transaction",
"count": int(row.get("altered_count") or 0),
"ok": str(row.get("status") or "") == "Ok",
})
except (json.JSONDecodeError, TypeError, ValueError):
pass
flatpak = run(["flatpak", "history", "--json"], timeout=30.0)
if flatpak.returncode == 0:
try:
rows = json.loads(flatpak.stdout or "[]")
except json.JSONDecodeError:
rows = []
for row in rows if isinstance(rows, list) else []:
application = str(row.get("application") or "").strip()
change = str(row.get("change") or "").strip()
if not application:
continue
# "deploy update", "deploy install", "uninstall" -- the verb is a
# word inside the change rather than the whole of it.
verb = next((word for word in ("update", "install", "uninstall")
if word in change), "")
if not verb:
continue
entries.append({
"source": "flatpak",
"at": flatpak_time(str(row.get("time") or "")),
"summary": verb + " " + application,
"count": 1,
"ok": True,
})
entries.sort(key=lambda entry: entry["at"], reverse=True)
return entries[:limit]
def main(arguments: list[str]) -> int:
try:
if arguments == ["history"]:
print(json.dumps({"entries": history(), "error": ""}, separators=(",", ":")))
return 0
if arguments == ["snapshot"]:
print(json.dumps(snapshot(), separators=(",", ":")))
return 0
if arguments == ["check"]:
check()
print(json.dumps(snapshot(), separators=(",", ":")))
return 0
if len(arguments) == 2 and arguments[0] == "apply":
outcome = apply(arguments[1])
# Re-check, so the page reflects what is actually left rather than
# assuming the update cleared everything it listed.
check()
state = snapshot()
state["applied"] = {"source": arguments[1], **outcome}
print(json.dumps(state, separators=(",", ":")))
return 0
if len(arguments) == 2 and arguments[0] == "set-auto-flatpak":
set_auto_flatpak(arguments[1] == "true")
elif len(arguments) == 2 and arguments[0] == "set-auto-dnf":
set_auto_dnf(arguments[1] == "true")
else:
raise BoundaryError(
"Usage: panama-updates snapshot | check | apply dnf|flatpak|firmware | "
"set-auto-flatpak true|false | set-auto-dnf true|false")
except BoundaryError as error:
state = snapshot()
state["error"] = str(error)
print(json.dumps(state, separators=(",", ":")))
return 0
print(json.dumps(snapshot(), separators=(",", ":")))
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
+340
View File
@@ -0,0 +1,340 @@
#!/usr/bin/env python3
"""User accounts, through accountsservice -- the same daemon GNOME's Users panel
drives.
Everything here that changes something is authorized by polkit, which prompts
through the agent this desktop already runs. This script never asks for a
password itself and never holds an administrator credential.
The one credential it does handle is a NEW password being set. That is read
from stdin, hashed by openssl reading its own stdin, and handed to
accountsservice over D-Bus from inside this process. It is never an argument:
argv is world-readable through /proc, so a password -- or even its hash --
passed that way is published to every process on the machine.
panama-accounts snapshot
panama-accounts set-real-name USER NAME
panama-accounts set-icon USER PATH [X Y SIZE]
panama-accounts set-account-type USER standard|administrator
panama-accounts set-automatic-login USER true|false
panama-accounts set-password USER (new password on stdin)
panama-accounts create-user USERNAME REALNAME standard|administrator
panama-accounts delete-user USERNAME [keep-files|remove-files]
"""
from __future__ import annotations
import json
import os
import re
import subprocess
import tempfile
import sys
ACCOUNTS = "org.freedesktop.Accounts"
ACCOUNTS_PATH = "/org/freedesktop/Accounts"
USER_INTERFACE = "org.freedesktop.Accounts.User"
# Account types as accountsservice numbers them.
STANDARD, ADMINISTRATOR = 0, 1
USERNAME = re.compile(r"^[a-z_][a-z0-9_-]{0,31}$")
class BoundaryError(RuntimeError):
"""A user-visible validation or permission failure."""
def bus():
try:
import gi
gi.require_version("Gio", "2.0")
from gi.repository import Gio, GLib
return Gio, GLib, Gio.bus_get_sync(Gio.BusType.SYSTEM, None)
except Exception as error: # noqa: BLE001 - no bus is a legitimate state
raise BoundaryError("The account service is not answering.") from error
def call(path: str, interface: str, method: str, parameters=None, reply=None):
Gio, GLib, connection = bus()
try:
result = connection.call_sync(
ACCOUNTS, path, interface, method, parameters,
GLib.VariantType(reply) if reply else None,
Gio.DBusCallFlags.NONE, 120000, None)
except Exception as error: # noqa: BLE001
message = str(error)
# Polkit's own refusal is the common case and deserves plain words
# rather than a D-Bus error string.
if "not authorized" in message.lower() or "dismissed" in message.lower():
raise BoundaryError("That change was not authorized.") from error
raise BoundaryError(_clean(message)) from error
return result.unpack() if result is not None else None
def _clean(message: str) -> str:
"""The last useful sentence of a D-Bus error, without the type prefix."""
trimmed = re.sub(r"^GDBus\.Error:[^:]+:\s*", "", message).strip()
return trimmed.splitlines()[0][:200] if trimmed else "That change failed."
def properties(path: str) -> dict:
Gio, GLib, connection = bus()
result = connection.call_sync(
ACCOUNTS, path, "org.freedesktop.DBus.Properties", "GetAll",
GLib.Variant("(s)", (USER_INTERFACE,)), GLib.VariantType("(a{sv})"),
Gio.DBusCallFlags.NONE, 20000, None)
return result.unpack()[0]
def user_path(username: str) -> str:
if not USERNAME.fullmatch(username or ""):
raise BoundaryError("That is not a user name.")
from gi.repository import GLib
return call(ACCOUNTS_PATH, ACCOUNTS, "FindUserByName",
GLib.Variant("(s)", (username,)), "(o)")[0]
def describe(path: str) -> dict:
values = properties(path)
icon = str(values.get("IconFile") or "")
return {
"path": path,
"userName": str(values.get("UserName") or ""),
"realName": str(values.get("RealName") or ""),
# Reported only when it is actually there: accountsservice keeps the
# path in its database whether or not a file exists, so a deleted
# avatar otherwise shows as a broken image.
"iconFile": icon if icon and os.path.isfile(icon) else "",
"administrator": int(values.get("AccountType") or 0) == ADMINISTRATOR,
"locked": bool(values.get("Locked")),
"automaticLogin": bool(values.get("AutomaticLogin")),
"loginTime": int(values.get("LoginTime") or 0),
"shell": str(values.get("Shell") or ""),
"homeDirectory": str(values.get("HomeDirectory") or ""),
"systemAccount": bool(values.get("SystemAccount")),
"uid": int(values.get("Uid") or 0),
}
def snapshot() -> dict:
paths = call(ACCOUNTS_PATH, ACCOUNTS, "ListCachedUsers", None, "(ao)")[0]
users = [describe(path) for path in paths]
users = [user for user in users if not user["systemAccount"]]
users.sort(key=lambda user: user["uid"])
me = os.environ.get("USER") or ""
return {
"users": users,
"currentUser": me,
# Removing the only administrator would leave a machine nobody can
# administer, so the page needs to know rather than find out.
"administratorCount": sum(1 for user in users if user["administrator"]),
"error": "",
}
def set_real_name(username: str, name: str) -> None:
from gi.repository import GLib
if len(name) > 128 or "\n" in name or ":" in name:
raise BoundaryError("That name cannot be used.")
call(user_path(username), USER_INTERFACE, "SetRealName",
GLib.Variant("(s)", (name,)))
# What an avatar is written out at. accountsservice stores whatever it is
# handed, so a 4000px photograph would sit on disk forever to be drawn at 48.
AVATAR_SIZE = 512
def crop_square(path: str, x: int, y: int, size: int) -> str:
"""Cut a square out of a picture and write it at avatar size.
Returns a path to a new file; the caller is responsible for removing it.
GdkPixbuf rather than a new dependency -- gi is already required here for
accountsservice itself.
"""
import gi
gi.require_version("GdkPixbuf", "2.0")
from gi.repository import GdkPixbuf
try:
picture = GdkPixbuf.Pixbuf.new_from_file(path)
except Exception as error:
raise BoundaryError("That file is not a picture this can read.") from error
if size <= 0:
raise BoundaryError("That is not a region of the picture.")
# Clamped rather than rejected: a drag that ends a pixel outside the image
# is a normal thing to do with a pointer, not an error worth refusing.
x = max(0, min(x, picture.get_width() - 1))
y = max(0, min(y, picture.get_height() - 1))
size = min(size, picture.get_width() - x, picture.get_height() - y)
if size <= 0:
raise BoundaryError("That region is outside the picture.")
square = picture.new_subpixbuf(x, y, size, size)
scaled = square.scale_simple(AVATAR_SIZE, AVATAR_SIZE, GdkPixbuf.InterpType.BILINEAR)
if scaled is None:
raise BoundaryError("That picture could not be resized.")
handle, out = tempfile.mkstemp(prefix="panama-avatar-", suffix=".png")
os.close(handle)
# accountsservice reads this as root and copies it; it must not be private
# to this user, and it is deleted as soon as that copy has happened.
os.chmod(out, 0o644)
scaled.savev(out, "png", [], [])
return out
def set_icon(username: str, path: str, region: tuple[int, int, int] | None = None) -> None:
from gi.repository import GLib
if not os.path.isfile(path):
raise BoundaryError("That picture no longer exists.")
source = path
temporary = None
if region is not None:
temporary = source = crop_square(path, *region)
try:
call(user_path(username), USER_INTERFACE, "SetIconFile",
GLib.Variant("(s)", (source,)))
finally:
# SetIconFile copies the file before it returns, so this is safe here
# and leaving it behind would litter /tmp with every picture ever set.
if temporary is not None:
try:
os.unlink(temporary)
except OSError:
pass
def set_account_type(username: str, kind: str) -> None:
from gi.repository import GLib
if kind not in ("standard", "administrator"):
raise BoundaryError("That is not an account type.")
call(user_path(username), USER_INTERFACE, "SetAccountType",
GLib.Variant("(i)", (ADMINISTRATOR if kind == "administrator" else STANDARD,)))
def set_automatic_login(username: str, enabled: bool) -> None:
from gi.repository import GLib
call(user_path(username), USER_INTERFACE, "SetAutomaticLogin",
GLib.Variant("(b)", (enabled,)))
def set_password(username: str) -> None:
"""Set a new password, read from stdin and never named on a command line."""
secret = sys.stdin.buffer.read()
# A trailing newline from a pipe is not part of the password.
if secret.endswith(b"\n"):
secret = secret[:-1]
if not secret:
raise BoundaryError("No password was provided.")
if len(secret) < 6:
raise BoundaryError("That password is too short.")
# openssl reads the password on ITS stdin too, so the cleartext never
# appears in a process listing at any point in the chain.
hashed = subprocess.run(["openssl", "passwd", "-6", "-stdin"],
input=secret, capture_output=True, timeout=30, check=False)
if hashed.returncode != 0 or not hashed.stdout.strip():
raise BoundaryError("The password could not be prepared.")
from gi.repository import GLib
call(user_path(username), USER_INTERFACE, "SetPassword",
GLib.Variant("(ss)", (hashed.stdout.decode().strip(), "")))
def create_user(username: str, real_name: str, kind: str) -> None:
from gi.repository import GLib
if not USERNAME.fullmatch(username or ""):
raise BoundaryError("A user name may use lowercase letters, digits, - and _.")
if kind not in ("standard", "administrator"):
raise BoundaryError("That is not an account type.")
call(ACCOUNTS_PATH, ACCOUNTS, "CreateUser",
GLib.Variant("(ssi)", (username, real_name,
ADMINISTRATOR if kind == "administrator" else STANDARD)),
"(o)")
def delete_user(username: str, files: str) -> None:
from gi.repository import GLib
if files not in ("keep-files", "remove-files"):
raise BoundaryError("Say whether to keep or remove the home directory.")
if username == (os.environ.get("USER") or ""):
raise BoundaryError("You cannot delete the account you are signed in to.")
state = snapshot()
target = next((user for user in state["users"] if user["userName"] == username), None)
if target is None:
raise BoundaryError("That account no longer exists.")
if target["administrator"] and state["administratorCount"] <= 1:
raise BoundaryError("That is the only administrator; the machine would have none.")
call(ACCOUNTS_PATH, ACCOUNTS, "DeleteUser",
GLib.Variant("(xb)", (target["uid"], files == "remove-files")))
def main(arguments: list[str]) -> int:
try:
if arguments == ["snapshot"]:
print(json.dumps(snapshot(), separators=(",", ":")))
return 0
if len(arguments) == 3 and arguments[0] == "set-real-name":
set_real_name(arguments[1], arguments[2])
elif len(arguments) == 3 and arguments[0] == "set-icon":
set_icon(arguments[1], arguments[2])
elif len(arguments) == 6 and arguments[0] == "set-icon":
try:
region = tuple(int(value) for value in arguments[3:6])
except ValueError:
raise BoundaryError("That is not a region of the picture.")
set_icon(arguments[1], arguments[2], region)
elif len(arguments) == 3 and arguments[0] == "set-account-type":
set_account_type(arguments[1], arguments[2])
elif len(arguments) == 3 and arguments[0] == "set-automatic-login":
set_automatic_login(arguments[1], arguments[2] == "true")
elif len(arguments) == 2 and arguments[0] == "set-password":
set_password(arguments[1])
elif len(arguments) == 4 and arguments[0] == "create-user":
create_user(arguments[1], arguments[2], arguments[3])
elif len(arguments) == 3 and arguments[0] == "delete-user":
delete_user(arguments[1], arguments[2])
else:
raise BoundaryError(
"Usage: panama-accounts snapshot | set-real-name USER NAME | "
"set-icon USER PATH [X Y SIZE] | set-account-type USER standard|administrator | "
"set-automatic-login USER true|false | set-password USER | "
"create-user USERNAME REALNAME standard|administrator | "
"delete-user USERNAME keep-files|remove-files")
except BoundaryError as error:
# Answers with the fresh state plus the message, so a page never has to
# ask twice to find out what happened.
try:
state = snapshot()
except BoundaryError:
state = {"users": [], "currentUser": "", "administratorCount": 0}
state["error"] = str(error)
print(json.dumps(state, separators=(",", ":")))
return 0
print(json.dumps(snapshot(), separators=(",", ":")))
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
+12 -7
View File
@@ -170,13 +170,18 @@ Singleton {
`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.
// their own palettes -- terminals, chiefly. Portal readers (GTK4,
// Qt6, Chromium, Electron) already have their SCHEME from the
// gsettings write above.
//
// Their ACCENT does not come from here: GNOME's accent-color is a
// fixed enum of nine names rather than a color, so the mapping from
// our eight lives in panama-theme-apps beside the other per-
// application translations. The accent is passed through for that,
// and because kitty's border and the hyprlock fallback template are
// the accent role too -- 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]);
}
@@ -45,6 +45,40 @@ Singleton {
return fallback;
}
// Whether the wired connection is on, as a person means it: carrying
// traffic, and set to come back by itself.
readonly property bool wiredOn: root.wiredDevice !== null
&& root.wiredDevice.connected
// Deliberately NOT gated on hasLink. That property reads false while the
// device is merely disconnected -- NetworkManager still reports the carrier
// as on -- so using it to decide whether the switch works built a trap
// door: turning Ethernet off made the switch disable itself, claim "no
// cable", and leave no way to turn it back on. A wired device that exists
// can always be asked to come up; if there is genuinely no cable, the
// attempt fails and says so, which is the honest failure.
readonly property bool wiredAvailable: root.wiredDevice !== null
// Turning wired networking off means both halves. Disconnecting alone lasts
// about a second: NetworkManager sees a managed device with autoconnect set
// and immediately brings it back, so the switch would flip itself on again
// and read as broken.
function setWired(enabled: bool): void {
const device = root.wiredDevice;
if (!device)
return;
device.autoconnect = enabled;
if (enabled) {
// With autoconnect restored NetworkManager will usually bring it up
// on its own; asking directly makes it immediate rather than
// whenever the daemon next looks.
if (device.network)
device.network.connect();
} else {
device.disconnect();
}
}
readonly property var adapter: Bluetooth.defaultAdapter
readonly property bool wifiEnabled: Networking.wifiEnabled
@@ -0,0 +1,202 @@
pragma Singleton
// Rootless podman-compose stacks: what they are, what they expose, what they cost.
//
// Grouping is read from the compose labels rather than invented, and acting on a
// group is done with plain podman over the labelled set -- never
// `podman-compose down`, which would remove containers this shell did not
// create. The compose file is the source of truth for what exists, and it
// belongs to the repository.
//
// Rootless throughout, so nothing here prompts for a password.
import Quickshell
import Quickshell.Io
import QtQuick
Singleton {
id: root
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-containers"
// How many log lines are kept in memory. A busy container can emit
// thousands a minute, and the panel is for reading the recent past, not for
// archiving it.
readonly property int logLimit: 2000
property bool available: false
property var projects: []
property var loose: []
property int running: 0
property int total: 0
property var exposed: []
property var disk: ({})
property bool scanned: false
property string lastError: ""
// Guards read these Processes directly. A derived `busy` binding returns its
// cached value inside the handler that changes its dependency, which is how
// a write can be dropped without any error at all.
readonly property bool busy: query.running || mutation.running
// Published on every interface AND currently running: reachable now, as
// opposed to a stopped container that merely would be.
readonly property var reachable: root.exposed.filter(entry => entry.running === true)
readonly property var wouldExpose: root.exposed.filter(entry => entry.running !== true)
readonly property var unusedImages: root.disk.unusedImages ?? []
readonly property var unusedVolumes: root.disk.unusedVolumes ?? []
readonly property int reclaimable:
Number(root.disk.imagesReclaimable ?? 0) + Number(root.disk.volumesReclaimable ?? 0)
// The container whose logs are on screen, and whether podman is still
// feeding them. Empty when the log view is closed.
property string logTarget: ""
readonly property bool logFollowing: logs.running
property string logError: ""
readonly property alias logLines: logModel
// Decimal units, to match what podman itself prints.
function formatBytes(bytes: real): string {
if (!(bytes > 0))
return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
let value = bytes;
let index = 0;
while (value >= 1000 && index < units.length - 1) {
value /= 1000;
index += 1;
}
return value.toFixed(value < 10 && index > 1 ? 1 : 0) + " " + units[index];
}
// Paths are shown relative to home: the interesting part of a compose path
// is where it sits in the repository, not the eight characters before it.
function shorten(path: string): string {
const home = Quickshell.env("HOME") ?? "";
return home !== "" && path.startsWith(home + "/") ? "~" + path.slice(home.length) : path;
}
// Hand a compose file to whatever opens text files -- which on this machine
// is Neovim in kitty.
function openFile(path: string): void {
if (path === "")
return;
opener.command = ["xdg-open", path];
opener.running = true;
}
function refresh(): void {
if (query.running)
return;
query.command = [root.helperPath, "snapshot"];
query.running = true;
}
function absorb(text: string): void {
try {
const parsed = JSON.parse(text);
root.available = parsed.available === true;
root.projects = Array.isArray(parsed.projects) ? parsed.projects : [];
root.loose = Array.isArray(parsed.loose) ? parsed.loose : [];
root.running = Number(parsed.running ?? 0);
root.total = Number(parsed.total ?? 0);
root.exposed = Array.isArray(parsed.exposed) ? parsed.exposed : [];
root.disk = parsed.disk ?? ({});
root.lastError = String(parsed.error ?? "");
} catch (error) {
root.lastError = "Could not read the state of the containers.";
console.warn("Containers: could not parse helper output:", error);
}
root.scanned = true;
}
function run(arguments: var): void {
if (mutation.running)
return;
root.lastError = "";
mutation.command = [root.helperPath].concat(arguments);
mutation.running = true;
}
function start(name: string): void { root.run(["start", name]); }
function stop(name: string): void { root.run(["stop", name]); }
function restart(name: string): void { root.run(["restart", name]); }
function startProject(name: string): void { root.run(["project-start", name]); }
function stopProject(name: string): void { root.run(["project-stop", name]); }
function restartProject(name: string): void { root.run(["project-restart", name]); }
function pruneImages(): void { root.run(["prune-images"]); }
function pruneVolumes(): void { root.run(["prune-volumes"]); }
// Bind a service's published ports to loopback by editing its compose file
// in place. The helper adds an address and leaves everything else alone, or
// refuses; it never rewrites a port it cannot read unambiguously.
function bindLocal(project: string, service: string): void {
root.run(["bind-local", project, service]);
}
// ── logs ────────────────────────────────────────────────────────────────
function openLogs(name: string): void {
root.closeLogs();
root.logTarget = name;
root.logError = "";
// --tail bounds the initial burst: a container running for weeks would
// otherwise deliver its entire history before the first line appears.
logs.command = ["podman", "logs", "--tail", "400", "--timestamps", "--follow", name];
logs.running = true;
}
function closeLogs(): void {
if (logs.running)
logs.running = false;
logModel.clear();
root.logTarget = "";
root.logError = "";
}
function appendLog(line: string): void {
if (root.logTarget === "")
return;
logModel.append({ line });
if (logModel.count > root.logLimit)
logModel.remove(0, logModel.count - root.logLimit);
}
Component.onCompleted: root.refresh()
ListModel { id: logModel }
Process {
id: query
stdout: StdioCollector { onStreamFinished: root.absorb(this.text) }
stderr: StdioCollector {
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
}
}
Process {
id: mutation
stdout: StdioCollector { onStreamFinished: root.absorb(this.text) }
stderr: StdioCollector {
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
}
}
Process { id: opener }
Process {
id: logs
// podman writes container output to both streams; a log view that showed
// only one would silently drop half of what the container said.
stdout: SplitParser { onRead: line => root.appendLog(line) }
stderr: SplitParser { onRead: line => root.appendLog(line) }
onExited: (code, status) => {
if (root.logTarget !== "" && code !== 0)
root.logError = "The log stream ended unexpectedly.";
}
}
}
+43 -6
View File
@@ -18,9 +18,28 @@ Singleton {
property var luaAutostartEntries: []
property string lastError: ""
// For the UI, which wants one answer to "is anything happening".
//
// Guards inside this file do NOT use it. `busy` is a binding, and a binding
// hands back its cached value until the change notification that feeds it
// has been delivered. Inside a process's own onExited handler that has not
// happened yet, so `busy` still reads true there -- which silently turned
// the refresh after every successful write into a no-op. The write landed
// and the settings page never noticed, which looks exactly like a settings
// page that cannot change anything. Guards below read the Process objects
// directly, where the value is current.
readonly property bool busy: snapshotProcess.running || mutationProcess.running
readonly property string helper: Quickshell.shellDir + "/scripts/panama-default-apps"
readonly property var supportedRoles: ["browser", "mail", "files", "terminal", "music", "images", "video"]
// Derived from the snapshot, never restated. This was a hardcoded list of
// seven, and when the helper and the page grew documents, text and archives
// it stayed at seven -- so choosing a PDF viewer set lastError and did
// nothing, which reads exactly like a settings page that does not work.
//
// The snapshot already reports one handler per role the helper supports, so
// that IS the list. An empty one means no snapshot has landed yet; the
// helper validates the role itself and reports a failure, so there is
// nothing for this guard to add before then.
readonly property var supportedRoles: Object.keys(root.handlers ?? ({}))
Process {
id: snapshotProcess
@@ -59,7 +78,9 @@ Singleton {
}
function refresh(): void {
if (root.busy)
// Only a snapshot already in flight is a reason not to start another.
// A mutation finishing is the single best reason TO refresh.
if (snapshotProcess.running)
return;
root.lastError = "";
snapshotProcess.exec([root.helper, "snapshot"]);
@@ -76,9 +97,11 @@ Singleton {
}
function setDefault(role: string, desktopId: string): void {
if (root.busy)
if (mutationProcess.running)
return;
if (!root.supportedRoles.includes(role) || !root.knownDesktopId(desktopId)) {
const roleIsKnown = root.supportedRoles.length === 0
|| root.supportedRoles.includes(role);
if (!roleIsKnown || !root.knownDesktopId(desktopId)) {
root.lastError = "Choose an application from the available list."
return;
}
@@ -87,7 +110,7 @@ Singleton {
}
function setAutostart(desktopId: string, enabled: bool): void {
if (root.busy)
if (mutationProcess.running)
return;
const known = root.autostartEntries.some(entry => entry.id === desktopId);
if (!known) {
@@ -98,8 +121,22 @@ Singleton {
mutationProcess.exec([root.helper, "set-autostart", desktopId, String(enabled)]);
}
// Deletes the entry rather than hiding it. Disabling writes Hidden=true and
// is reversible; this is not, so the page confirms before calling it.
function removeAutostart(desktopId: string): void {
if (mutationProcess.running)
return;
const known = root.autostartEntries.some(entry => entry.id === desktopId);
if (!known) {
root.lastError = "That user autostart entry is no longer available."
return;
}
root.lastError = "";
mutationProcess.exec([root.helper, "remove-autostart", desktopId]);
}
function addAutostart(desktopId: string): void {
if (root.busy)
if (mutationProcess.running)
return;
if (!root.knownDesktopId(desktopId)) {
root.lastError = "Choose an installed application.";
@@ -208,7 +208,12 @@ Singleton {
DesktopPreferences.get("middleClickPaste")),
root.setting("org.gnome.desktop.wm.preferences", "button-layout", root.buttonLayout()),
root.setting("org.gnome.desktop.wm.preferences", "action-double-click-titlebar",
DesktopPreferences.get("titlebarDoubleClick"))
DesktopPreferences.get("titlebarDoubleClick")),
// The portal republishes this as org.freedesktop.appearance
// contrast, which is what libadwaita reads -- so this reaches GTK4
// applications without any high-contrast theme being installed.
root.setting("org.gnome.desktop.a11y.interface", "high-contrast",
DesktopPreferences.get("highContrast"))
]);
}
+183
View File
@@ -0,0 +1,183 @@
pragma Singleton
// Storage: what is in this machine, how full it is, and what is filling it.
//
// Two reads with very different costs, kept apart on purpose:
//
// refresh() layout, usage, and drive health. Around 90ms -- lsblk plus one
// udisks call -- so the page opens with it and re-reads freely.
// scan() what is using the space. Measuring a folder means walking it,
// and a Steam library alone can be a terabyte, so this happens
// only when asked and the answer is kept until asked again.
//
// Not a stored preference: every value here is the machine's, not the user's.
//
// Deliberately absent: partitioning and formatting. Those stay in GNOME Disks,
// which the page can launch.
import Quickshell
import Quickshell.Io
import QtQuick
Singleton {
id: root
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-disks"
property var drives: []
property var filesystems: []
property var swap: []
property bool scanned: false
property string lastError: ""
// The expensive half.
property var folders: []
property var containers: null
property bool scanning: false
property bool folderScanTruncated: false
// Empty until a scan has completed, which is what the page shows a prompt
// for rather than an empty list -- "nothing here" and "not measured yet"
// are different answers.
property bool foldersMeasured: false
readonly property var primaryDrive: root.drives.length > 0 ? root.drives[0] : null
// The filesystem the user means when they ask how full the machine is.
readonly property var rootFilesystem: {
for (const filesystem of root.filesystems) {
if ((filesystem.mountpoints ?? []).includes("/"))
return filesystem;
}
return root.filesystems.length > 0 ? root.filesystems[0] : null;
}
// Bytes, in the units people actually read. Binary units with decimal-style
// names would be a lie in the other direction; this matches what df -h and
// the drive's own packaging say.
function formatBytes(bytes: real): string {
if (!(bytes > 0))
return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
let value = bytes;
let index = 0;
while (value >= 1000 && index < units.length - 1) {
value /= 1000;
index += 1;
}
const decimals = value < 10 && index > 1 ? 1 : 0;
return value.toFixed(decimals) + " " + units[index];
}
function usedFraction(filesystem: var): real {
const size = Number(filesystem?.sizeBytes ?? 0);
if (!(size > 0))
return 0;
return Math.max(0, Math.min(1, Number(filesystem.usedBytes ?? 0) / size));
}
// "/ and /home" rather than two rows: btrfs subvolumes share one pool of
// free space, and showing them separately doubles it on screen.
function mountLabel(filesystem: var): string {
const points = filesystem?.mountpoints ?? [];
if (points.length === 0)
return String(filesystem?.device ?? "");
if (points.length === 1)
return points[0];
return points.slice(0, -1).join(", ") + " and " + points[points.length - 1];
}
function healthSummary(drive: var): string {
if (!drive)
return "";
if (drive.healthy === false)
return (drive.warnings ?? []).length > 0
? "Reporting " + drive.warnings.join(", ")
: "Reporting a failure";
if (drive.healthy === true)
return "No warnings";
return "Health not reported";
}
function refresh(): void {
if (!query.running)
query.running = true;
}
function scan(): void {
if (root.scanning)
return;
root.scanning = true;
folderScan.running = true;
}
function unmount(devicePath: string): void {
root.runMedia(["unmount", devicePath]);
}
function eject(devicePath: string): void {
root.runMedia(["eject", devicePath]);
}
function runMedia(arguments: var): void {
if (media.running)
return;
root.lastError = "";
media.command = [root.helperPath].concat(arguments);
media.running = true;
}
Process {
id: query
command: [root.helperPath, "snapshot"]
stdout: StdioCollector {
onStreamFinished: {
try {
const parsed = JSON.parse(this.text);
root.drives = Array.isArray(parsed.drives) ? parsed.drives : [];
root.filesystems = Array.isArray(parsed.filesystems) ? parsed.filesystems : [];
root.swap = Array.isArray(parsed.swap) ? parsed.swap : [];
root.lastError = "";
} catch (error) {
root.drives = [];
root.filesystems = [];
root.lastError = "Could not read the storage helper's output.";
console.warn("Disks: could not parse helper output:", error);
}
root.scanned = true;
}
}
stderr: StdioCollector {
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
}
}
Process {
id: folderScan
command: [root.helperPath, "scan"]
stdout: StdioCollector {
onStreamFinished: {
try {
const parsed = JSON.parse(this.text);
root.folders = Array.isArray(parsed.folders) ? parsed.folders : [];
root.containers = parsed.containers ?? null;
root.folderScanTruncated = parsed.truncated === true;
root.foldersMeasured = true;
} catch (error) {
root.lastError = "Could not measure what is using the drive.";
console.warn("Disks: could not parse scan output:", error);
}
}
}
onExited: root.scanning = false
}
Process {
id: media
stderr: StdioCollector {
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
}
// Re-read rather than assuming: a device may refuse to unmount because
// something still has a file open on it.
onExited: root.refresh()
}
}
+123
View File
@@ -0,0 +1,123 @@
pragma Singleton
// The firewall, answered as "what can another machine reach?"
//
// Listing zones and services is what firewall-cmd already does. The question it
// does not answer needs both halves at once: a port is reachable only when
// something is listening on a network address AND the firewall permits it.
// Either alone tells you nothing, which is how a tidy set of rules coexists
// with an exposed database.
//
// Changes go through firewall-cmd, which is polkit-aware, so they prompt --
// through Panama's own prompt now.
import Quickshell
import Quickshell.Io
import QtQuick
Singleton {
id: root
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-firewall"
property bool running: false
property bool enabledAtBoot: false
property bool available: false
property string defaultZone: ""
property var allZones: []
property var activeZones: ({})
property var zones: []
property var exposed: []
property var exposedDataStores: []
property int sshSessions: 0
property bool scanned: false
property string lastError: ""
readonly property bool busy: query.running || mutation.running
readonly property var zone: root.zones.length > 0 ? root.zones[0] : null
// The range Fedora Workstation opens by default, if this zone has it. Named
// separately because it is the single rule that explains almost everything
// on the exposed list.
readonly property var openRanges: (root.zone?.ports ?? []).filter(
spec => String(spec).indexOf("-") > 0)
readonly property bool wideOpen: root.openRanges.length > 0
function serviceCount(): int { return (root.zone?.services ?? []).length; }
function allowedByRange(entry: var): bool {
return String(entry?.allowedBy ?? "").indexOf("range") >= 0;
}
// What closing the open range would cut off, by name, so the consequence is
// stated before it happens rather than discovered afterwards.
function rangeDependents(): var {
return root.exposed.filter(entry => root.allowedByRange(entry));
}
function refresh(): void {
if (query.running)
return;
query.command = [root.helperPath, "snapshot"];
query.running = true;
}
function absorb(text: string): void {
try {
const parsed = JSON.parse(text);
root.running = parsed.running === true;
root.enabledAtBoot = parsed.enabledAtBoot === true;
root.available = parsed.available === true;
root.defaultZone = String(parsed.defaultZone ?? "");
root.allZones = Array.isArray(parsed.allZones) ? parsed.allZones : [];
root.activeZones = parsed.activeZones ?? ({});
root.zones = Array.isArray(parsed.zones) ? parsed.zones : [];
root.exposed = Array.isArray(parsed.exposed) ? parsed.exposed : [];
root.exposedDataStores = Array.isArray(parsed.exposedDataStores)
? parsed.exposedDataStores : [];
root.sshSessions = Number(parsed.sshSessions ?? 0);
root.lastError = String(parsed.error ?? "");
} catch (error) {
root.lastError = "Could not read the firewall's state.";
console.warn("Firewall: could not parse helper output:", error);
}
root.scanned = true;
}
function run(arguments: var): void {
if (mutation.running)
return;
root.lastError = "";
mutation.command = [root.helperPath].concat(arguments);
mutation.running = true;
}
function removeService(name: string): void { root.run(["remove-service", name]); }
function addService(name: string): void { root.run(["add-service", name]); }
function removePort(spec: string): void { root.run(["remove-port", spec]); }
function addPort(spec: string): void { root.run(["add-port", spec]); }
function setZone(interfaceName: string, zoneName: string): void {
root.run(["set-zone", interfaceName, zoneName]);
}
function setDefaultZone(zoneName: string): void { root.run(["set-default-zone", zoneName]); }
Component.onCompleted: root.refresh()
Process {
id: query
stdout: StdioCollector { onStreamFinished: root.absorb(this.text) }
stderr: StdioCollector {
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
}
}
Process {
id: mutation
stdout: StdioCollector { onStreamFinished: root.absorb(this.text) }
stderr: StdioCollector {
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
}
}
}
@@ -0,0 +1,256 @@
pragma Singleton
// Named focus modes, turned on by conditions rather than by alarms.
//
// A mode is active because something is true right now -- a game is running, a
// window is fullscreen on a given display, a workspace is focused, the clock is
// inside a window. That is re-evaluated continuously rather than fired once,
// which is the whole reason schedules here do not have the usual failure modes:
// there is no alarm to have missed. A machine asleep at 23:30, rebooted at
// 02:00, or opened at 08:00 into a window that has already passed all arrive at
// the correct answer by simply asking again.
//
// This deliberately does not own the manual timed session. FocusSession already
// does that, with its own capsule, shortcut, Quick Settings entry and contracts,
// and two things writing Do Not Disturb would fight. Modes whose only trigger is
// "manual" are started through FocusSession; everything automatic lives here,
// and defers entirely while a manual session is running.
import Quickshell
import Quickshell.Hyprland
import QtQuick
import qs.config
Singleton {
id: root
readonly property var modes: {
const stored = DesktopPreferences.get("focusModes");
return Array.isArray(stored) ? stored : [];
}
// Modes something could switch on without being asked.
readonly property var automatic: root.modes.filter(mode =>
mode.enabled === true && (mode.triggers ?? []).some(trigger => trigger.kind !== "manual"))
// Re-read on a timer only because one trigger kind depends on the clock.
// Everything else is driven by change signals; this is what makes a schedule
// a condition rather than an alarm.
property double nowMs: Date.now()
// Set by the gamemode start/end hooks over IPC. The hook is the only thing
// that reliably knows, and it is already talking to the shell.
property bool gameRunning: false
// The mode in force, or null. First match wins, so the order in the list is
// the priority: a person who wants Gaming to beat Sleep moves it up.
readonly property var activeMode: {
// A manual session owns Do Not Disturb while it runs. Evaluating on top
// of it would mean two owners for one piece of state and a restore that
// puts back whatever the loser happened to see.
if (FocusSession.active)
return null;
for (const mode of root.automatic) {
if (root.triggered(mode))
return mode;
}
return null;
}
readonly property bool active: root.activeMode !== null
// Applications the mode in force lets through anyway, by the same id the
// per-application rules use. Empty whenever no mode is active, which is
// what keeps a Do Not Disturb somebody set by hand absolute: an exception
// belongs to a mode, so without a mode there are no exceptions.
readonly property var allowedApps: {
const mode = root.activeMode;
if (!mode || mode.silence !== true)
return [];
return Array.isArray(mode.allow) ? mode.allow.map(String) : [];
}
function allows(appId: string): bool {
return root.allowedApps.indexOf(String(appId)) >= 0;
}
readonly property string activeName: String(root.activeMode?.name ?? "")
// Why it is on, in the words the page uses, so "something silenced my
// notifications" is always answerable.
readonly property string activeReason: {
if (!root.activeMode)
return "";
for (const trigger of (root.activeMode.triggers ?? [])) {
if (root.triggerActive(trigger))
return root.describeTrigger(trigger);
}
return "";
}
function triggered(mode: var): bool {
return (mode.triggers ?? []).some(trigger => root.triggerActive(trigger));
}
function triggerActive(trigger: var): bool {
switch (String(trigger?.kind ?? "")) {
case "game":
// Told to us by the gamemode hook rather than read from the Gaming
// service, which only polls while its settings page is open and
// would therefore be stale exactly when a game starts.
return root.gameRunning;
case "schedule":
return root.withinWindow(trigger, new Date(root.nowMs));
case "workspace":
return Hyprland.focusedWorkspace?.id === Number(trigger.id ?? -1);
case "fullscreen": {
// Hyprland reports fullscreen on the workspace, not the window, and
// Quickshell has no typed property for it -- so this reads the raw
// IPC object, which does carry it and has a change notification, so
// the binding still updates. Checked rather than assumed: the typed
// property does not exist in this version.
const workspace = Hyprland.focusedWorkspace;
const fullscreen = workspace?.lastIpcObject?.hasfullscreen === true;
if (!fullscreen)
return false;
const monitor = String(trigger.monitor ?? "");
return monitor === "" || Hyprland.focusedMonitor?.name === monitor;
}
default:
// "manual" included: FocusSession owns those.
return false;
}
}
// Minutes since midnight, or -1 for anything unparseable. A malformed
// schedule must never read as "on" -- silencing someone because a string
// was wrong is the worst way for this to fail.
function minutesOf(text: string): int {
const match = /^(\d{1,2}):(\d{2})$/.exec(String(text ?? ""));
if (!match)
return -1;
const hours = Number(match[1]);
const minutes = Number(match[2]);
if (hours > 23 || minutes > 59)
return -1;
return hours * 60 + minutes;
}
// Is `when` inside this schedule?
//
// Windows that cross midnight are the case worth being careful about. A
// window belongs to the day it STARTS on, so 23:30 Friday to 07:00 Saturday
// is Friday's window, and Saturday morning is inside it only because Friday
// is enabled. Treating it as Saturday's would silence a Saturday morning
// nobody asked to be quiet.
function withinWindow(trigger: var, when: var): bool {
const start = root.minutesOf(trigger.start);
const end = root.minutesOf(trigger.end);
if (start < 0 || end < 0 || start === end)
return false;
const days = Array.isArray(trigger.days) ? trigger.days.map(Number) : [];
if (days.length === 0)
return false;
const day = when.getDay();
const minutes = when.getHours() * 60 + when.getMinutes();
if (start < end)
return days.indexOf(day) >= 0 && minutes >= start && minutes < end;
// Crosses midnight: either late on an enabled day, or early on the day
// after an enabled one.
const yesterday = (day + 6) % 7;
return (days.indexOf(day) >= 0 && minutes >= start)
|| (days.indexOf(yesterday) >= 0 && minutes < end);
}
function describeTrigger(trigger: var): string {
switch (String(trigger?.kind ?? "")) {
case "game":
return "a game is running";
case "schedule":
return "scheduled " + String(trigger.start ?? "") + " to " + String(trigger.end ?? "");
case "workspace":
return "workspace " + String(trigger.id ?? "");
case "fullscreen":
return String(trigger.monitor ?? "") === ""
? "a window is fullscreen"
: "a window is fullscreen on " + String(trigger.monitor);
case "manual":
return "started by hand";
default:
return "";
}
}
function summary(mode: var): string {
const triggers = (mode.triggers ?? []).map(trigger => root.describeTrigger(trigger))
.filter(text => text !== "");
const effects = [];
if (mode.silence === true)
effects.push("silences notifications");
if (mode.keepAwake === true)
effects.push("keeps the screen awake");
const allow = Array.isArray(mode.allow) ? mode.allow : [];
if (allow.length > 0)
effects.push(allow.length + (allow.length === 1 ? " app may interrupt" : " apps may interrupt"));
const when = triggers.length > 0 ? "When " + triggers.join(" or ") : "Never turns on";
return effects.length > 0 ? when + " · " + effects.join(", ") : when;
}
function save(next: var): void {
DesktopPreferences.set("focusModes", next);
}
function setEnabled(id: string, enabled: bool): void {
root.save(root.modes.map(mode =>
mode.id === id ? Object.assign({}, mode, { enabled: enabled }) : mode));
}
function update(id: string, changes: var): void {
root.save(root.modes.map(mode =>
mode.id === id ? Object.assign({}, mode, changes) : mode));
}
// ── applying ────────────────────────────────────────────────────────────
//
// What was true before a mode took over, so it can be put back. Recorded at
// the moment of taking over rather than read at release, which would return
// whatever the mode itself had set.
property bool holding: false
property bool previousDnd: false
property bool previousCaffeine: false
onActiveModeChanged: {
const mode = root.activeMode;
if (mode && !root.holding) {
root.previousDnd = Notifs.doNotDisturb;
root.previousCaffeine = Caffeine.enabled;
root.holding = true;
}
if (mode) {
if (mode.silence === true)
Notifs.doNotDisturb = true;
if (mode.keepAwake === true)
Caffeine.enabled = true;
return;
}
if (root.holding) {
Notifs.doNotDisturb = root.previousDnd;
Caffeine.enabled = root.previousCaffeine;
root.holding = false;
}
}
// Only runs while a schedule could change the answer. Thirty seconds is
// finer than any window boundary anyone sets and costs nothing.
Timer {
running: root.automatic.some(mode =>
(mode.triggers ?? []).some(trigger => trigger.kind === "schedule"))
interval: 30000
repeat: true
onTriggered: root.nowMs = Date.now()
}
}
+129
View File
@@ -0,0 +1,129 @@
pragma Singleton
// Gaming: what the machine is doing, and what it should do while you play.
//
// The reporting half is cheap -- GPU sensors come from sysfs, gamemode from its
// own daemon -- so this can poll while its page is open. It only polls then:
// a settings page nobody is looking at has no business waking the CPU twice a
// second.
//
// The acting half is not in this file at all. gamemode runs a script when a
// game starts and another when it exits, and that script reads the preferences
// directly, because the shell may have been restarted since the game launched.
import Quickshell
import Quickshell.Io
import QtQuick
Singleton {
id: root
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-gaming"
property var gameMode: ({})
property var gpus: []
property var overlay: ({})
property var library: ({})
property bool scanned: false
property string lastError: ""
// Set by the page while it is visible. Nothing polls otherwise.
property bool watching: false
readonly property bool busy: query.running || mutation.running
readonly property bool active: root.gameMode?.active === true
readonly property var primaryGpu: {
for (const gpu of root.gpus) {
if (gpu.discrete)
return gpu;
}
return root.gpus.length > 0 ? root.gpus[0] : null;
}
// The honest version: gamemode's headline trick is switching the governor,
// and it does nothing if the machine already runs that governor.
readonly property bool governorAlreadyThere:
String(root.gameMode?.governorNow ?? "") !== ""
&& root.gameMode?.governorNow === root.gameMode?.governorWhileGaming
function formatBytes(bytes: real): string {
if (!(bytes > 0))
return "0 GB";
return (bytes / 1073741824).toFixed(bytes < 10737418240 ? 1 : 0) + " GB";
}
function gpuSummary(gpu: var): string {
if (!gpu)
return "";
const parts = [];
if (gpu.temperatureC !== null && gpu.temperatureC !== undefined)
parts.push(gpu.temperatureC + " °C");
if (gpu.watts !== null && gpu.watts !== undefined && gpu.watts > 0)
parts.push(gpu.watts + " W");
if (Number(gpu.vramTotalBytes ?? 0) > 0)
parts.push(root.formatBytes(gpu.vramUsedBytes) + " / "
+ root.formatBytes(gpu.vramTotalBytes));
return parts.join(" · ");
}
function refresh(): void {
if (query.running)
return;
query.command = [root.helperPath, "snapshot"];
query.running = true;
}
function absorb(text: string): void {
try {
const parsed = JSON.parse(text);
root.gameMode = parsed.gameMode ?? ({});
root.gpus = Array.isArray(parsed.gpus) ? parsed.gpus : [];
root.overlay = parsed.overlay ?? ({});
root.library = parsed.library ?? ({});
root.lastError = String(parsed.error ?? "");
} catch (error) {
root.lastError = "Could not read the gaming helper's answer.";
console.warn("Gaming: could not parse helper output:", error);
}
root.scanned = true;
}
function run(arguments: var): void {
if (mutation.running)
return;
root.lastError = "";
mutation.command = [root.helperPath].concat(arguments);
mutation.running = true;
}
function setOverlay(enabled: bool): void { root.run(["set-overlay", enabled ? "true" : "false"]); }
function setOverlayPreset(preset: string): void { root.run(["set-overlay-preset", preset]); }
function installHooks(): void { root.run(["install-hooks"]); }
function removeHooks(): void { root.run(["remove-hooks"]); }
Process {
id: query
stdout: StdioCollector { onStreamFinished: root.absorb(this.text) }
stderr: StdioCollector {
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
}
}
Process {
id: mutation
stdout: StdioCollector { onStreamFinished: root.absorb(this.text) }
stderr: StdioCollector {
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
}
}
// Three seconds: fast enough that a temperature reading feels live, slow
// enough that it is not a background task of its own.
Timer {
running: root.watching
interval: 3000
repeat: true
onTriggered: root.refresh()
}
}
+53 -8
View File
@@ -277,30 +277,75 @@ Singleton {
// Grouping is by what the shortcut does, taken from its own description,
// so adding a bind puts it in the right section without touching this file.
// Which section a bind belongs to.
//
// "Windows" used to catch focus, movement, splitting, resizing and window
// state alike, which put 43 of the 93 binds under one heading -- a section
// that long is a list, not a grouping. The window verbs are separated here
// by what you are actually trying to do.
//
// Order matters: "Next window splits down" is about splitting rather than
// focus, and "Focus session" is a Panama feature rather than window focus,
// so both are settled before the general checks below them.
function groupFor(description: string, bind: var): string {
const text = description.toLowerCase();
if (bind.key && String(bind.key).indexOf("XF86") === 0)
return "Media & hardware keys";
// Quiet mode and Caffeine bound to a workspace, not window focus.
if (text.indexOf("focus session") >= 0)
return "Applications & shell";
if (text.indexOf("workspace") >= 0)
return "Workspaces";
if (text.indexOf("window") >= 0 || text.indexOf("focus") >= 0
|| text.indexOf("swap") >= 0 || text.indexOf("split") >= 0
|| text.indexOf("wider") >= 0 || text.indexOf("narrower") >= 0
if (text.indexOf("wider") >= 0 || text.indexOf("narrower") >= 0
|| text.indexOf("taller") >= 0 || text.indexOf("shorter") >= 0
|| text.indexOf("shrink") >= 0 || text.indexOf("grow") >= 0
|| text.indexOf("float") >= 0 || text.indexOf("fullscreen") >= 0
|| text.indexOf("close") >= 0 || text.indexOf("scratchpad") >= 0)
return "Windows";
|| text.indexOf("shrink") >= 0 || text.indexOf("expand") >= 0
|| text.indexOf("grow") >= 0 || text.indexOf("resize") >= 0)
return "Size";
if (text.indexOf("split") >= 0 || text.indexOf("swap") >= 0
|| text.indexOf("move window") >= 0)
return "Move & split";
if (text.indexOf("close") >= 0 || text.indexOf("fullscreen") >= 0
|| text.indexOf("float") >= 0 || text.indexOf("pin ") >= 0
|| text.indexOf("scratchpad") >= 0 || text.indexOf("minimize") >= 0)
return "Window state";
if (text.indexOf("focus") >= 0 || text.indexOf("next window") >= 0
|| text.indexOf("previous window") >= 0 || text.indexOf("last window") >= 0
|| text.indexOf("window switch") >= 0)
return "Focus";
if (text.indexOf("volume") >= 0 || text.indexOf("mute") >= 0
|| text.indexOf("track") >= 0 || text.indexOf("play") >= 0
|| text.indexOf("brightness") >= 0)
return "Media & hardware keys";
return "Applications & shell";
}
// Section order for the page. Anything a future bind invents lands at the
// end rather than being dropped.
readonly property var groupOrder: ["Windows", "Workspaces", "Applications & shell", "Media & hardware keys"]
readonly property var groupOrder: ["Focus", "Move & split", "Size", "Window state",
"Workspaces", "Applications & shell", "Media & hardware keys"]
// The action already bound to a chord, or "" if it is free. Compared on the
// form keybinds.lua writes rather than the prettified display form, because
// that is what a rebind is keyed by -- "SUPER + Q" and "Super+Q" are the
// same binding and must not read as two.
function boundTo(luaChord: string, exceptLuaChord: string): string {
const wanted = String(luaChord).replace(/\s+/g, "").toLowerCase();
const skip = String(exceptLuaChord).replace(/\s+/g, "").toLowerCase();
for (const bind of root.binds) {
const candidate = String(bind.luaChord).replace(/\s+/g, "").toLowerCase();
if (candidate === wanted && candidate !== skip)
return String(bind.description);
}
return "";
}
function grouped(): var {
const buckets = {};
@@ -39,6 +39,25 @@ Singleton {
readonly property bool replacementDaemon: root.daemon === "dbus"
// What is stored, never what is stored IN it. No property on this service
// ever holds a password: the value is read by the helper, handed straight
// to the clipboard, and forgotten. It does not cross into QML at all.
property var collections: []
property bool listed: false
property bool listing: false
property bool working: false
// Set for a moment after a successful copy, so the page can say what
// happened without the page having to know how long a clipboard lasts.
property string copiedPath: ""
readonly property int storedCount: {
let total = 0;
for (const collection of root.collections)
total += (collection.items ?? []).length;
return total;
}
function refresh(): void {
if (!query.running)
query.running = true;
@@ -53,6 +72,54 @@ Singleton {
unlockProcess.running = true;
}
// The stored-secret list. Separate from status() because it is the only
// read that needs the keyring UNLOCKED, and because a settings page should
// not enumerate someone's passwords just because it was opened.
function list(): void {
if (root.listing)
return;
root.listing = true;
items.command = [root.helperPath, "items"];
items.running = true;
}
// Puts one stored secret on the clipboard. The value never reaches this
// process; the helper reads it and writes it to wl-copy's stdin, and clears
// it again shortly afterwards if it is still there.
function copy(path: string): void {
if (root.working)
return;
root.working = true;
root.copiedPath = path;
items.command = [root.helperPath, "copy", path];
items.running = true;
}
// Irreversible. The page confirms before calling this.
function forget(path: string): void {
if (root.working)
return;
root.working = true;
root.copiedPath = "";
items.command = [root.helperPath, "forget", path];
items.running = true;
}
function absorbItems(text: string): void {
try {
const parsed = JSON.parse(text);
root.collections = Array.isArray(parsed.collections) ? parsed.collections : [];
root.lastError = String(parsed.error ?? "");
if (root.lastError !== "")
root.copiedPath = "";
} catch (error) {
root.collections = [];
root.lastError = "Could not read the stored secrets.";
console.warn("Keyring: could not parse item output:", error);
}
root.listed = true;
}
function absorb(text: string): void {
try {
const parsed = JSON.parse(text);
@@ -74,6 +141,27 @@ Singleton {
stdout: StdioCollector { onStreamFinished: root.absorb(this.text) }
}
// One process for all three item operations: each of them answers with the
// same list, so a page never has to ask again to find out what changed.
Process {
id: items
stdout: StdioCollector { onStreamFinished: root.absorbItems(this.text) }
stderr: StdioCollector {
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
}
onExited: {
root.listing = false;
root.working = false;
}
}
// The clipboard notice is transient, and says so by disappearing.
Timer {
running: root.copiedPath !== ""
interval: 12000
onTriggered: root.copiedPath = ""
}
Process {
id: unlockProcess
command: [root.helperPath, "unlock"]
+4 -1
View File
@@ -234,7 +234,10 @@ Singleton {
root.unreadCount += 1;
}
if (!root.doNotDisturb) {
// An exception belongs to the focus mode that is in force. A Do Not
// Disturb switched on by hand has no exceptions and stays absolute,
// because FocusModes.allows is false whenever no mode is active.
if (!root.doNotDisturb || FocusModes.allows(root.notificationAppId(notification))) {
root.popups = [notification].concat(root.popups);
} else if (notification.transient) {
// Never shown, and (being transient) never filed in history
@@ -25,7 +25,8 @@ Singleton {
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-accounts"
// [{ path, provider, providerName, identity, needsAttention, services: [{key,label,enabled}] }]
// [{ path, provider, providerName, providerIcons, identity, needsAttention,
// services: [{key,label,enabled}] }]
property var accounts: []
property bool scanned: false
property bool busy: false
@@ -0,0 +1,92 @@
pragma Singleton
// Which applications may use the camera and microphone.
//
// Backed by xdg-desktop-portal's permission store, which records the answer an
// application got when it asked through the portal. That is the whole of what
// this controls, and the limit belongs on the page rather than in a comment: a
// native binary opens /dev/video0 directly and no desktop setting stands in its
// way. What this covers is Flatpaks and anything else going through the portal.
import Quickshell
import Quickshell.Io
import QtQuick
Singleton {
id: root
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-permissions"
property bool available: false
property var devices: []
property bool scanned: false
property string lastError: ""
// Guards read the Processes directly rather than a derived binding, which
// returns its cached value inside the handler that changes its dependency.
readonly property bool busy: query.running || mutation.running
// Devices something has actually asked for. A device nothing has asked for
// is still reported, so the page can say so rather than omit it.
readonly property var recorded: root.devices.filter(
device => (device.applications ?? []).length > 0)
readonly property int grantedCount: root.devices.reduce(
(total, device) => total + (device.applications ?? []).filter(app => app.allowed).length, 0)
function refresh(): void {
if (query.running)
return;
query.command = [root.helperPath, "snapshot"];
query.running = true;
}
function absorb(text: string): void {
try {
const parsed = JSON.parse(text);
root.available = parsed.available === true;
root.devices = Array.isArray(parsed.devices) ? parsed.devices : [];
root.lastError = String(parsed.error ?? "");
} catch (error) {
root.lastError = "Could not read the portal's permissions.";
console.warn("Permissions: could not parse helper output:", error);
}
root.scanned = true;
}
function run(arguments: var): void {
if (mutation.running)
return;
root.lastError = "";
mutation.command = [root.helperPath].concat(arguments);
mutation.running = true;
}
function setAllowed(device: string, app: string, allowed: bool): void {
root.run(["set", device, app, allowed ? "allow" : "deny"]);
}
// Drops the recorded answer entirely, so the application is asked again the
// next time it wants the device.
function forget(device: string, app: string): void {
root.run(["forget", device, app]);
}
Component.onCompleted: root.refresh()
Process {
id: query
stdout: StdioCollector { onStreamFinished: root.absorb(this.text) }
stderr: StdioCollector {
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
}
}
Process {
id: mutation
stdout: StdioCollector { onStreamFinished: root.absorb(this.text) }
stderr: StdioCollector {
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
}
}
}

Some files were not shown because too many files have changed in this diff Show More