Author SHA1 Message Date
Gabriel Brown 27af4fd443 Carry the colour scheme into btop and tmux
Two more applications that keep their own palette and so never followed
the desktop. btop was on "Default" and had never been themed at all;
tmux had Tokyo Night Moon hardcoded across seventeen lines, which meant
a dark status bar sitting under a light terminal in light mode.

Both themes are authored rather than borrowed. btop ships a
"tokyo-night" theme, but it is the Night variant (#1a1b26) where the
rest of this desktop is Moon (#222436), and two Tokyo Nights side by
side read as a mistake; it ships no Tokyo Night light theme at all.
Colours come from kitty's theme files so a terminal and what runs inside
it cannot disagree.

tmux follows kitty's shape: the colours move to themes/, tmux.conf
sources a generated current-theme.conf, and running servers are
re-sourced so an open session changes immediately rather than at next
launch. btop is different -- it OWNS btop.conf and rewrites it on exit,
so only the color_theme line is edited in place and the file is not
symlinked into the repository. btop reads its theme once at startup, so
a running instance keeps the old colours; forcing a restart would kill a
process the user is watching.

The tmux light theme took two passes. Mapping the palette role-for-role
put the standard Day accents on a mid-grey panel at 2.74:1 and 2.94:1 --
under the 3:1 floor, on a bar you read at a glance. It now uses Day's
darker accent variants on a lighter panel: 4.42:1 and 4.17:1, and
5.01:1 / 4.73:1 for the light text inside the inverted blocks.

The helper also now reports every target rather than only kitty. Saying
"kitty: applied" while silently skipping three other applications is
how a half-applied theme goes unnoticed.

Not changed: bat is configured with --theme ansi, which follows the
terminal's own palette, so it already tracks kitty with nothing to do.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 11:04:00 -04:00
Gabriel Brown b16834fea6 Version the settings file so it can be upgraded
The schema is the single source of truth for what a setting IS. It
cannot express what a setting USED to be -- and renaming a key, changing
its units, or splitting one setting into two all leave a stored value
the new schema does not recognise. Unrecognised keys are deliberately
carried through untouched so that rolling back to an older Panama does
not discard a newer version's settings, which means the user's choice
silently stops taking effect with nothing to explain it.

settings.json now carries a schemaVersion, and load() runs every pending
migration before anything reads a value. The list is empty: the point is
that the first breaking schema change becomes a routine edit rather than
an emergency, and Omarchy carries eighty of these.

The behaviours that make it safe to run against a real user's file:

  A file with no schemaVersion predates this and is STAMPED, not
  migrated -- running the list against it would apply upgrades designed
  for schemas it never had.

  A file from a NEWER Panama is left completely alone. Downgrading keys
  is not something this can do correctly, and unknown keys already
  survive, so an older build simply ignores what it does not understand.

  A step that throws stops at the last good version. Skipping past it
  would lose that conversion forever; failing the whole load would cost
  the user every setting.

The list being empty is exactly why this is tested now: the first time
it runs for real will be against somebody's actual settings during an
upgrade, which is a poor moment to find out how it behaves. The harness
supplies fixture steps including one that throws, and the contract pins
all four behaviours above plus the promise that unknown keys survive.

One thing the contract earned its place on: stamping a pre-versioning
file changes it without running any step, so writing only on "migrated"
left the stamp in memory to be redone on every launch. It now writes
whenever the version moves, and explicitly does not write a file from
the future.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 10:59:34 -04:00
Gabriel Brown 4279da999a Share a Wi-Fi network by QR code
GNOME's Wi-Fi panel has this and it is the most-used thing in it: the
alternative is reading a passphrase out loud. Network & Devices now shows
a scannable code for any saved network whose passphrase this user can
read.

The image contains the network password in machine-readable form, so
most of the care here is about that rather than about QR codes. It is
written under XDG_RUNTIME_DIR -- 0700, on tmpfs, gone at logout --
rather than /tmp, which is shared; the file is 0600; the passphrase is
piped to qrencode on stdin rather than passed as an argument, because
argv is world-readable through /proc for as long as the process runs;
and it is never printed or included in an error message. Generated on
demand, because producing a code for every saved network up front means
writing images of passwords nobody asked to see.

Enterprise networks are listed as not shareable rather than offered and
broken: there is no passphrase to encode, so the code could not work.

Two bugs the contract caught while being written. Semicolons in an SSID
were not escaped -- the sed replacement had one backslash where its four
neighbours have two, so sed dropped it, and an SSID containing a
semicolon would have produced a QR code describing a different network.
And nmcli's trailing newline landed inside the payload; it decoded here,
but a newline in the middle of a WIFI: URI is not something every phone
tolerates, and that failure would present as "the QR code just doesn't
work on my phone".

The contract stubs nmcli and qrencode, because the real ones would write
this machine's actual Wi-Fi password into a fixture directory. It
asserts the escaping, the absence of a newline, the file and directory
modes, that no temporary payload survives, and that the passphrase never
reaches argv.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 10:56:01 -04:00
Gabriel Brown 54b8a82803 Give Accessibility a real magnifier, and stop implying the rest is available
The page had two settings and a hand-off card claiming GNOME's stack
provided "screen reader, zoom, and on-screen keyboard". Zoom did not
need handing off at all, and the claim about the rest was optimistic.

Zoom is native now. Hyprland has a real magnifier -- cursor:zoom_factor
follows the pointer -- so it is a slider here rather than a button that
opens another application. Inactive windows can also be dimmed as well
as faded, which is the other thing that makes a focused window
unmistakable. 47 mapped compositor options, from 43.

What is NOT here is the more useful half of the change. Sticky keys,
slow keys, bounce keys and mouse keys are AccessX, an X11 SERVER
feature. XKB under Wayland has no accessx option group at all -- checked
against evdev.lst, which lists altwin, caps, compose, ctrl, grp and the
rest, and nothing resembling accessx -- and Hyprland implements none of
it. The compositor will accept "accessx:enable" as a keyboard option and
store it happily; I verified that, and verified nothing acts on it.

So the page says plainly that they are unavailable in this session
rather than offering switches, and does not point at GNOME's panel for
them either: the daemon that would apply those keys is not running here,
so that hand-off would be just as empty. Orca is offered instead, since
the accessibility bus genuinely does work.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 10:49:04 -04:00
Gabriel Brown 2e8292599a Add power profiles to Power & Lock
The same Power Saver / Balanced / Performance choice GNOME's Power panel
offers, and the daemon behind it was already running here -- it simply
had no control anywhere in Panama. This machine has been sitting on
"performance" with nothing to say so.

Talks to the net.hadess.PowerProfiles interface rather than to a binary.
Fedora 44 implements it with tuned-ppd instead of power-profiles-daemon,
and powerprofilesctl is not installed at all, so anything shelling out
to that command would have found nothing while the service was right
there. Setting a profile needs no privileges: the daemon accepts a
property write from the active session user.

Not a stored preference. The daemon owns the profile, it survives Panama
restarts, and anything else on the system can change it, so a copy in
settings.json would drift -- the same reasoning as monitor brightness.

PerformanceDegraded is surfaced because it is what makes the setting a
lie: a thermally throttled machine reports "performance" while behaving
otherwise, and that is worth saying out loud.

The contract stubs busctl, because the real daemon is a system service
shared with everything else on the machine and a test that flipped the
daily driver to power-saver and then died would leave it there. It pins
the parsing in particular: busctl renders the Profiles property flat, so
profile names and driver names arrive in one stream, and a pattern loose
enough to match both reports the driver as an extra profile. On this
machine the driver is called "tuned", which reads exactly like a
plausible fourth profile -- verified the contract catches that, and
catches an unvalidated name reaching the system service.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 10:46:03 -04:00
Gabriel Brown 5562323eb8 Stop the live shell clobbering the write contract
This contract failed intermittently with "a typed batch did not reach
the compositor", reporting Panama's shipped defaults, and passed on
retry. The earlier guard against a lingering harness was not the cause --
no instance was alive.

Adding the writer's own error to the failure message settled it: the
writer reports NO error while the compositor holds defaults. A rejected
write leaves an error behind; a write that succeeded and was then
overwritten does not. Both this contract and the LIVE shell write to the
same compositor, and the running Panama re-applies its own preferences
on any store change -- landing exactly the values that were being
mistaken for "the write never happened".

So the apply is re-issued periodically while waiting, which makes the
test survive being overwritten without weakening what it asserts, and
the failure message now distinguishes the two cases instead of
describing both as a write that never arrived.

The wait is also 10 seconds rather than 4. The write path verifies every
option by reading it back and retries a refused batch, so a loaded
machine legitimately takes longer -- and this runs in a suite alongside
other tests driving the same compositor.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 10:39:42 -04:00
Gabriel Brown 52d896b054 Add an Online Accounts page
GNOME Online Accounts is a daemon plus a D-Bus API, and the daemon
already runs in this session -- gvfs activates it, and all four accounts
on this machine work without gnome-shell involved anywhere. Only the
PANEL was GNOME's. The accounts themselves are ordinary D-Bus objects
that anything may read and modify.

So everything except the initial sign-in is now native: the account
list, per-service toggles for mail, calendar, contacts, files, photos,
music and chat, and removal. That is the whole Online Accounts panel
apart from one OAuth handshake.

Signing in is the exception, and only for OAuth providers. The daemon's
AddAccount takes credentials as an argument -- it stores them, it does
not obtain them -- and the code that runs Google's OAuth exchange lives
in libgoa-backend, which Fedora ships without a GIR binding, so it is
reachable from C only. Reimplementing it would mean our own Google
client credentials. That step is handed to GNOME's panel and the page
says so, because a hand-off the user does not expect reads as a bug.
Password-based providers (Nextcloud, IMAP, WebDAV) could be added
natively later; their credential keys are known now.

Accounts needing re-authentication are surfaced first, which turned up
something immediately: both Google accounts on this machine report
attention_needed, meaning their tokens have expired and they have
stopped syncing. GOA has known that all along and nothing outside its
own panel ever said so.

Every write re-reads the account list rather than assuming it landed.
GOA can refuse, and a toggle that springs back is the honest outcome.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 10:34:41 -04:00
Gabriel Brown e4409ed6aa Surface the login keyring, and offer to unlock it
The keyring is already unlocked at sign-in exactly as GNOME does it --
pam_gnome_keyring is in GDM's stack and the journal confirms it works
("gnome-keyring-daemon started properly and unlocked keyring"). So there
was no configuration bug to fix. What a bare Hyprland session lacks is
anywhere to see when that has stopped being true.

It stops being true rarely and expensively. gnome-keyring-daemon crashed
once on this machine -- an upstream abort in service_method_open_session,
with a core dump -- and D-Bus then activated a replacement. That
replacement never received the login password, so the keyring was locked
in the middle of a session that had unlocked it correctly at login.
Nothing announces this. What you see instead is a mail account that will
not authenticate, a git push that cannot find its key, or an integration
reporting "not configured", none of which mention keyrings. That is the
same root cause as the Home Assistant token failure earlier.

Privacy & Security now shows the state, offers an Unlock action that
raises the standard password dialog, and reports when the daemon holding
your secrets is a D-Bus replacement rather than PAM's -- because a
replacement that is currently unlocked was unlocked by hand and will not
survive a restart. The password never passes through Panama.

The contract stubs the secret service rather than touching the real one:
locking the login keyring breaks every saved password on the machine and
can only be undone by typing the password into a dialog, so it is not
something a test suite may do to a daily driver. Verified it catches a
helper that misreports locked as unlocked, and one that crashes instead
of reporting a missing service.

Worth recording: a locked keyring makes a NON-INTERACTIVE caller appear
to hang. It is not hung -- it is waiting on a dialog nobody is looking
at, which is exactly how the earlier secret-tool investigation lost an
hour.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 10:34:41 -04:00
Gabriel Brown f0321432b4 Fix dark mode: the GTK theme it asked for does not exist
Reported symptom: Electron applications and a Chromium-based browser,
all set to follow the system, went light when the desktop went light and
never came back. Nothing reported an error, and the portal was serving
the correct value the whole time.

Cause: ColorScheme set gtk-theme to "Adwaita-dark" for dark and
"Adwaita" for light. Neither is installed on Fedora 44 -- only adw-gtk3
and adw-gtk3-dark are. GTK responds to an unknown theme name by falling
back to its built-in default, which is LIGHT. So asking for light worked
by accident, asking for dark silently produced light, and anything that
takes its cue from the GTK theme rather than the portal stayed light no
matter what org.freedesktop.appearance said. Verified: the portal emits
correctly in both directions, so this was never the portal's fault.

Second cause, the mirror of the first: gtk-3.0/settings.ini and
gtk-4.0/settings.ini were pinned to adw-gtk3-dark and prefer-dark=1 and
never regenerated. Under GNOME that file is ignored because
gnome-settings-daemon publishes over XSETTINGS; under Hyprland nothing
does, so for GTK3 it is authoritative -- and it contradicted the scheme
in light mode. They are now generated from a template on every switch,
gitignored as machine state, and seeded by link-dotfiles, the same shape
kitty's current-theme.conf already uses. These directories are symlinked
into the repository, so writing the live file directly would dirty the
working tree on every theme switch.

The failure was silent by construction, so it gets a test rather than a
comment: gtk-theme-contract asserts every theme name Panama sets is
actually installed, and that the generated GTK config agrees with the
scheme in both directions. Verified it catches both original bugs.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 10:34:19 -04:00
Gabriel Brown 607acb0a2d Make About answer what fastfetch answers
About reported four component versions and, since this morning, a short
hardware summary. It now covers what someone actually wants from an
About page or a fastfetch run: operating system, model, hostname,
kernel, uptime, package counts, shell, locale, windowing system,
resolution, processor, graphics, memory, swap, and disk.

Rows are ordered the way fastfetch presents them -- what the system is,
then what is installed on it, then the hardware underneath -- and the
GPU rows are spliced in directly after Processor rather than appended,
because a graphics card listed after "Disk" reads as an afterthought.
Graphics is still joined from GraphicsDevices rather than read a second
time, so the two readouts cannot disagree.

Memory is total, not used. About is not a monitor: a "12.4 GiB used"
figure is stale before it finishes drawing, and the Home page's vitals
readout is where live numbers belong. Disk is the exception because free
space does not move while you look at it.

Package counting is why this whole helper runs on demand -- rpm -qa on a
full workstation is a few thousand lines and takes a moment. Swap and
package counts are omitted entirely when they are zero or the tools are
absent, rather than reported as "0" or "Unknown".

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 10:09:06 -04:00
Gabriel Brown 9b2fa80ab7 Drop the product branding: this is just Settings
It is the system settings app for this desktop, so it is named the way
one is. The window is "Settings", the titlebar is "Settings", the
wordmark above the search field is gone, the sidebar's last row is
"About", and the status line reads "Desktop is healthy".

Prose followed the same rule. Forty-odd strings explained what "Panama"
does -- "Panama never animates while idle", "Restore Panama defaults",
"Panama looks in ~/Pictures/Wallpapers" -- which is how a product
describes itself, not how a settings panel describes a setting. They now
say what happens. No user-visible "Panama" remains anywhere in the app.

Two consequences worth naming:

The SUPER+I shortcut's description is user-visible, because the
Shortcuts page is generated from it, so that is renamed too. Rebindings
are keyed by shipped chord rather than description, so no existing
override is orphaned by this.

Three contracts matched the window by title and one matched that
shortcut by description; all four are updated. There are no Hyprland
window rules keyed on the title, so nothing about the window's placement
changes.

The desktop entry is now Name=Settings, but the FILE keeps its
panama-settings name, as does the icon: the dock pins applications by
desktop id, and renaming the file would silently unpin it.
dock-pins-contract covers exactly that.

The dated design docs under docs/superpowers keep the old name. They are
a record of what was decided when, and editing them to agree with the
present would make them lie about the past.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 10:08:55 -04:00
Gabriel Brown 816ea68f9a Read DDC/CI from the AUX bus, not the EDID bus
With I2C access granted, the helper still found nothing: every connected
monitor was probed on the wrong bus.

A connector has two. The `ddc` symlink points at the classic I2C line
that carries EDID on HDMI and DVI. DisplayPort carries DDC/CI over the
AUX channel instead, which appears as a child directory of the
connector. Both exist on a DP connector and both resolve, so the wrong
choice looks entirely reasonable and simply finds no monitor: this
machine's DP-2 has ddc -> i2c-5, where ddcutil reports "No monitor
detected", while its AUX child i2c-9 answers VCP 0x10 immediately.

This is the guess the previous commit said was unverified, and it was
wrong in the way that mattered. Enumerating from sysfs is still right --
it gives the connector name Hyprland uses and skips empty connectors --
but it has to prefer the AUX child and fall back to the symlink.

The fixture now mirrors sysfs properly: /sys/class/drm/<connector> is a
SYMLINK to the real device directory, and `find` does not follow the
path it is given. A fixture built from plain directories passes whether
or not the code resolves the symlink first, which is a test that agrees
with itself rather than with the kernel.

Verified on hardware: the Kuycon P20 reports 100%, accepts 70 and
returns to 100, and the media keys move it through codex's OSD path.
Both bus-selection mistakes are now caught by the contract.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 09:51:55 -04:00
Gabriel Brown 3ff414fb1b Stop two contracts from driving each other's shell
settings-hyprland-write-contract failed in full suite runs and passed on
its own, reporting "a typed batch did not reach the compositor" with
every value at its default.

Both it and settings-commit-reset-contract drive the same harness file,
and Quickshell identifies an instance by its config path -- not by the
environment it was launched with. So when one run's instance has not
fully exited, the other's wait for `ipc show` is satisfied by that
instance's target, and the whole contract then talks to a shell it did
not start.

The two directions fail differently, and the second is the alarming one:

  The write contract lands on the isolated instance, whose compositor
  write seam is deliberately stubbed. Its writes go nowhere, which is
  exactly the symptom above.

  The commit/reset contract lands on the non-isolated instance and
  drives the DAILY DESKTOP's real compositor while believing it is
  isolated.

Both now refuse to start while another instance of that harness is
alive, and say which hazard they are avoiding rather than failing on an
assertion much later.

One trap worth naming, since it bit me writing this: `rg -c` prints
nothing at all when there are no matches, so an unguarded command
substitution yields "" and not "0" -- the first version of the guard
fired on a perfectly clean machine.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 08:43:59 -04:00
Gabriel Brown 0fd59c59e3 Answer GNOME's Search panel, and pin the settings navigation wiring
GNOME's Search panel configures which applications provide results in
gnome-shell's overview and which folders are indexed. gnome-shell does
not run here, so reimplementing those switches would store preferences
that change nothing. Under Panama searching is the launcher's job and
Vicinae carries its own preferences, so the Applications page says where
search lives and how to rebind the keys that open it, rather than
offering settings that would be inert or duplicated.

The nav contract is the more useful half. Adding a settings page means
editing four files, and missing any one of them fails quietly in a
different way: no sidebar row, a row that silently shows Home, a deep
link that redirects to Home, or -- worst -- a missing qmldir entry,
which makes the page "not a type" and takes the entire settings window
down with it. Nothing at runtime cross-checks the four. Having just
added four pages by hand, this checks them statically, and also fails on
a page file that exists but is unreachable.

Verified it catches a missing qmldir registration and a missing
allow-list entry.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 08:38:54 -04:00
Gabriel Brown b1f9891849 Add multitasking controls, and a light theme for the launcher
Two things, both closing gaps in work that was already reported done.

GNOME's Multitasking panel, in Hyprland's terms: tiling layout, split
behaviour, floating-window snapping, workspace wrap-around and
back-and-forth, whether applications may take focus, and whether the
pointer changes the active display. Hyprland creates and destroys
workspaces as you use them, so there is no fixed count to expose, and
the page says so rather than leaving a conspicuous absence.

The Desktop page described the first two of these as read-only facts --
"Layout: Tiling", "Workspace movement: Dynamic" -- which was never true.
Both are ordinary Hyprland options that happened to have no controls,
and TextRow's own documentation says a setting the user could reasonably
change does not belong in it. Schema defaults are Panama's shipped
values from looks.lua rather than Hyprland's own, so restoring defaults
returns the desktop to how it ships. 43 mapped options now, from 35.

The launcher had no light theme. vicinae.json already selected a theme
per system appearance, but both entries pointed at Moon, so choosing
light mode left the most frequently opened window on the desktop dark --
a hole in the light/dark work, not a missing feature. Day is authored
from the same palette as the kitty Day theme so the two cannot drift,
and link-dotfiles now installs every authored theme rather than only the
dark one, which is why the gap survived being noticed.

Its placeholder colour is not Tokyo Night Day's own: that measures
2.54:1 against the background, below the 3:1 floor for secondary text.
This is 3.25:1, the same value used for neovim's light comments.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 08:34:05 -04:00
Gabriel Brown 6026bf308b Add Region & Language, and answer "what am I running on" in About
More GNOME Settings parity.

Region & Language is new. The locale is localectl's, and Panama stores no
copy of it -- there is exactly one system locale, so a preference here
would be a second source of truth that drifts the moment anything else
changes it. Codes are resolved against iso-codes into "Portuguese
(Brazil)" the way GNOME does, with the code kept visible because it is
what actually gets written and someone choosing between two Spanish
variants needs to see it. Changing it is privileged and only applies to
programs started afterwards, so the page says a sign-out is needed
rather than claiming the new language is in use.

The service is called SystemLocale, not Locale: QML has a built-in
Locale value type that silently shadows a singleton of that name, and
every binding then reads properties off the wrong thing. The page
rendered empty with nothing but "cannot read property of undefined" to
explain it.

About now answers what GNOME's About answers -- model, processor,
memory, disk, OS, kernel, windowing system -- where before it listed
only Panama's own component versions. Graphics is joined from
GraphicsDevices rather than read again, because two readouts of the same
hardware are two things that can disagree. Placeholder DMI strings
("To Be Filled By O.E.M.") are filtered out, and unreadable facts are
omitted rather than shown as "Unknown".

The Fedora hand-off card was one row listing five subjects that opened
the network panel regardless. Naming a panel and then not opening it
reads as a broken button rather than a deliberate hand-off. Each subject
now opens the panel that owns it, and openGnomePanel takes an optional
subpage so "Users" reaches System's users page the way GNOME's own
desktop entry does, instead of dropping the user on System's front page.
Printers and online accounts are not repeated here; they stay with the
network hardware on Network & Devices.

One bug this surfaced, caught by the hyprland write contract: the Lua
config key and the hyprctl option name genuinely differ for tap to
click. hl.config wants input.touchpad.tap_to_click; getoption answers to
input:touchpad:tap-to-click. Either spelling used for both fails -- a
hyphen is not a Lua identifier, and the underscored name is not a known
option -- which is what the schema's two separate fields are for.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 08:30:21 -04:00
Gabriel Brown 14529c011f Add a Privacy & Security page
Continuing towards GNOME Settings parity. GNOME's Privacy panel covers
screen lock, camera and microphone access, file history, trash, and
device security; Panama had no equivalent page at all, despite already
tracking camera and microphone use for the bar indicator.

Device security is a new read-only readout: Secure Boot, TPM, disk
encryption, SELinux mode, and the firewall. None of these is a
preference -- they are set in firmware, at install time, or by system
policy, and a switch offering to change them would either fail or do
something far-reaching from a control that looks like every other
control. What it answers is "is this machine set up the way I think it
is", which otherwise takes five commands and root. Facts that cannot be
determined report Unknown rather than guessing, because a security
readout that quietly says "fine" when it failed to look is worse than
no readout.

File history and trash retention are deliberately NOT offered as
switches. They are GNOME preferences enforced by gsd-housekeeping, which
does not run in a Hyprland session -- verified, it is not running here.
Toggling them would store a preference, change nothing, and give no sign
of it. They are delegated to GNOME Settings by name instead.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 08:20:04 -04:00
Gabriel Brown 7378c18ab9 Merge remote-tracking branch 'origin/fix/osd-ddc-brightness' 2026-08-18 08:16:49 -04:00
Gabriel Brown 9ce7040b91 Add a Mouse & Touchpad page and make keyboard layout editable
Working towards parity with GNOME Settings, which splits pointing
devices into their own panel. Panama had pointer speed and focus-follows
buried under a page called "Input & Shortcuts", and had nothing at all
for scroll direction, acceleration profile, scroll speed, left-handed
buttons, or any touchpad setting -- all of which could only be changed
by editing hypr/input.lua by hand, which is the thing this app exists to
stop.

Every new mapping was read back off the running compositor rather than
assumed, and two were not what they look like: touchpad drag lock is an
int with three states, not a switch, and scroll factors are floats even
at their default of exactly 1. Getting either wrong makes every write to
that setting look rejected. The shape contract now covers 35 mapped
options, up from 23.

The touchpad card renders only when a touchpad is attached, which is
what InputDevices is for. On a desktop it would be worse than useless:
every switch on it would appear to work, because the preference is
stored and Hyprland accepts an option for a device class it has no
member of, so the settings would silently affect nothing.

Keyboard layout was read-only text, justified by a note saying changes
needed a compositor reload. That is not true in 0.56.2 -- setting
input:kb_variant through hl.config re-keymaps attached keyboards
immediately, verified by watching active_keymap on a real keyboard
change to "English (US, intl., with dead keys)" and back. So layout,
variant, and options are now real controls, joined by a TextEntryRow
that commits on Enter or focus loss rather than per keystroke, since
half a layout name is a valid string meaning something else.

Rejected input is shown as rejected rather than sanitised: these strings
are serialised into an hl.config payload, where stripping an unexpected
character would turn a typo into a different working setting.

Verified each new pointer option applies and reverts against the live
compositor. Schema, search, commit/reset, and system contracts pass.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 08:16:10 -04:00
Gabriel Brown 09e5f1ad6b Fix external monitor brightness OSD 2026-08-18 08:15:01 -04:00
Gabriel Brown 7541a45e77 Add external monitor brightness over DDC/CI
brightnessctl drives the kernel backlight class, which laptop panels
have and this desktop does not -- it reports only keyboard and NIC LEDs.
So BrightnessControl removed itself and there was no way to dim the
screen from Panama at all. DDC/CI is the channel the buttons on a
monitor's bezel drive, and it is the only brightness an external display
has. Both sources now render a row each, so a machine gets whichever it
actually has, or none.

Displays are enumerated from sysfs rather than `ddcutil detect`. The
kernel publishes the connector-to-bus mapping as
/sys/class/drm/<card>-<connector>/ddc along with whether anything is
plugged in, which beats parsing detect's undocumented brief output,
yields the connector name spelled exactly as Hyprland spells it, and
probes only connectors with a monitor attached -- one bus on this
machine rather than fourteen, where each empty bus costs a timeout.
No model name is read: Hyprland already knows what every output is
called, so the UI joins on the connector instead of keeping a second
source of truth that could disagree with the Displays page.

Writes are debounced, serial, and read back. Serial because DDC/CI has
no arbitration and two ddcutil processes on one bus interleave their
exchanges and both return garbage. Read back because a write is not a
promise: panels clamp to their own range, ignore values while waking
from standby, and drop writes that arrive too fast. Without the read the
slider would show what Panama asked for rather than what the monitor
did, which is the same class of lie as trusting `hyprctl keyword`.

Brightness is deliberately not a stored preference. The monitor
remembers it and the bezel buttons change it behind Panama's back, so
persisting it would mean restoring a value the panel had moved past.

The contract runs against fixtures with ddcutil stubbed and both sysfs
roots redirected, so it never touches a real monitor. Its fixture
reports a maximum of 200 rather than 100 on purpose -- at 100 the
scaling arithmetic is the identity and a helper that ignored the
reported maximum would pass everything. Verified it catches that, plus a
dropped connection-status filter and an unstripped connector prefix.

Not yet confirmed against hardware: this machine cannot open any I2C bus
yet. ddcutil's udev rule grants that through uaccess but only to devices
created after it was installed, so it needs one udevadm trigger. The
helper detects exactly that case and returns the command as its error
rather than reporting "no displays".

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 08:01:49 -04:00
Gabriel Brown 1a91d00f2c Stop two contracts from latching a broken desktop state
Both tests run against the live session and capture "what it was before"
so they can put it back. Neither checked that what they found was sane,
so one interrupted run poisoned every run after it -- and because each
subsequent run faithfully restored the bad value, the desktop stayed
broken while the failure looked like an ordinary flake.

displays-contract left the monitor at scale 1.25 after a failed revert.
The next run recorded 1.25 as the original and restored the desktop to
it. It now reads the shipped scale out of monitors.lua and refuses to
run when the live display disagrees. A failure to parse that value is
fatal rather than skipped, because silently skipping the check is how
the laundering happened in the first place.

focus-session-expiry kills and restarts the shell mid-session, so an
interrupted run leaves caffeine on with nothing left to turn it off. The
next `focus start` recorded "previously on", handed it back on expiry,
and failed the assertion that caffeine ends off -- identically, forever,
with the desktop unable to idle or lock the entire time. It now refuses
to start unless caffeine is already off, which is the only state in
which the test can tell "restored correctly" from "never released".

Both guards name the exact command to recover with. Verified each fires
on a dirty state and passes on a clean one; caffeine was found latched
on this machine and has been released.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 07:48:02 -04:00
Gabriel Brown 19063a3e02 Carry the colour scheme into terminals and the editor
The scheme switch already reached everything that reads
org.freedesktop.appearance -- GTK4, Qt6, Chromium, Electron -- because
ColorScheme.qml writes the gsettings key those all watch. Applications
carrying their own palettes did not follow, so choosing light mode left
the two windows actually used all day, kitty and neovim, still dark.

kitty: the 32 colours move out of kitty.conf into themes/, and kitty.conf
ends with `include current-theme.conf`. The generated file is machine
state rather than configuration, so it is gitignored and link-dotfiles
seeds it on install -- otherwise a fresh checkout starts by complaining
about a missing include. Running terminals are re-coloured in place over
their control sockets; a restart is not needed.

neovim: reads settings.json directly, since it neither watches the portal
nor keeps a socket open. Tokyo Night ships Day in the same family as
Moon, so light mode keeps the editor's identity instead of turning it
into a different-looking application. The existing readability overrides
were written against Moon and are now dark-only -- applied to Day they
would have put light grey on a light background, the same problem they
exist to fix, inverted. Light mode gets one override of its own:
tokyonight's shipped comment colour measures 2.54:1 against Day's
background, under the 3:1 floor for secondary text, so it is replaced
with 3.25:1 -- readable, still dimmer than Normal's 4.52:1.

An editor already open when the scheme flips re-applies on FocusGained,
which is cheap and fires exactly when the mismatch would be noticed.

Verified both directions: kitty re-coloured 4 live terminals, and neovim
starts as tokyonight-day with background=light and tokyonight-moon with
background=dark.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 07:47:51 -04:00
94 changed files with 5327 additions and 250 deletions
+8
View File
@@ -13,3 +13,11 @@
# Python helper bytecode is local runtime state.
__pycache__/
*.py[cod]
# Generated from the colour scheme; machine state, not configuration.
/config/dot/kitty/current-theme.conf
# Generated from the colour scheme; machine state, not configuration.
/config/dot/gtk-3.0/settings.ini
/config/dot/gtk-4.0/settings.ini
/config/dot/tmux/current-theme.conf
+1 -1
View File
@@ -35,7 +35,7 @@ same Tokyo Night Moon palette.
| Piece | What it is |
|---|---|
| `config/dot/hypr/` | Compositor config. **Lua, not hyprlang** — see its README |
| `config/dot/quickshell/` | The shell: bar, dock, Continuum overview, Panama Settings, Screen Intelligence, focus sessions, quick settings, notifications, screenshot UI |
| `config/dot/quickshell/` | The shell: bar, dock, Continuum overview, Settings, Screen Intelligence, focus sessions, quick settings, notifications, screenshot UI |
| `config/dot/vicinae/` | Raycast-style launcher, themed |
| `config/dot/uwsm/` | Session environment (see the uwsm caveat in the hypr README) |
| `config/dot/wofi/` | Fallback launcher, in case the shell fails to start |
@@ -0,0 +1,57 @@
# Tokyo Night Day for btop, the light counterpart to tokyonight-moon.
#
# btop ships no Tokyo Night light variant at all, and the nearest stock light
# theme (flat-remix-light) is a different palette that happens to have a similar
# background. Same colours as kitty/themes/tokyonight-day.conf so the terminal
# and what runs inside it cannot disagree.
#
# Gradients keep the same low -> middle -> high meaning as the dark theme, using
# Day's darker, more saturated accents: the light versions of these hues are too
# faint to read as a filled meter.
theme[main_bg]="#e1e2e7"
theme[main_fg]="#3760bf"
theme[title]="#3760bf"
theme[hi_fg]="#2e7de9"
theme[selected_bg]="#c4c8da"
theme[selected_fg]="#2e7de9"
theme[inactive_fg]="#7079a8"
theme[proc_misc]="#587539"
theme[cpu_box]="#a8aecb"
theme[mem_box]="#a8aecb"
theme[net_box]="#a8aecb"
theme[proc_box]="#a8aecb"
theme[div_line]="#c4c8da"
theme[temp_start]="#2e7de9"
theme[temp_mid]="#8c6c3e"
theme[temp_end]="#f52a65"
theme[cpu_start]="#2e7de9"
theme[cpu_mid]="#9854f1"
theme[cpu_end]="#f52a65"
theme[free_start]="#c4c8da"
theme[free_mid]="#007197"
theme[free_end]="#2e7de9"
theme[cached_start]="#007197"
theme[cached_mid]="#2e7de9"
theme[cached_end]="#9854f1"
theme[available_start]="#8c6c3e"
theme[available_mid]="#b15c00"
theme[available_end]="#f52a65"
theme[used_start]="#587539"
theme[used_mid]="#8c6c3e"
theme[used_end]="#f52a65"
theme[download_start]="#c4c8da"
theme[download_mid]="#2e7de9"
theme[download_end]="#007197"
theme[upload_start]="#c4c8da"
theme[upload_mid]="#9854f1"
theme[upload_end]="#7847bd"
@@ -0,0 +1,56 @@
# Tokyo Night Moon for btop, matched to Panama's palette.
#
# btop ships a "tokyo-night" theme, but it is the Night variant (#1a1b26). The
# rest of this desktop is Moon (#222436), and two Tokyo Nights side by side read
# as a mistake rather than as a choice.
#
# The *_start/_mid/_end triples are gradients btop draws meters with: low,
# middle, and high. They run blue -> yellow -> red so a saturated resource is
# obvious at a glance without reading the number.
theme[main_bg]="#222436"
theme[main_fg]="#c8d3f5"
theme[title]="#c8d3f5"
theme[hi_fg]="#82aaff"
theme[selected_bg]="#3b4261"
theme[selected_fg]="#82aaff"
theme[inactive_fg]="#636da6"
theme[proc_misc]="#a5e8b5"
theme[cpu_box]="#4d5685"
theme[mem_box]="#4d5685"
theme[net_box]="#4d5685"
theme[proc_box]="#4d5685"
theme[div_line]="#3b4261"
theme[temp_start]="#82aaff"
theme[temp_mid]="#ffc777"
theme[temp_end]="#ff757f"
theme[cpu_start]="#82aaff"
theme[cpu_mid]="#c099ff"
theme[cpu_end]="#ff757f"
theme[free_start]="#3b4261"
theme[free_mid]="#589ed7"
theme[free_end]="#86e1fc"
theme[cached_start]="#86e1fc"
theme[cached_mid]="#82aaff"
theme[cached_end]="#c099ff"
theme[available_start]="#ffc777"
theme[available_mid]="#ff966c"
theme[available_end]="#ff757f"
theme[used_start]="#a5e8b5"
theme[used_mid]="#ffc777"
theme[used_end]="#ff757f"
theme[download_start]="#3b4261"
theme[download_mid]="#82aaff"
theme[download_end]="#86e1fc"
theme[upload_start]="#3b4261"
theme[upload_mid]="#c099ff"
theme[upload_end]="#fca7ea"
@@ -1,3 +1,12 @@
# GENERATED FILE -- edit settings.ini.template instead.
#
# The theme name and dark preference below follow Panama's colour
# scheme, so this file is regenerated on every switch and is not
# committed. Under GNOME, gnome-settings-daemon publishes these over
# XSETTINGS and this file is ignored; under Hyprland there is no
# settings daemon, so for GTK3 it is authoritative -- which is why it
# has to change with the scheme rather than being pinned to dark.
#
[Settings]
# These values must match `gsettings get org.gnome.desktop.interface ...`.
#
@@ -6,12 +15,12 @@
# this file becomes authoritative for GTK3 — which is why it previously named
# themes that aren't installed (Tahoe-Dark, WhiteSur-cursors) without anything
# appearing broken.
gtk-theme-name=adw-gtk3-dark
gtk-theme-name=@GTK_THEME@
gtk-icon-theme-name=Adwaita
gtk-font-name=Adwaita Sans 11
gtk-cursor-theme-name=oreo_blue_cursors
gtk-cursor-theme-size=24
gtk-application-prefer-dark-theme=1
gtk-application-prefer-dark-theme=@PREFER_DARK@
gtk-toolbar-style=GTK_TOOLBAR_ICONS
gtk-toolbar-icon-size=GTK_ICON_SIZE_LARGE_TOOLBAR
-11
View File
@@ -1,11 +0,0 @@
[Settings]
# libadwaita apps normally take their dark preference from the
# org.freedesktop.appearance portal (served by xdg-desktop-portal-gtk, which
# reads gsettings). This file is the fallback for plain GTK4 apps and for the
# window before the portal answers.
gtk-application-prefer-dark-theme=1
gtk-theme-name=adw-gtk3-dark
gtk-icon-theme-name=Adwaita
gtk-font-name=Adwaita Sans 11
gtk-cursor-theme-name=oreo_blue_cursors
gtk-cursor-theme-size=24
+20
View File
@@ -0,0 +1,20 @@
# GENERATED FILE -- edit settings.ini.template instead.
#
# The theme name and dark preference below follow Panama's colour
# scheme, so this file is regenerated on every switch and is not
# committed. Under GNOME, gnome-settings-daemon publishes these over
# XSETTINGS and this file is ignored; under Hyprland there is no
# settings daemon, so for GTK3 it is authoritative -- which is why it
# has to change with the scheme rather than being pinned to dark.
#
[Settings]
# libadwaita apps normally take their dark preference from the
# org.freedesktop.appearance portal (served by xdg-desktop-portal-gtk, which
# reads gsettings). This file is the fallback for plain GTK4 apps and for the
# window before the portal answers.
gtk-application-prefer-dark-theme=@PREFER_DARK@
gtk-theme-name=@GTK_THEME@
gtk-icon-theme-name=Adwaita
gtk-font-name=Adwaita Sans 11
gtk-cursor-theme-name=oreo_blue_cursors
gtk-cursor-theme-size=24
+1 -1
View File
@@ -36,7 +36,7 @@ Last live audit: 2026-08-17, Fedora 44, Hyprland 0.56.2, Quickshell 0.3.0.
| Removable media | udiskie plus udisks notifications | Live |
| Autostart apps | Nextcloud, Bitwarden, and RustDesk system service/tray | Live |
| Printer administration | CUPS with the `system-config-printer` graphical interface | Live |
| System settings | Panama Settings for display policy, appearance, desktop, sound, focus, shortcuts, and services; labelled GNOME hardware/account handoffs | Live |
| System settings | The Settings app for display policy, appearance, desktop, sound, focus, shortcuts, and services; labelled GNOME hardware/account handoffs | Live |
## GNOME extension migration
+8 -8
View File
@@ -27,7 +27,7 @@ Don't "fix" them.
| File | Contents |
|---|---|
| `hyprland.lua` | Entry point. Each `require()` is its own error scope |
| `prefs.lua` | Reads the settings file Panama Settings writes. See below |
| `prefs.lua` | Reads the settings file the Settings app writes. See below |
| `env.lua` | Environment. Note the uwsm caveat below |
| `monitors.lua` | DP-2 geometry, scaling, and the HDR decision |
| `looks.lua` | Colours, blur, glow, shadows, animations, VRR, scanout |
@@ -59,7 +59,7 @@ generated elsewhere:
`quickshell/scripts/panama-idle` regenerates the hypridle config from the
settings store and restarts the daemon. `hypridle.conf` in this directory
remains the shipped default and is what runs when the drop-in is not installed;
Panama Settings shows which of the two states you are in rather than presenting
The Settings app shows which of the two states you are in rather than presenting
controls that quietly do nothing.
Remove the drop-in and go back to the shipped config with:
@@ -77,7 +77,7 @@ relationship is:
`prefs.get("key", <shipped value>)`, so the config still works standalone with
no settings file at all.
- **The JSON is the truth.** Hyprland and Quickshell both read it.
- **Panama Settings is the editor.** It writes the file *and* applies the change
- **The Settings app is the editor.** It writes the file *and* applies the change
live, so nothing needs a reload and the two sides cannot drift apart.
To add an adjustable setting: add an entry to
@@ -95,7 +95,7 @@ accepts the config in each of those states.
Every bind in `keybinds.lua` goes through a local `bind()` wrapper that
substitutes the chord from a stored override, so shortcuts can be moved from
Panama Settings without editing this file.
the Settings app without editing this file.
```lua
bind(mod .. " + Q", hl.dsp.window.close(), { description = "Close window" })
@@ -118,7 +118,7 @@ shipped one, so a hand-edited `settings.json` cannot cost you a keymap.
Every `hl.bind` must pass a `description`. Hyprland reports Lua-defined binds
with dispatcher `__lua` and a bytecode offset as the argument, so a bind without
one has nothing readable beside its chord, and Panama Settings drops it from the
one has nothing readable beside its chord, and the Settings app drops it from the
Input & Shortcuts page rather than showing a mystery row.
`tests/quickshell/keybinds-contract.sh` fails if any bind lacks a description, so
this cannot regress silently.
@@ -275,7 +275,7 @@ The mental model is unchanged from Forge:
| `SUPER + V` | Clipboard history |
| `SUPER + .` | Emoji picker |
| `SUPER + S` | Quick settings |
| `SUPER + I` | Panama Settings |
| `SUPER + I` | Settings |
| `SUPER + SHIFT + F` | Start or reveal focus session |
| `SUPER + B` | Notification centre |
| `SUPER + grave` | Workspace overview |
@@ -288,7 +288,7 @@ The mental model is unchanged from Forge:
### Apps
`SUPER + T` terminal · `N` neovim · `W` browser · `F` files · `C` calculator ·
`E` mail · `I` Panama Settings · `CTRL + SHIFT + Esc` system monitor. GNOME
`E` mail · `I` Settings · `CTRL + SHIFT + Esc` system monitor. GNOME
Settings remains searchable in Vicinae for hardware and account panels.
## Calendar and notifications
@@ -326,7 +326,7 @@ one place. Home and Phone continue the same surface rather than opening extra
dashboard windows.
Home shows the first four selected favourites at rest and every selected light
when expanded. Use **Panama Settings → Home & Phone** to choose favourites,
when expanded. Use **Settings → Home & Phone** to choose favourites,
set Panama-only aliases, and arrange their order. Dragging a brightness control
only previews the value; releasing it sends one brightness request. A normal
power toggle leaves Home Assistant responsible for restoring its previous
+1 -1
View File
@@ -80,7 +80,7 @@ bind(mod .. " + W", hl.dsp.exec_cmd(browser), { description = "Browser" })
bind(mod .. " + F", hl.dsp.exec_cmd(files), { description = "Files" })
bind(mod .. " + C", hl.dsp.exec_cmd(calculator), { description = "Calculator" })
bind(mod .. " + E", hl.dsp.exec_cmd(mail), { description = "Mail" })
bind(mod .. " + I", hl.dsp.exec_cmd(settings), { description = "Panama Settings" })
bind(mod .. " + I", hl.dsp.exec_cmd(settings), { description = "Settings" })
bind("CTRL + SHIFT + Escape", hl.dsp.exec_cmd(sysmonitor), { description = "System monitor" })
-- ── Launcher ────────────────────────────────────────────────────────────────
+2 -32
View File
@@ -183,7 +183,6 @@ font_size 14.0
#: Cursor customization {{{
cursor #c8d3f5
#: Default cursor color. If set to the special value none the cursor
#: will be rendered with a "reverse video" effect. It's color will be
@@ -193,7 +192,6 @@ cursor #c8d3f5
#: precedence. Also, the cursor colors are modified if the cell
#: background and foreground colors have very low contrast.
cursor_text_color #111325
#: The color of text under the cursor. If you want it rendered with
#: the background color of the cell underneath instead, use the
@@ -312,7 +310,6 @@ cursor_shape block
#: robustly with the ever-changing sea of bugs that is Cocoa is too
#: much effort.
url_color #4fd6be
# url_style curly
#: The color and style for highlighting URLs on mouse-over. url_style
@@ -734,12 +731,10 @@ window_border_width 1pt
#: placed centrally. A value of top-left means the padding will be
#: only at the bottom and right edges.
active_border_color #82aaff
#: The color for the border of the active window. Set this to none to
#: not draw borders around the active window.
inactive_border_color #2f334d
#: The color for the border of inactive windows.
@@ -960,16 +955,11 @@ tab_powerline_style round
#: Template to use for active tabs. If not specified falls back to
#: tab_title_template.
active_tab_foreground #1e2030
active_tab_background #82aaff
# active_tab_font_style bold-italic
inactive_tab_foreground #c8d3f5
inactive_tab_background #42465a
# inactive_tab_font_style normal
#: Tab bar colors and styles.
tab_bar_background #222436
#: Background color for the tab bar. Defaults to using the terminal
#: background color.
@@ -983,8 +973,8 @@ tab_bar_margin_color none
#: Color scheme {{{
foreground #c8d3f5
background #222436
include current-theme.conf
#: The foreground and background colors.
@@ -1043,8 +1033,6 @@ dim_opacity 1
#: How much to dim text that has the DIM/FAINT attribute set. One
#: means no dimming and zero means fully dimmed (i.e. invisible).
selection_foreground #2d3f76
selection_background #c8d3f5
#: The foreground and background colors for text selected with the
#: mouse. Setting both of these to none will cause a "reverse video"
@@ -1060,47 +1048,29 @@ selection_background #c8d3f5
#: dull and bright version, for the first 16 colors. You can set the
#: remaining 240 colors as color16 to color255.
color0 #1b1d2b
color8 #444a73
#: black
color1 #ff757f
color9 #ff757f
#: red
color2 #a5e8b5
color10 #a5e8b5
#: green
color3 #ffc777
color11 #ffc777
#: yellow
color4 #82aaff
color12 #82aaff
#: blue
color5 #c099ff
color13 #c099ff
#: magenta
color6 #86e1fc
color14 #86e1fc
#: cyan
color7 #828bb8
color15 #c8d3f5
#: white
color16 #ff966c
color17 #c53b53
# mark1_foreground black
@@ -0,0 +1,38 @@
# Tokyo Night Day — the palette's own light variant.
#
# The same hues as Moon at a different lightness, so a terminal in light mode
# still belongs to this desktop rather than looking like a different machine.
# Kept in the same order as tokyonight-moon.conf so the two can be diffed.
cursor #3760bf
cursor_text_color #e1e2e7
url_color #118c74
active_border_color #2e7de9
inactive_border_color #c4c8da
active_tab_foreground #e1e2e7
active_tab_background #2e7de9
inactive_tab_foreground #3760bf
inactive_tab_background #c4c8da
tab_bar_background #e1e2e7
foreground #3760bf
background #e1e2e7
selection_foreground #e1e2e7
selection_background #99a7df
color0 #e9e9ed
color8 #a1a6c5
color1 #f52a65
color9 #f52a65
color2 #587539
color10 #587539
color3 #8c6c3e
color11 #8c6c3e
color4 #2e7de9
color12 #2e7de9
color5 #9854f1
color13 #9854f1
color6 #007197
color14 #007197
color7 #6172b0
color15 #3760bf
color16 #b15c00
color17 #c64343
@@ -0,0 +1,42 @@
# Tokyo Night Moon — the dark theme Panama ships.
#
# Extracted from kitty.conf so the two schemes can be swapped. kitty.conf
# includes current-theme.conf, which Panama generates from the colour scheme
# setting; that generated file is gitignored because it is machine state.
#
# Live changes go through `kitty @ set-colors`, which is why kitty.conf enables
# remote control. Without it a scheme change would only reach terminals opened
# afterwards.
cursor #c8d3f5
cursor_text_color #111325
url_color #4fd6be
active_border_color #82aaff
inactive_border_color #2f334d
active_tab_foreground #1e2030
active_tab_background #82aaff
inactive_tab_foreground #c8d3f5
inactive_tab_background #42465a
tab_bar_background #222436
foreground #c8d3f5
background #222436
selection_foreground #2d3f76
selection_background #c8d3f5
color0 #1b1d2b
color8 #444a73
color1 #ff757f
color9 #ff757f
color2 #a5e8b5
color10 #a5e8b5
color3 #ffc777
color11 #ffc777
color4 #82aaff
color12 #82aaff
color5 #c099ff
color13 #c099ff
color6 #86e1fc
color14 #86e1fc
color7 #828bb8
color15 #c8d3f5
color16 #ff966c
color17 #c53b53
+3
View File
@@ -10,3 +10,6 @@ vim.api.nvim_create_autocmd("BufWritePre", {
vim.fn.winrestview(view)
end,
})
-- Follow the desktop's light/dark setting while running, not only at startup.
require("config.panama").watch()
+88
View File
@@ -0,0 +1,88 @@
-- Panama desktop integration.
--
-- Neovim is the one application here that neither reads the desktop portal nor
-- has a control socket open by default, so it reads the shared settings file
-- directly -- the same ~/.config/panama/settings.json that the shell and the
-- Hyprland config read.
--
-- Nothing here may raise. A missing or malformed settings file must cost the
-- user their colour scheme preference and nothing else; editing text is more
-- important than matching the desktop.
local M = {}
local function settings_path()
local config_home = os.getenv("XDG_CONFIG_HOME")
if config_home == nil or config_home == "" then
local home = os.getenv("HOME")
if home == nil or home == "" then
return nil
end
config_home = home .. "/.config"
end
return config_home .. "/panama/settings.json"
end
-- "dark" or "light". Defaults to dark, which is what Panama ships.
function M.color_scheme()
local path = settings_path()
if not path then
return "dark"
end
local ok, contents = pcall(function()
local file = io.open(path, "r")
if not file then
return nil
end
local text = file:read("*a")
file:close()
return text
end)
if not ok or not contents or contents == "" then
return "dark"
end
local decoded_ok, decoded = pcall(vim.json.decode, contents)
if not decoded_ok or type(decoded) ~= "table" then
return "dark"
end
return decoded.colorScheme == "light" and "light" or "dark"
end
function M.is_light()
return M.color_scheme() == "light"
end
-- tokyonight ships a light variant in the same family, so light mode stays the
-- same identity rather than becoming a different editor theme.
function M.tokyonight_style()
return M.is_light() and "day" or "moon"
end
-- Re-apply the scheme when the window regains focus.
--
-- Neovim reads the setting once at startup and has no control socket open by
-- default, so an editor already running when the desktop scheme flips would
-- otherwise stay on the old palette until it was restarted. FocusGained is
-- cheap, happens exactly when you would notice the mismatch, and does nothing
-- at all unless the scheme actually changed.
function M.watch()
local applied = M.color_scheme()
vim.api.nvim_create_autocmd("FocusGained", {
group = vim.api.nvim_create_augroup("PanamaColorScheme", { clear = true }),
callback = function()
local current = M.color_scheme()
if current == applied then
return
end
applied = current
vim.o.background = current
pcall(vim.cmd.colorscheme, current == "light" and "tokyonight-day" or "tokyonight-moon")
end,
})
end
return M
+62 -25
View File
@@ -1,31 +1,68 @@
-- Tokyo Night, following the desktop's colour scheme.
--
-- Moon when Panama is dark, Day when it is light. Same theme family either way,
-- so the editor keeps the identity the rest of the desktop has rather than
-- becoming a different-looking application when the scheme flips.
--
-- The readability fixes below are deliberately dark-only. They were written
-- against Moon's palette -- a pale comment colour, a mid-grey gutter -- and
-- applying them to Day would put light grey text on a light background, which
-- is exactly the legibility problem they exist to solve, inverted.
local panama = require("config.panama")
return {
{
"folke/tokyonight.nvim",
opts = {
style = "moon",
transparent = true,
on_colors = function(colors)
colors.comment = "#a0a7c5"
colors.fg_gutter = "#787f93"
colors.terminal_black = "#828bb8"
end,
on_highlights = function(highlights, colors)
-- Fix inline code visibility in markdown
highlights["@markup.raw.markdown_inline"] = {
bg = colors.terminal_black,
fg = colors.fg,
}
highlights["RenderMarkdownCodeInline"] = {
bg = colors.terminal_black,
fg = colors.fg,
}
-- Fix LspReference* readability: DiagnosticUnnecessary dims fg for unused
-- imports, making text nearly invisible against LspReferenceText's background
highlights["LspReferenceText"] = { bg = colors.fg_gutter, fg = colors.fg }
highlights["LspReferenceRead"] = { bg = colors.fg_gutter, fg = colors.fg }
highlights["LspReferenceWrite"] = { bg = colors.fg_gutter, fg = colors.fg }
end,
},
opts = function()
local light = panama.is_light()
return {
style = panama.tokyonight_style(),
light_style = "day",
transparent = true,
on_colors = function(colors)
if light then
-- Day's defaults are tuned for a light ground and mostly need no
-- help. Comments are the exception: the shipped #848cb5 measures
-- 2.54:1 against the #e1e2e7 background, well under the 3:1 floor
-- for secondary text. This is 3.25:1 -- readable, and still clearly
-- dimmer than Normal's 4.52:1 so it does not compete with code.
colors.comment = "#7079a8"
return
end
colors.comment = "#a0a7c5"
colors.fg_gutter = "#787f93"
colors.terminal_black = "#828bb8"
end,
on_highlights = function(highlights, colors)
-- Inline code in markdown is invisible at both lightnesses without
-- an explicit background, because the theme leaves it unset.
highlights["@markup.raw.markdown_inline"] = {
bg = light and colors.bg_highlight or colors.terminal_black,
fg = colors.fg,
}
highlights["RenderMarkdownCodeInline"] = {
bg = light and colors.bg_highlight or colors.terminal_black,
fg = colors.fg,
}
-- LspReference* readability: DiagnosticUnnecessary dims fg for unused
-- imports, making text nearly invisible against LspReferenceText's
-- background.
local reference = {
bg = light and colors.bg_visual or colors.fg_gutter,
fg = colors.fg,
}
highlights["LspReferenceText"] = reference
highlights["LspReferenceRead"] = reference
highlights["LspReferenceWrite"] = reference
end,
}
end,
},
{
"LazyVim/LazyVim",
@@ -98,7 +98,26 @@ Singleton {
// back to shipped defaults and let the next write replace it.
parsed = {};
}
root.values = (parsed && typeof parsed === "object") ? parsed : {};
const raw = (parsed && typeof parsed === "object") ? parsed : {};
// Upgrade before anything reads a value. A stored key the current
// schema no longer recognises is carried through untouched and silently
// stops taking effect, so the conversion has to happen here rather than
// being noticed later by whoever owns that setting.
const result = Migrations.apply(raw);
root.values = result.values;
if (result.migrated)
console.info("Settings migrated from version", result.from, "to", result.to + ":",
result.applied.join("; "));
// Write whenever the version moved, which includes stamping a file
// written before versioning existed. Left unwritten, the stamp lives
// only in memory and is redone on every launch, and a migration that is
// not idempotent would compound.
if (result.changed)
persistTimer.restart();
root.revision++;
root.loaded = true;
}
+121
View File
@@ -0,0 +1,121 @@
pragma Singleton
// Versioned upgrades for the settings file.
//
// The schema is the single source of truth for what a setting IS, but it cannot
// describe what a setting USED to be. Renaming a key, changing its units, or
// splitting one setting into two all leave a stored value that the new schema
// does not recognise -- and an unrecognised key is silently carried through
// untouched, so the user's choice simply stops taking effect with nothing to
// say why. That is the failure this exists to prevent.
//
// HOW IT WORKS
//
// settings.json carries a schemaVersion. On load, every migration with a
// version ABOVE the stored one runs in order, then the file is stamped with
// `current`. A file with no schemaVersion at all is a file written before this
// existed; it is stamped at `baseline` and NOT migrated, because those
// migrations were never written for it.
//
// WRITING ONE
//
// { version: 2, describe: "rename dockDelay to dockHideDelayMs",
// migrate: values => { ... return values; } }
//
// Rules that make this safe to run against a real user's file:
//
// * migrate() receives the whole values object and returns it. Mutating and
// returning the same object is fine.
// * NEVER delete a key you are not replacing. Unknown keys are deliberately
// preserved so that rolling back to an older Panama does not discard a
// newer version's settings, and a migration is the one place that promise
// could quietly be broken.
// * A migration must tolerate its input being absent or the wrong type. It
// runs against files written by every previous version, including ones
// that were hand-edited.
// * Migrations never run twice: the stored version only moves forward.
import QtQuick
QtObject {
id: root
// What a file written today is stamped with. Bump this when adding a
// migration, to the version of the migration you added.
readonly property int current: 1
// Files predating versioning are stamped here without being migrated.
readonly property int baseline: 1
readonly property string versionKey: "schemaVersion"
// Ordered by version. Empty is the correct state until the first breaking
// schema change -- this exists so that change is a routine edit rather than
// an emergency.
readonly property var steps: []
function storedVersion(values: var): int {
const raw = values ? values[root.versionKey] : undefined;
return (typeof raw === "number" && isFinite(raw)) ? Math.floor(raw) : 0;
}
// Returns { values, migrated, changed, from, to, applied }.
//
// `applied` names each step that ran, so the caller can log something
// meaningful rather than "settings changed somehow". `changed` is the one
// the caller should write on: stamping a pre-versioning file changes it
// without running any step, and left unwritten the stamp would live only in
// memory and be redone on every launch.
function apply(values: var): var {
return root.applyWith(values, root.steps, root.current, root.baseline);
}
// The same logic with the step list injected, so the machinery can be
// tested against fixture migrations. The real list is empty until the first
// breaking schema change, and a mechanism that has never run against a
// failing step is not one to find out about during an upgrade.
function applyWith(values: var, steps: var, current: int, baseline: int): var {
const safe = (values && typeof values === "object") ? values : {};
const from = root.storedVersion(safe);
// No version: written before versioning existed. Stamp it and stop.
// Running the migration list against it would apply upgrades designed
// for schemas this file never had.
if (from === 0) {
safe[root.versionKey] = baseline;
return { values: safe, migrated: false, changed: true, from: 0, to: baseline, applied: [] };
}
// A file from a NEWER Panama. Left completely alone: downgrading its
// keys is not something this can do correctly, and unknown keys are
// already preserved, so the older build simply ignores what it does not
// understand.
if (from > current)
return { values: safe, migrated: false, changed: false, from: from, to: from, applied: [] };
const applied = [];
let working = safe;
for (const step of steps) {
if (step.version <= from || step.version > current)
continue;
try {
const result = step.migrate(working);
if (result && typeof result === "object")
working = result;
applied.push(step.version + ": " + step.describe);
} catch (error) {
// One bad migration must not cost the user every setting. Stop
// at the last good version so the next launch retries from
// here rather than skipping the failed step forever.
console.warn("Migrations: step", step.version, "failed:", error);
working[root.versionKey] = step.version - 1;
return { values: working, migrated: applied.length > 0, changed: applied.length > 0,
from: from, to: step.version - 1, applied: applied };
}
}
working[root.versionKey] = current;
return { values: working, migrated: applied.length > 0, changed: from !== current,
from: from, to: current, applied: applied };
}
}
@@ -257,6 +257,24 @@ Singleton {
detail: "XKB layout name, or a comma-separated list to switch between",
hypr: { path: ["input", "kb_layout"], option: "input:kb_layout", readAs: "str" }
},
{
key: "keyboardVariant", type: "string", def: "", group: "input",
// Same shape as the layout list and for the same reason: this is
// serialised into an hl.config string.
pattern: "^$|^[a-z0-9_]{1,24}(,[a-z0-9_]{1,24})*$",
label: "Layout variant",
detail: "XKB variant, such as dvorak or colemak. Empty for the standard layout",
hypr: { path: ["input", "kb_variant"], option: "input:kb_variant", readAs: "str" }
},
{
key: "keyboardOptions", type: "string", def: "", group: "input",
// XKB option names are colon-separated pairs in a comma-separated
// list, e.g. "compose:ralt,caps:escape".
pattern: "^$|^[a-z0-9_]+:[a-z0-9_]+(,[a-z0-9_]+:[a-z0-9_]+)*$",
label: "Keyboard options",
detail: "XKB options, such as compose:ralt to make right Alt a compose key",
hypr: { path: ["input", "kb_options"], option: "input:kb_options", readAs: "str" }
},
{
key: "numlockByDefault", type: "bool", def: true, group: "input",
label: "Num Lock on login",
@@ -310,6 +328,203 @@ Singleton {
hypr: { path: ["cursor", "inactive_timeout"], option: "cursor:inactive_timeout", readAs: "float" }
},
// ── Pointer ─────────────────────────────────────────────────────────
//
// Every `readAs` below was read back off the running compositor rather
// than guessed. Two are not what they look like: touchpad drag lock is
// an int with three states, not a switch, and scroll factors are floats
// even at their default of exactly 1.
{
key: "naturalScroll", type: "bool", def: false, group: "pointer",
label: "Natural scrolling",
detail: "Content follows the direction of your fingers, as on a phone",
hypr: { path: ["input", "natural_scroll"], option: "input:natural_scroll", readAs: "bool" }
},
{
key: "accelProfile", type: "enum", def: "flat", group: "pointer",
label: "Acceleration",
detail: "Flat moves the pointer the same distance however fast you move",
options: [
{ value: "flat", label: "Flat" },
{ value: "adaptive", label: "Adaptive" }
],
hypr: { path: ["input", "accel_profile"], option: "input:accel_profile", readAs: "str" }
},
{
key: "scrollFactor", type: "real", def: 1.0, min: 0.1, max: 4.0, step: 0.1,
group: "pointer",
label: "Scroll speed",
detail: "Multiplies how far one notch of the wheel scrolls",
hypr: { path: ["input", "scroll_factor"], option: "input:scroll_factor", readAs: "float" }
},
{
key: "leftHanded", type: "bool", def: false, group: "pointer",
label: "Left-handed",
detail: "Swap the primary and secondary buttons",
hypr: { path: ["input", "left_handed"], option: "input:left_handed", readAs: "bool" }
},
// ── Touchpad ────────────────────────────────────────────────────────
//
// Shown only on machines that have one. These are separate from the
// pointer settings above because libinput keeps them separate: a mouse
// and a touchpad on the same machine can scroll in opposite directions,
// and usually should.
{
key: "touchpadTapToClick", type: "bool", def: true, group: "touchpad",
label: "Tap to click",
detail: "A tap counts as a click without pressing down",
// The Lua config key and the hyprctl option name genuinely differ
// here: hl.config wants input.touchpad.tap_to_click, getoption
// answers to input:touchpad:tap-to-click. Using either spelling for
// both fails -- a hyphen is not a Lua identifier, and the
// underscored name is not a known option to getoption. This is what
// the two separate fields are for.
hypr: { path: ["input", "touchpad", "tap_to_click"], option: "input:touchpad:tap-to-click", readAs: "bool" }
},
{
key: "touchpadNaturalScroll", type: "bool", def: true, group: "touchpad",
label: "Natural scrolling",
detail: "Content follows the direction of your fingers",
hypr: { path: ["input", "touchpad", "natural_scroll"], option: "input:touchpad:natural_scroll", readAs: "bool" }
},
{
key: "touchpadDisableWhileTyping", type: "bool", def: true, group: "touchpad",
label: "Disable while typing",
detail: "Ignore the touchpad briefly after a keystroke, so a palm cannot move the pointer",
hypr: { path: ["input", "touchpad", "disable_while_typing"], option: "input:touchpad:disable_while_typing", readAs: "bool" }
},
{
key: "touchpadScrollFactor", type: "real", def: 1.0, min: 0.1, max: 4.0, step: 0.1,
group: "touchpad",
label: "Scroll speed",
detail: "Multiplies how far a two-finger scroll travels",
hypr: { path: ["input", "touchpad", "scroll_factor"], option: "input:touchpad:scroll_factor", readAs: "float" }
},
{
key: "touchpadDragLock", type: "enum", def: 0, group: "touchpad",
label: "Drag lock",
detail: "Keeps a tap-and-drag active when you lift a finger mid-drag",
// An int with three states rather than a switch, which is why this
// is an enum: reported as `int` by getoption, not `bool`.
options: [
{ value: 0, label: "Off" },
{ value: 1, label: "On" },
{ value: 2, label: "On, until you tap again" }
],
hypr: { path: ["input", "touchpad", "drag_lock"], option: "input:touchpad:drag_lock", readAs: "int" }
},
{
key: "touchpadMiddleButtonEmulation", type: "bool", def: false, group: "touchpad",
label: "Middle-click by pressing both buttons",
detail: "Pressing left and right together acts as a middle click",
hypr: { path: ["input", "touchpad", "middle_button_emulation"], option: "input:touchpad:middle_button_emulation", readAs: "bool" }
},
// ── Multitasking ────────────────────────────────────────────────────
//
// GNOME's Multitasking panel, in Hyprland's terms. The Desktop page
// described the first two of these as read-only facts ("Layout:
// Tiling"), which was never true -- they are ordinary settings that
// happened not to have controls.
//
// Defaults here are Panama's shipped values from hypr/looks.lua, not
// Hyprland's own, so restoring defaults returns the desktop to how it
// ships rather than to how Hyprland would behave with no config.
{
key: "windowLayout", type: "enum", def: "dwindle", group: "multitasking",
label: "Tiling layout",
detail: "Dwindle splits the focused window; master keeps one large window beside a stack",
options: [
{ value: "dwindle", label: "Dwindle" },
{ value: "master", label: "Master and stack" }
],
hypr: { path: ["general", "layout"], option: "general:layout", readAs: "str" }
},
{
key: "preserveSplit", type: "bool", def: true, group: "multitasking",
label: "Keep split direction",
detail: "New windows follow the split of the window they replace, instead of always halving the longer side",
hypr: { path: ["dwindle", "preserve_split"], option: "dwindle:preserve_split", readAs: "bool" }
},
{
key: "forceSplit", type: "enum", def: 0, group: "multitasking",
label: "New windows open",
detail: "Where a new window lands relative to the one that was focused",
options: [
{ value: 0, label: "Where the pointer is" },
{ value: 1, label: "Always left or above" },
{ value: 2, label: "Always right or below" }
],
hypr: { path: ["dwindle", "force_split"], option: "dwindle:force_split", readAs: "int" }
},
{
key: "windowSnapping", type: "bool", def: true, group: "multitasking",
label: "Snap floating windows",
detail: "Floating windows stick to screen edges and to each other as you drag them",
hypr: { path: ["general", "snap", "enabled"], option: "general:snap:enabled", readAs: "bool" }
},
{
key: "workspaceBackAndForth", type: "bool", def: false, group: "multitasking",
label: "Switch back and forth",
detail: "Selecting the workspace you are already on returns you to the previous one",
hypr: { path: ["binds", "workspace_back_and_forth"], option: "binds:workspace_back_and_forth", readAs: "bool" }
},
{
key: "allowWorkspaceCycles", type: "bool", def: false, group: "multitasking",
label: "Wrap around at the ends",
detail: "Moving past the last workspace continues from the first",
hypr: { path: ["binds", "allow_workspace_cycles"], option: "binds:allow_workspace_cycles", readAs: "bool" }
},
{
key: "focusOnActivate", type: "bool", def: false, group: "multitasking",
label: "Let applications take focus",
detail: "An application asking for attention is switched to, rather than only highlighted",
hypr: { path: ["misc", "focus_on_activate"], option: "misc:focus_on_activate", readAs: "bool" }
},
{
key: "mouseMoveFocusesMonitor", type: "bool", def: true, group: "multitasking",
label: "Pointer changes active display",
detail: "Moving the pointer to another display makes it the active one",
hypr: { path: ["misc", "mouse_move_focuses_monitor"], option: "misc:mouse_move_focuses_monitor", readAs: "bool" }
},
// ── Accessibility ───────────────────────────────────────────────────
//
// Only what Hyprland can actually deliver. GNOME's sticky keys, slow
// keys, bounce keys and mouse keys are AccessX, an X11 server feature:
// XKB under Wayland has no accessx option group at all (verified
// against evdev.lst), and Hyprland does not implement it. The
// compositor will happily STORE "accessx:enable" as a keyboard option
// and nothing will ever act on it, which is exactly the kind of switch
// this app refuses to ship.
{
key: "magnifierFactor", type: "real", def: 1.0, min: 1.0, max: 5.0, step: 0.1,
group: "accessibility",
label: "Magnifier",
detail: "Magnifies the screen around the pointer. 1.0 is off",
hypr: { path: ["cursor", "zoom_factor"], option: "cursor:zoom_factor", readAs: "float" }
},
{
key: "magnifierRigid", type: "bool", def: false, group: "accessibility",
label: "Magnifier follows in steps",
detail: "Moves the magnified view in increments rather than gliding with the pointer",
hypr: { path: ["cursor", "zoom_rigid"], option: "cursor:zoom_rigid", readAs: "bool" }
},
{
key: "dimInactive", type: "bool", def: false, group: "accessibility",
label: "Dim inactive windows",
detail: "Darkens every window except the focused one, so the active window is unmistakable",
hypr: { path: ["decoration", "dim_inactive"], option: "decoration:dim_inactive", readAs: "bool" }
},
{
key: "dimStrength", type: "real", def: 0.5, min: 0.05, max: 0.9, step: 0.05,
group: "accessibility",
label: "Dim amount",
detail: "How much darker unfocused windows are",
hypr: { path: ["decoration", "dim_strength"], option: "decoration:dim_strength", readAs: "float" }
},
// ── Night light ─────────────────────────────────────────────────────
{
key: "nightLightEnabled", type: "bool", def: false, group: "nightLight",
+1
View File
@@ -4,3 +4,4 @@ singleton PreferenceSchema 1.0 PreferenceSchema.qml
singleton HomePreferences 1.0 HomePreferences.qml
singleton Settings 1.0 Settings.qml
singleton Theme 1.0 Theme.qml
singleton Migrations 1.0 Migrations.qml
@@ -0,0 +1,56 @@
// Exercises Migrations.applyWith against fixture steps and prints a verdict per
// case. Run by tests/quickshell/migrations-contract.sh.
//
// Fixture steps rather than the real list: the real one is empty until the
// first breaking schema change, and a mechanism that has never been run against
// a failing step is not one to discover the behaviour of during an upgrade.
import Quickshell
import QtQuick
import qs.config
ShellRoot {
Component.onCompleted: {
const steps = [
{ version: 2, describe: "add b", migrate: v => { v.b = (v.a ?? 0) + 1; return v; } },
{ version: 3, describe: "add c", migrate: v => { v.c = "three"; return v; } },
{ version: 4, describe: "explode", migrate: v => { throw new Error("boom"); } }
];
const results = {};
// No version at all: a file written before versioning. Stamped, never
// migrated.
let r = Migrations.applyWith({ a: 1 }, steps, 3, 1);
results.unversioned = { v: r.values.schemaVersion, migrated: r.migrated,
changed: r.changed, untouched: r.values.b === undefined };
// Older file: every step above its version runs, in order.
r = Migrations.applyWith({ a: 1, schemaVersion: 1 }, steps, 3, 1);
results.upgrade = { v: r.values.schemaVersion, b: r.values.b, c: r.values.c,
count: r.applied.length, migrated: r.migrated };
// Already current: nothing runs.
r = Migrations.applyWith({ schemaVersion: 3, keep: "me" }, steps, 3, 1);
results.current = { v: r.values.schemaVersion, migrated: r.migrated,
kept: r.values.keep === "me", b: r.values.b === undefined };
// From the future: left completely alone, including its unknown keys.
r = Migrations.applyWith({ schemaVersion: 9, futureKey: "x" }, steps, 3, 1);
results.future = { v: r.values.schemaVersion, migrated: r.migrated,
changed: r.changed, kept: r.values.futureKey === "x" };
// A failing step stops at the last good version rather than losing the
// file or skipping past the failure forever.
r = Migrations.applyWith({ schemaVersion: 1, a: 5 }, steps, 4, 1);
results.failure = { v: r.values.schemaVersion, b: r.values.b, c: r.values.c,
kept: r.values.a === 5, count: r.applied.length };
// Unknown keys survive a migration: rolling back to an older Panama
// must not discard a newer version's settings.
r = Migrations.applyWith({ schemaVersion: 1, unknownFromFuture: true }, steps, 3, 1);
results.preserved = { kept: r.values.unknownFromFuture === true };
console.info("PANAMA-MIGRATIONS " + JSON.stringify(results));
Qt.callLater(() => Qt.quit());
}
}
@@ -1,24 +1,44 @@
// Backlight slider, via brightnessctl.
// Brightness, from whichever source this machine actually has.
//
// This machine drives an external DisplayPort monitor and has no backlight
// class device at all (brightnessctl only reports keyboard/NIC LEDs), so the
// row removes itself rather than sitting there as a dead control. Probed once
// at startup — backlight devices do not appear and disappear.
// Two exist and they are not interchangeable:
//
// The kernel backlight class, driven by brightnessctl. Laptop panels have it;
// this desktop does not -- brightnessctl reports only keyboard and NIC LEDs.
//
// DDC/CI, the channel the buttons on a monitor's bezel drive. That is the
// only brightness an external display has, and it is per-monitor.
//
// A machine may have neither, either, or both, so this renders a row per source
// found and removes itself entirely when there are none, rather than sitting
// there as a dead control.
//
// Connector labels appear only when there is more than one row. A single
// slider needs no explanation of which screen it dims.
import QtQuick
import Quickshell
import Quickshell.Io
import qs.widgets
import qs.config
import qs.services
Item {
id: root
property bool available: false
property real value: 0
property bool backlightAvailable: false
property real backlightValue: 0
visible: root.available
implicitHeight: root.available ? 32 : 0
readonly property int rowCount: (root.backlightAvailable ? 1 : 0) + Brightness.displays.length
readonly property bool labelled: root.rowCount > 1
visible: root.rowCount > 0
implicitHeight: rows.implicitHeight
// Probing I2C takes on the order of a second, so it waits until the panel
// is actually on screen rather than running at shell startup. Monitors do
// not come and go, so once is enough.
onVisibleChanged: if (visible && !Brightness.scanned) Brightness.refresh()
Component.onCompleted: if (root.visible && !Brightness.scanned) Brightness.refresh()
// `-m` is the machine-readable form: name,class,current,percent,max
Process {
@@ -34,36 +54,105 @@ Item {
const fields = line.split(",");
if (fields.length < 5 || fields[1] !== "backlight")
continue;
root.available = true;
root.value = parseInt(fields[3]) / 100;
root.backlightAvailable = true;
root.backlightValue = parseInt(fields[3]) / 100;
return;
}
}
function apply(v: real): void {
root.value = v;
function applyBacklight(v: real): void {
root.backlightValue = v;
// Never go fully dark: a 0% backlight looks like a broken shell.
Quickshell.execDetached(["brightnessctl", "-c", "backlight", "-q", "set", Math.max(1, Math.round(v * 100)) + "%"]);
}
// ValueSlider draws its own leading icon, but symbolic icons need
// recolouring to be visible — see ThemedIcon.
ThemedIcon {
id: glyph
Column {
id: rows
anchors.left: parent.left
anchors.leftMargin: 6
anchors.verticalCenter: parent.verticalCenter
size: 17
icon: "display-brightness-symbolic"
anchors.right: parent.right
spacing: 4
BrightnessRow {
width: rows.width
visible: root.backlightAvailable
label: "Built-in"
value: root.backlightValue
onMoved: v => root.applyBacklight(v)
}
Repeater {
model: Brightness.displays
BrightnessRow {
required property var modelData
width: rows.width
// Hyprland already knows what each output is called, so the
// name comes from there rather than from a second source that
// could disagree with the Displays page. The connector is the
// fallback, so a display is never an unlabelled slider.
label: Displays.monitorNamed(modelData.connector)?.description || modelData.connector
value: modelData.value / 100
onMoved: v => Brightness.set(modelData.bus, Math.round(v * 100))
}
}
}
ValueSlider {
anchors.left: glyph.right
anchors.leftMargin: 12
anchors.right: parent.right
anchors.rightMargin: 32
anchors.verticalCenter: parent.verticalCenter
value: root.value
onMoved: v => root.apply(v)
component BrightnessRow: Item {
id: row
property string label: ""
property real value: 0
signal moved(real value)
implicitHeight: caption.height + control.height
Text {
id: caption
anchors.left: parent.left
anchors.right: parent.right
anchors.leftMargin: 6
anchors.rightMargin: 6
anchors.top: parent.top
visible: root.labelled
height: visible ? implicitHeight + 2 : 0
text: row.label
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
elide: Text.ElideRight
}
// The slider and its glyph share one strip so the two stay aligned
// whether or not a caption sits above them. Anchoring the glyph to
// both a caption and a centre line instead would conflict, and an
// anchor set to undefined is not released.
Item {
id: control
anchors.left: parent.left
anchors.right: parent.right
anchors.top: caption.bottom
height: 32
// ValueSlider draws its own leading icon, but symbolic icons need
// recolouring to be visible — see ThemedIcon.
ThemedIcon {
id: glyph
anchors.left: parent.left
anchors.leftMargin: 6
anchors.verticalCenter: parent.verticalCenter
size: 17
icon: "display-brightness-symbolic"
}
ValueSlider {
anchors.left: glyph.right
anchors.leftMargin: 12
anchors.right: parent.right
anchors.rightMargin: 32
anchors.verticalCenter: parent.verticalCenter
value: row.value
onMoved: v => row.moved(v)
}
}
}
}
@@ -4,11 +4,20 @@ import qs.config
import qs.services
SettingsPage {
title: "About Panama"
id: root
title: "About"
lede: "A curated Hyprland desktop built around focus, speed, and good taste."
Component.onCompleted: {
if (!MachineInfo.scanned)
MachineInfo.refresh();
if (GraphicsDevices.devices.length === 0)
GraphicsDevices.refresh();
}
SettingsCard {
title: "Panama Desktop"
title: "Desktop"
subtitle: "Tokyo Night Moon · Prism glass · native tiling"
TextRow {
@@ -31,6 +40,59 @@ SettingsPage {
}
}
// What GNOME's About panel answers and this page did not: what am I running
// on. Rows come from MachineInfo, except graphics, which is joined from
// GraphicsDevices rather than read a second time -- two readouts of the same
// hardware are two things that can disagree.
//
// The join splices the GPU rows in directly after Processor rather than
// appending them, so the card reads the way fastfetch does: what the system
// is, then what is installed on it, then the hardware underneath. A GPU
// listed after "Disk" reads as an afterthought.
readonly property var machineRows: {
const rows = (MachineInfo.facts ?? []).slice();
const gpus = GraphicsDevices.devices ?? [];
if (gpus.length === 0)
return rows;
const graphics = gpus.map((device, index) => ({
label: gpus.length > 1 ? "Graphics " + (index + 1) : "Graphics",
value: device.name
}));
const after = rows.findIndex(row => row.label === "Processor");
if (after < 0)
return rows.concat(graphics);
return rows.slice(0, after + 1).concat(graphics, rows.slice(after + 1));
}
SettingsCard {
title: "This machine"
subtitle: "Hardware and system, as the kernel reports it."
Repeater {
model: root.machineRows
TextRow {
id: machineRow
required property var modelData
required property int index
label: machineRow.modelData.label
value: machineRow.modelData.value
divider: machineRow.index < root.machineRows.length - 1
}
}
TextRow {
visible: MachineInfo.scanned && root.machineRows.length === 0
label: "Hardware"
detail: "The system did not report anything readable"
value: "Unavailable"
divider: false
}
}
SettingsCard {
title: "Design principles"
@@ -25,32 +25,61 @@ SettingsPage {
SettingsCard {
title: "Text"
subtitle: "Scales text in applications. Panama's own panels are drawn at their design size, so the shell is unaffected."
subtitle: "Scales text in applications. The shell's own panels are drawn at their design size, so they are unaffected."
SliderRow { setting: "textScale"; divider: false }
}
SettingsCard {
title: "Motion"
subtitle: "Panama never animates while idle. This affects motion you asked for — windows opening, workspaces sliding, panels appearing."
subtitle: "Nothing animates while idle. This affects motion you asked for — windows opening, workspaces sliding, panels appearing."
ToggleRow { setting: "animationsEnabled"; divider: false }
}
// Zoom, done by the compositor rather than handed to GNOME. Hyprland has a
// real magnifier (cursor:zoom_factor) that follows the pointer, so there is
// no reason to send someone to another application for it.
SettingsCard {
title: "Contrast"
subtitle: "Unfocused windows can be faded to make the focused one obvious, or left at full strength if that is harder to read."
title: "Magnifier"
subtitle: "Magnifies the screen around the pointer. Set the magnification to 1× to turn it off."
SliderRow { setting: "inactiveOpacity"; divider: false }
SliderRow { setting: "magnifierFactor"; zeroLabel: "Off" }
ToggleRow { setting: "magnifierRigid"; divider: false }
}
SettingsCard {
title: "System accessibility"
subtitle: "Screen reader, zoom, and on-screen keyboard are provided by GNOME's accessibility stack."
title: "Contrast"
subtitle: "Unfocused windows can be faded or darkened to make the focused one obvious, or left alone if that is harder to read."
SliderRow { setting: "inactiveOpacity" }
ToggleRow { setting: "dimInactive" }
SliderRow { setting: "dimStrength"; divider: false }
}
// What this session genuinely cannot do, said plainly.
//
// Sticky keys, slow keys, bounce keys and mouse keys are AccessX, which is
// an X11 SERVER feature. XKB under Wayland has no accessx option group at
// all, and Hyprland does not implement one. The compositor will accept
// "accessx:enable" as a keyboard option and store it, and nothing will ever
// act on it -- so there is no switch here, and pointing at GNOME's panel
// would be no better, since the daemon that applies those keys is not
// running either.
SettingsCard {
title: "Keyboard accessibility"
subtitle: "Sticky, slow and bounce keys are an X11 feature with no Wayland equivalent, so they are unavailable in this session. Offering them here would store a preference that nothing acts on."
ActionRow {
label: "Screen reader"
detail: "Orca reads the screen aloud and works over the accessibility bus, which does run here"
action: "Start Orca"
onTriggered: SystemSettings.openApplication("orca")
}
ActionRow {
label: "GNOME accessibility settings"
detail: "Opens in GNOME Settings"
detail: "For the parts GNOME's own stack still owns"
action: "Open"
divider: false
onTriggered: SystemSettings.openGnomePanel("universal-access")
@@ -46,7 +46,7 @@ SettingsPage {
title: "Background"
subtitle: Wallpaper.lastError !== ""
? Wallpaper.lastError
: "Applied to every display. Panama looks in ~/Pictures/Wallpapers, ~/Pictures/Backgrounds, ~/.local/share/backgrounds, and /usr/share/backgrounds."
: "Applied to every display. Looked for in ~/Pictures/Wallpapers, ~/Pictures/Backgrounds, ~/.local/share/backgrounds, and /usr/share/backgrounds."
WallpaperPicker {
id: wallpapers
@@ -184,7 +184,7 @@ SettingsPage {
SettingsCard {
title: "Theme"
subtitle: "Panama has one curated visual identity rather than a matrix of partially compatible themes. The controls above adjust its parameters — how much space, how soft, how much motion — without replacing it."
subtitle: "This desktop has one curated visual identity rather than a matrix of partially compatible themes. The controls above adjust its parameters — how much space, how soft, how much motion — without replacing it."
TextRow { label: "Color palette"; detail: "Tokyo Night Moon"; value: "Prism" }
TextRow { label: "Interface type"; detail: "Adwaita Sans"; value: "System"; divider: false }
@@ -142,6 +142,40 @@ SettingsPage {
}
}
// GNOME's Search panel, answered honestly.
//
// It configures which applications provide results in gnome-shell's
// overview and which folders are indexed. gnome-shell does not run here,
// so those settings would do nothing. Under Panama searching is the
// launcher's job, and Vicinae carries its own preferences -- reimplementing
// them here would give two places to change one thing.
SettingsCard {
title: "Search"
subtitle: "Applications, files, the calculator, clipboard history, emoji, and open windows are all searched from the launcher."
TextRow {
label: "Launcher"
detail: SystemSettings.vicinaeActive
? "Running as a user service"
: "Not running — Super+Shift+R opens the fallback launcher"
value: "Vicinae"
}
TextRow {
label: "Open search"
detail: "Three keys open it, because Super+A and Super+R were GNOME's app grid and run dialog"
value: "Super+Space"
}
ActionRow {
label: "Change these shortcuts"
detail: "Every launcher chord is rebindable, including clipboard history and emoji"
action: "Open keyboard"
divider: false
onTriggered: ShellState.openSettings("shortcuts")
}
}
SettingsCard {
title: "User autostart"
subtitle: "These desktop entries live in your user configuration. Select a row to toggle it."
@@ -175,7 +209,7 @@ SettingsPage {
SettingsCard {
title: "Compositor autostart"
subtitle: "Panama starts these from Hyprland configuration. They are read-only here."
subtitle: "These are started from the Hyprland configuration. They are read-only here."
TextRow {
visible: !DefaultApps.busy && DefaultApps.luaAutostartEntries.length === 0
@@ -19,13 +19,18 @@ import qs.services
SettingsPage {
id: root
title: "Network & Devices"
lede: Connectivity.activeNetwork
? "Connected to " + Connectivity.activeNetwork.name
: "Wi-Fi, Bluetooth, and the things Fedora owns."
// Drive the scanners only while this page is the one being shown.
Component.onCompleted: Connectivity.active = true
Component.onCompleted: {
Connectivity.active = true;
if (!WifiShare.scanned)
WifiShare.refresh();
}
Component.onDestruction: Connectivity.active = false
SettingsCard {
@@ -68,6 +73,69 @@ SettingsPage {
}
}
// Sharing a network by QR, the way GNOME's Wi-Fi panel does. The
// alternative is reading a passphrase out loud.
//
// The image holds the password in machine-readable form, so it is generated
// on demand rather than up front, and the helper writes it to tmpfs under
// XDG_RUNTIME_DIR instead of anywhere persistent.
SettingsCard {
visible: Connectivity.wifiDevice !== null && WifiShare.shareable.length > 0
title: "Share a network"
subtitle: WifiShare.sharing !== ""
? "Point a phone's camera at the code to join " + WifiShare.sharing + "."
: "Shows a QR code a phone can scan to join, without reading the password out."
Repeater {
model: WifiShare.shareable
ActionRow {
id: shareRow
required property var modelData
required property int index
label: shareRow.modelData.ssid
detail: WifiShare.sharing === shareRow.modelData.name
? "Showing a code below — anyone who can see the screen can join"
: "Saved network"
action: WifiShare.sharing === shareRow.modelData.name ? "Hide" : "Show code"
divider: shareRow.index < WifiShare.shareable.length - 1 || WifiShare.sharing !== ""
onTriggered: WifiShare.sharing === shareRow.modelData.name
? WifiShare.stopSharing()
: WifiShare.share(shareRow.modelData.name)
}
}
// Drawn at its natural size on a white plate: a QR code inverted or
// tinted to match a dark theme is unreliable to scan, and this one has
// exactly one job.
Item {
width: parent.width
visible: WifiShare.sharing !== "" && WifiShare.imagePath !== ""
implicitHeight: visible ? plate.height + 20 : 0
Rectangle {
id: plate
anchors.horizontalCenter: parent.horizontalCenter
y: 10
width: 208
height: 208
radius: 10
color: "white"
Image {
anchors.centerIn: parent
width: 184
height: 184
smooth: false
fillMode: Image.PreserveAspectFit
cache: false
source: WifiShare.imagePath !== "" ? "file://" + WifiShare.imagePath : ""
}
}
}
}
SettingsCard {
title: "Bluetooth"
visible: Connectivity.adapter !== null
@@ -47,18 +47,16 @@ SettingsPage {
SettingsCard {
title: "Window layout"
subtitle: "Panama follows the Forge mental model with native Hyprland tiling."
subtitle: "Follows the Forge mental model, with native Hyprland tiling."
TextRow {
label: "Layout"
detail: "Dwindle with preserved split direction"
value: "Tiling"
}
TextRow {
label: "Workspace movement"
detail: "Alt+H / Alt+L, add Shift to move a window"
value: "Dynamic"
}
// These were two read-only rows reporting "Tiling" and "Dynamic", which
// described settings rather than facts -- both are ordinary Hyprland
// options that simply had no controls. TextRow's own documentation says
// a setting the user could reasonably change does not belong in it.
ChoiceRow { setting: "windowLayout" }
ToggleRow { setting: "preserveSplit" }
ChoiceRow { setting: "forceSplit" }
ToggleRow { setting: "windowSnapping" }
ActionRow {
label: "Gaps, corners, and effects"
detail: "Adjusted on the Appearance page, beside a live preview"
@@ -68,6 +66,18 @@ SettingsPage {
}
}
// GNOME's Multitasking panel, in Hyprland's terms.
SettingsCard {
title: "Workspaces & focus"
subtitle: "Hyprland's workspaces are created and destroyed as you use them, so there is no fixed count to set."
ToggleRow { setting: "workspaceBackAndForth" }
ToggleRow { setting: "allowWorkspaceCycles" }
ToggleRow { setting: "focusOnActivate" }
ToggleRow { setting: "mouseMoveFocusesMonitor" }
ChoiceRow { setting: "followMouse"; divider: false }
}
SettingsCard {
title: "Focus"
@@ -111,10 +121,10 @@ SettingsPage {
SettingsCard {
title: "Reset"
subtitle: "Restores Panama's appearance, dock, clock, focus, and display policy, and clears your Home accessory arrangement. Pinned applications, files, and paired devices are not changed."
subtitle: "Restores the appearance, dock, clock, focus, and display policy, and clears your Home accessory arrangement. Pinned applications, files, and paired devices are not changed."
ActionRow {
label: "Restore Panama defaults"
label: "Restore defaults"
detail: "Applies immediately, including to the compositor"
action: "Restore defaults"
divider: false
@@ -89,9 +89,12 @@ Rectangle {
}
}
// The real bar carries a clock here, not a name. Standing in with the
// actual time keeps the preview a picture of this desktop rather than
// a picture of a product.
Text {
anchors.centerIn: parent
text: "Panama"
text: Qt.formatTime(new Date(), "h:mm")
color: Theme.alpha(Theme.fg, 0.45)
font.family: Theme.fontFamily
font.pixelSize: 9
@@ -1,7 +1,7 @@
// Displays.
//
// Resolution, refresh rate, scale, and rotation, plus the gaming display
// policy that was already here.
// Resolution, refresh rate, scale, and rotation, plus panel brightness and the
// gaming display policy that was already here.
//
// Every geometry change goes through an apply-then-confirm countdown. This is
// the one page where a wrong value can leave the screen unreadable or blank,
@@ -32,7 +32,14 @@ SettingsPage {
root.selectedOutput = Displays.monitors.length > 0 ? Displays.monitors[0].name : "";
}
Component.onCompleted: root.syncSelectedOutput()
// Probing I2C for DDC-capable monitors takes on the order of a second, so
// it runs when this page is opened rather than at shell startup. Monitors
// do not appear while you are looking at a settings page, so once is enough.
Component.onCompleted: {
root.syncSelectedOutput();
if (!Brightness.scanned)
Brightness.refresh();
}
Connections {
target: Displays
function onMonitorsChanged(): void { root.syncSelectedOutput(); }
@@ -142,7 +149,7 @@ SettingsPage {
ActionRow {
visible: Displays.isOverridden(root.monitor ? root.monitor.name : "")
label: "Using a custom display setting"
detail: "Forget it to go back to the resolution and scale Panama ships"
detail: "Forget it to go back to the shipped resolution and scale"
action: "Forget"
divider: false
onTriggered: Displays.forget(root.monitor.name)
@@ -200,9 +207,48 @@ SettingsPage {
SliderRow { setting: "nightLightTemperature"; divider: false }
}
// Panel brightness, over DDC/CI.
//
// This is hardware state rather than a stored preference: the monitor
// remembers it, the bezel buttons change it behind Panama's back, and
// writing it into settings.json would mean restoring a value the panel had
// already moved on from. So there is no schema key here and no SliderRow --
// the rows read and write the display directly.
SettingsCard {
visible: Brightness.available || Brightness.lastError !== ""
title: "Brightness"
subtitle: Brightness.available
? "Sent to the monitor over DDC/CI, the same channel its buttons use."
: Brightness.lastError
Repeater {
model: Brightness.displays
SettingRow {
id: brightnessRow
required property var modelData
required property int index
label: Displays.monitorNamed(modelData.connector)?.description || modelData.connector
detail: modelData.connector ? modelData.connector + " · " + modelData.value + "%"
: modelData.value + "%"
divider: brightnessRow.index < Brightness.displays.length - 1
controlWidth: 190
ValueSlider {
anchors.verticalCenter: parent.verticalCenter
anchors.right: parent.right
width: parent.width
value: brightnessRow.modelData.value / 100
onMoved: v => Brightness.set(brightnessRow.modelData.bus, Math.round(v * 100))
}
}
}
}
SettingsCard {
title: "Gaming display policy"
subtitle: "Applied immediately and restored when Panama starts."
subtitle: "Applied immediately and restored when the session starts."
ToggleRow { setting: "autoHdr" }
ChoiceRow { setting: "vrrPolicy" }
@@ -9,7 +9,7 @@ SettingsPage {
readonly property string greeting: openedHour < 12 ? "Good morning" : (openedHour < 18 ? "Good afternoon" : "Good evening")
title: `${root.greeting}, Gabriel`
lede: "Your Panama desktop is configured and ready."
lede: "Your desktop is configured and ready."
SettingsCard {
title: SystemSettings.monitorDescription || "Active display"
@@ -202,7 +202,7 @@ SettingsPage {
SettingRow {
label: "Private configuration"
detail: "Saved with owner-only permissions in Panama's private environment file"
detail: "Saved with owner-only permissions in a private environment file"
controlWidth: 216
Row {
@@ -239,7 +239,7 @@ SettingsPage {
SettingRow {
label: "Light catalog"
detail: "Panama reads light state through the Home Assistant helper."
detail: "Light state is read through the Home Assistant helper."
divider: false
controlWidth: 176
@@ -0,0 +1,57 @@
// Mouse & Touchpad.
//
// GNOME splits pointing devices out of Keyboard, and so does this: the settings
// are unrelated, and burying pointer speed under a page called "Shortcuts" is
// where it was before. These all reach Hyprland's input section, which until now
// could only be changed by editing hypr/input.lua by hand.
//
// The touchpad card renders only when a touchpad is attached. On a desktop it
// would be worse than useless -- every switch on it appears to work, because the
// preference is stored and the compositor accepts an option for a device class
// with no members, so the user would be toggling settings that can never affect
// anything with nothing to say so.
import QtQuick
import qs.config
import qs.services
SettingsPage {
id: root
title: "Mouse & Touchpad"
lede: InputDevices.hasTouchpad
? "Pointer behaviour for your mouse and touchpad."
: "Pointer behaviour. Touchpad settings appear when a touchpad is attached."
SettingsCard {
title: "Mouse"
subtitle: "Applied to every pointing device that is not a touchpad."
SliderRow { setting: "pointerSensitivity" }
ChoiceRow { setting: "accelProfile" }
ToggleRow { setting: "naturalScroll" }
SliderRow { setting: "scrollFactor" }
ToggleRow { setting: "leftHanded"; divider: false }
}
SettingsCard {
visible: InputDevices.hasTouchpad
title: "Touchpad"
subtitle: "Separate from the mouse on purpose: libinput keeps them apart, and a touchpad and a mouse usually want to scroll in opposite directions."
ToggleRow { setting: "touchpadTapToClick" }
ToggleRow { setting: "touchpadNaturalScroll" }
ToggleRow { setting: "touchpadDisableWhileTyping" }
SliderRow { setting: "touchpadScrollFactor" }
ChoiceRow { setting: "touchpadDragLock" }
ToggleRow { setting: "touchpadMiddleButtonEmulation"; divider: false }
}
SettingsCard {
title: "Pointer"
ChoiceRow { setting: "followMouse" }
SliderRow { setting: "cursorInactiveTimeout"; zeroLabel: "Never" }
SliderRow { setting: "cursorSize"; divider: false }
}
}
@@ -24,7 +24,7 @@ SettingsPage {
TextRow {
label: "Notification history"
detail: "Live notifications retained by Panama"
detail: "Live notifications retained by the shell"
value: `${Notifs.history.length} items`
}
@@ -0,0 +1,122 @@
// Online Accounts.
//
// GNOME's panel of the same name, except for one step. The accounts are GNOME
// Online Accounts objects on D-Bus, the daemon already runs here, and listing,
// per-service toggles and removal are all done natively on this page.
//
// Signing in to an OAuth provider is handed to GNOME's panel, because the
// credentials are OAuth tokens and the code that obtains them ships without a
// scriptable binding. That is stated on the page rather than hidden behind a
// button that looks native, because a hand-off the user does not expect reads
// as a bug.
//
// Accounts needing attention are surfaced first. GOA knows when a token has
// expired and nothing outside its own panel says so, which is how an account
// quietly stops syncing for weeks.
import QtQuick
import qs.config
import qs.services
SettingsPage {
id: root
title: "Online Accounts"
lede: OnlineAccounts.attentionCount > 0
? OnlineAccounts.attentionCount + (OnlineAccounts.attentionCount === 1
? " account needs you to sign in again."
: " accounts need you to sign in again.")
: "Accounts your mail, calendar, contacts, and files come from."
Component.onCompleted: if (!OnlineAccounts.scanned) OnlineAccounts.refresh()
SettingsCard {
visible: !OnlineAccounts.available
title: "Accounts unavailable"
subtitle: OnlineAccounts.lastError
}
SettingsCard {
visible: OnlineAccounts.scanned && OnlineAccounts.available && OnlineAccounts.accounts.length === 0
title: "No accounts yet"
subtitle: "Adding one lets mail, calendar, contacts, and file managers share a single sign-in."
ActionRow {
label: "Add an account"
detail: "Google, Nextcloud, Microsoft Exchange, IMAP, WebDAV, and Kerberos"
action: "Add account"
divider: false
onTriggered: SystemSettings.openGnomePanel("online-accounts")
}
}
Repeater {
model: OnlineAccounts.accounts
SettingsCard {
id: accountCard
required property var modelData
title: accountCard.modelData.identity || accountCard.modelData.providerName
subtitle: accountCard.modelData.needsAttention
? accountCard.modelData.providerName + " · sign-in expired, so this account has stopped syncing"
: accountCard.modelData.providerName
// Only when it is true, because it is the one thing on this page
// that needs acting on.
ActionRow {
visible: accountCard.modelData.needsAttention
label: "Sign in again"
detail: "Re-authorising uses the provider's own sign-in page, which GNOME's panel hosts"
action: "Sign in"
onTriggered: SystemSettings.openGnomePanel("online-accounts")
}
Repeater {
model: accountCard.modelData.services
SettingRow {
id: serviceRow
required property var modelData
required property int index
label: serviceRow.modelData.label
detail: serviceRow.modelData.enabled
? "Applications using " + serviceRow.modelData.label.toLowerCase() + " can see this account"
: "Hidden from applications"
controlWidth: 48
SettingsToggle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
checked: serviceRow.modelData.enabled
onToggled: value => OnlineAccounts.setService(
accountCard.modelData.path, serviceRow.modelData.key, value)
}
}
}
ActionRow {
label: "Remove this account"
detail: "Signs out and removes it from every application that was using it"
action: "Remove"
divider: false
onTriggered: OnlineAccounts.remove(accountCard.modelData.path)
}
}
}
SettingsCard {
visible: OnlineAccounts.available && OnlineAccounts.accounts.length > 0
title: "Add another account"
subtitle: "Signing in happens on the provider's own page. GNOME's panel hosts that step; everything after it is managed here."
ActionRow {
label: "Add an account"
detail: "Google, Nextcloud, Microsoft Exchange, IMAP, WebDAV, and Kerberos"
action: "Add account"
divider: false
onTriggered: SystemSettings.openGnomePanel("online-accounts")
}
}
}
@@ -14,14 +14,48 @@ import qs.services
SettingsPage {
id: root
// Probing the power daemon is a D-Bus round trip, so it happens when the
// page opens rather than at shell startup.
Component.onCompleted: if (!PowerProfiles.scanned) PowerProfiles.refresh()
title: "Power & Lock"
lede: "When the screen turns off, when the session locks, and whether it ever sleeps."
// The same profiles GNOME's Power panel offers. Not a stored preference --
// the daemon owns it, it survives Panama restarts, and anything else on the
// system can change it, so a copy here would drift.
SettingsCard {
visible: PowerProfiles.available || PowerProfiles.lastError !== ""
title: "Power profile"
subtitle: PowerProfiles.degraded !== ""
? "Performance is limited right now: " + PowerProfiles.degraded
: (PowerProfiles.available
? "Applies to the whole system and persists across sessions."
: PowerProfiles.lastError)
Repeater {
model: PowerProfiles.profiles
SettingRow {
id: profileRow
required property var modelData
required property int index
label: PowerProfiles.label(profileRow.modelData)
detail: PowerProfiles.detail(profileRow.modelData)
value: profileRow.modelData === PowerProfiles.active ? "Active" : ""
divider: profileRow.index < PowerProfiles.profiles.length - 1
activatable: profileRow.modelData !== PowerProfiles.active && !PowerProfiles.busy
onActivated: PowerProfiles.set(profileRow.modelData)
}
}
}
SettingsCard {
title: "Idle behaviour"
subtitle: IdleLock.managed
? "Panama is managing hypridle. Changes take effect immediately."
: "hypridle is running Panama's shipped configuration. Turn on management below to make these adjustable."
? "Idle timings are managed here. Changes take effect immediately."
: "hypridle is running its shipped configuration. Turn on management below to make these adjustable."
SliderRow { setting: "screenBlankMinutes"; zeroLabel: "Never" }
SliderRow { setting: "lockMinutes"; zeroLabel: "Never" }
@@ -41,10 +75,10 @@ SettingsPage {
SettingsCard {
title: "Management"
subtitle: "Panama generates hypridle's configuration into your state directory and points the service at it with a systemd drop-in. ~/.config/hypr is a symlink into the Panama repository, so the shipped configuration cannot be rewritten in place."
subtitle: "hypridle's configuration is generated into your state directory and the service is pointed at it with a systemd drop-in. ~/.config/hypr is a symlink into the configuration repository, so the shipped file cannot be rewritten in place."
SettingRow {
label: "Let Panama manage idle timings"
label: "Manage idle timings here"
detail: IdleLock.serviceState === "active"
? "hypridle is running"
: "hypridle is " + IdleLock.serviceState
@@ -0,0 +1,166 @@
// Privacy & Security.
//
// GNOME's Privacy panel covers screen lock, camera and microphone access, file
// history, trash, and device security. Panama covers the parts it genuinely
// owns and is explicit about the parts it does not.
//
// The file-history and trash settings are the notable omission, and the reason
// is worth stating: those are GNOME preferences enforced by gsd-housekeeping,
// which is not running in a Hyprland session. Offering switches for them would
// store a preference, change nothing, and give no sign of it -- the exact
// failure this codebase keeps designing against. So they are delegated by name
// rather than reimplemented as controls that lie.
import QtQuick
import qs.config
import qs.services
SettingsPage {
id: root
title: "Privacy & Security"
lede: DeviceSecurity.scanned && DeviceSecurity.attentionCount === 0
? "Screen lock, device access, and a machine whose security settings all check out."
: "Screen lock, which applications can see you, and how this machine is protected."
Component.onCompleted: {
if (!DeviceSecurity.scanned)
DeviceSecurity.refresh();
if (!Keyring.scanned)
Keyring.refresh();
}
SettingsCard {
title: "Screen lock"
subtitle: "The same settings as Power & Lock, which is where the idle timings live."
SliderRow { setting: "lockMinutes"; zeroLabel: "Never" }
ToggleRow { setting: "lockOnSleep"; divider: false }
}
// The login keyring, which nothing else surfaces.
//
// It is unlocked at sign-in by PAM, so this card normally just confirms
// that. It earns its place on the rare occasion it is not: a locked keyring
// breaks saved passwords everywhere at once, and does it without ever
// saying the word "keyring" -- you get a mail account that will not
// authenticate and a git push that cannot find its key.
SettingsCard {
visible: Keyring.scanned
title: "Saved passwords"
subtitle: !Keyring.available
? "No secret service is answering, so saved passwords are unavailable."
: Keyring.locked
? "The login keyring is locked. Saved passwords cannot be read until it is unlocked, and applications that need one will appear to fail for unrelated reasons."
: "The login keyring is unlocked, as it is after every normal sign-in."
// Two rows rather than one with a conditional button: a locked keyring
// needs an action, an unlocked one is a statement of fact, and ActionRow
// and TextRow already say exactly those two things.
ActionRow {
visible: Keyring.available && Keyring.locked
label: "Login keyring"
detail: "Unlock to restore access to stored passwords and keys"
action: Keyring.unlocking ? "Waiting…" : "Unlock"
enabled: !Keyring.unlocking
divider: Keyring.replacementDaemon || Keyring.lastError !== ""
onTriggered: Keyring.unlock()
}
TextRow {
visible: !(Keyring.available && Keyring.locked)
label: "Login keyring"
detail: Keyring.available
? "Unlocked at sign-in by PAM, the same way GNOME does it"
: "No secret service is answering on this session"
value: Keyring.available ? "Unlocked" : "Unavailable"
divider: Keyring.replacementDaemon || Keyring.lastError !== ""
}
// Only shown when it is true, because it is a diagnostic rather than a
// setting: it means the daemon holding your secrets is not the one PAM
// started, so whatever unlocked it will not survive a restart.
SettingRow {
visible: Keyring.replacementDaemon
label: "Keyring service"
detail: "The original keyring service was replaced during this session, usually after it crashed. Signing out and back in restores the one PAM unlocks."
value: "Replaced"
divider: Keyring.lastError !== ""
}
SettingRow {
visible: Keyring.lastError !== ""
label: "Keyring problem"
detail: Keyring.lastError
divider: false
}
}
SettingsCard {
title: "Camera & microphone"
subtitle: PrivacyState.anyActive
? "In use right now — the bar shows an indicator whenever this is true."
: "Nothing is using your camera or microphone."
TextRow {
label: "Camera"
detail: PrivacyState.cameraActive
? "In use by " + (PrivacyState.cameraApp || "an application")
: "Not in use"
value: PrivacyState.cameraActive ? "Active" : "Idle"
}
TextRow {
label: "Microphone"
detail: PrivacyState.microphoneActive
? "In use by " + (PrivacyState.microphoneApp || "an application")
: "Not in use"
value: PrivacyState.microphoneActive ? "Active" : "Idle"
}
TextRow {
label: "Screen sharing"
detail: PrivacyState.screenSharingActive
? "Being shared by " + (PrivacyState.screenSharingApp || "an application")
: "Not being shared"
value: PrivacyState.screenSharingActive ? "Active" : "Idle"
divider: false
}
}
SettingsCard {
title: "Device security"
subtitle: DeviceSecurity.attentionCount === 0
? "Everything below is in its recommended state."
: DeviceSecurity.attentionCount + " item"
+ (DeviceSecurity.attentionCount === 1 ? "" : "s") + " below may deserve attention."
Repeater {
model: DeviceSecurity.facts
TextRow {
id: factRow
required property var modelData
required property int index
label: factRow.modelData.label
detail: factRow.modelData.detail
value: factRow.modelData.value
divider: factRow.index < DeviceSecurity.facts.length - 1
}
}
}
SettingsCard {
title: "Owned by Fedora"
subtitle: "File history and trash retention are GNOME preferences, applied by a housekeeping service that does not run in a Hyprland session. They are not offered as switches here, because storing that preference would change nothing."
ActionRow {
label: "File history & trash"
detail: "Opens GNOME Settings, which owns these"
action: "Open privacy"
divider: false
onTriggered: SystemSettings.openGnomePanel("privacy")
}
}
}
@@ -1,4 +1,4 @@
# Panama Settings
# Settings
The control centre for everything Panama owns. Anything the system owns —
hardware, accounts, printers — is delegated to GNOME Settings and labelled as
@@ -0,0 +1,75 @@
// Region & Language.
//
// GNOME keeps language and formats under System; the setting itself is the
// machine's locale, which localectl owns. Panama does not store a copy of it --
// there is exactly one system locale and localectl is where it lives, so a
// preference here would be a second source of truth that drifts the moment
// anything else changes it.
//
// Keyboard layout is deliberately not repeated here even though GNOME groups it
// with region. It is a compositor setting that applies instantly, it lives on
// the Keyboard page with the rest of the typing settings, and showing it twice
// invites the two views to disagree.
import QtQuick
import qs.config
import qs.services
SettingsPage {
id: root
title: "Region & Language"
lede: SystemLocale.pendingRestart
? "Your new language applies to programs started after you sign out and back in."
: "The language and regional formats this machine uses."
Component.onCompleted: if (SystemLocale.locales.length === 0) SystemLocale.refresh()
SettingsCard {
title: "Language"
subtitle: "Changing this needs your password, and takes effect for programs started afterwards."
TextRow {
label: "Current language"
detail: SystemLocale.pendingRestart
? "Chosen, but not in use until you sign out and back in"
: "Used by programs that ask the system what language to speak"
value: SystemLocale.currentLabel || "Reading…"
}
SearchPicker {
width: parent.width
items: SystemLocale.locales
current: SystemLocale.current
placeholder: "Search languages and regions"
emptyText: SystemLocale.scanning ? "Reading installed locales…" : "No locales are installed"
onPicked: value => SystemLocale.set(value)
}
}
SettingsCard {
visible: SystemLocale.lastError !== ""
title: "Language problem"
subtitle: SystemLocale.lastError
}
SettingsCard {
title: "Formats"
subtitle: "Dates, times, and numbers follow the language above. The desktop's own clock formatting is on the Appearance page."
ActionRow {
label: "Clock and date display"
detail: "How the desktop itself shows the time"
action: "Open appearance"
onTriggered: ShellState.openSettings("appearance")
}
ActionRow {
label: "Regional formats"
detail: "Separate per-category formats (LC_TIME, LC_NUMERIC) are owned by Fedora"
action: "Open system"
divider: false
onTriggered: SystemSettings.openGnomePanel("system", "region")
}
}
}
@@ -18,7 +18,7 @@ SettingsPage {
SettingsCard {
title: "Read anything on screen"
subtitle: "Select a region, window, or display. Panama recognizes it locally and gives you clean follow-up actions."
subtitle: "Select a region, window, or display. It is recognized locally, with clean follow-up actions."
SettingRow {
icon: "󰗊"
@@ -52,7 +52,7 @@ SettingsPage {
SettingsCard {
title: "Local recognition"
subtitle: "The screen image stays in Panama's cache and is deleted when you dismiss the result."
subtitle: "The screen image stays in a local cache and is deleted when you dismiss the result."
TextRow {
label: "Text recognition"
@@ -0,0 +1,94 @@
// A searchable list of choices, for settings with far too many values to put in
// a dropdown and no useful preview to show.
//
// SearchPicker {
// items: Locale.locales // [{ value, label, detail }]
// current: Locale.current
// placeholder: "Search languages"
// onPicked: value => Locale.set(value)
// }
//
// FontPicker is the same idea specialised: it draws each candidate in its own
// family, which is the whole reason it is a list rather than a text field. This
// one is for choices whose only useful preview is their name.
import QtQuick
import qs.config
// SearchField lives with the clipboard module, which is where it was first
// needed; FontPicker imports it from the same place.
import qs.modules.clipboard
Column {
id: root
// [{ value, label, detail }]
property var items: []
property string current: ""
property string emptyText: "Nothing to choose from"
property string placeholder: "Search"
signal picked(string value)
spacing: 0
// Capped and filtered rather than listing everything: 327 locales inside a
// page that is already scrolling is worse than a search box.
readonly property var matches: {
const needle = filter.text.trim().toLowerCase();
const list = root.items.filter(item =>
needle === ""
? true
: (String(item.label).toLowerCase().indexOf(needle) >= 0
|| String(item.detail).toLowerCase().indexOf(needle) >= 0));
// The current choice stays visible while browsing, so it is always
// clear what would be replaced.
const selected = list.find(item => item.value === root.current);
if (needle === "" && selected !== undefined)
return [selected].concat(list.filter(item => item.value !== root.current)).slice(0, 12);
return list.slice(0, 12);
}
SearchField {
id: filter
width: parent.width
placeholder: root.placeholder
}
Repeater {
model: root.matches
SettingRow {
id: candidate
required property var modelData
required property int index
readonly property bool selected: candidate.modelData.value === root.current
label: candidate.modelData.label
detail: candidate.selected ? "Currently in use" : candidate.modelData.detail
controlWidth: 120
divider: candidate.index < root.matches.length - 1
activatable: !candidate.selected
onActivated: root.picked(candidate.modelData.value)
Text {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
visible: candidate.selected
text: "✓"
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize + 2
color: Theme.accent
}
}
}
SettingRow {
width: parent.width
visible: root.matches.length === 0
label: root.emptyText
divider: false
}
}
@@ -120,14 +120,40 @@ SettingsPage {
SettingsCard {
title: "Fedora system settings"
subtitle: "These remain owned by trusted system services and GNOME's mature panels."
subtitle: "Panels this app does not own, because they configure system services rather than the desktop. Each row opens the panel that actually owns it. Printers and online accounts live with the rest of the network hardware, on Network & Devices."
// This was one row listing five subjects and opening the network panel
// regardless. Naming a panel and then not opening it is worse than not
// offering it: it looks like a broken button rather than a deliberate
// hand-off, and someone looking for printers had to know to navigate
// once GNOME Settings appeared on the wrong page.
ActionRow {
label: "Users"
detail: "Accounts, passwords, and automatic login"
action: "Open users"
onTriggered: SystemSettings.openGnomePanel("system", "users")
}
ActionRow {
label: "Network, Bluetooth, printers, users, and accounts"
detail: "GNOME Settings remains searchable from the launcher too"
label: "Sharing"
detail: "Remote desktop, media sharing, and remote login"
action: "Open sharing"
onTriggered: SystemSettings.openGnomePanel("sharing")
}
ActionRow {
label: "Colour profiles"
detail: "ICC profiles for displays, printers, and scanners"
action: "Open colour"
onTriggered: SystemSettings.openGnomePanel("color")
}
ActionRow {
label: "Digital wellbeing"
detail: "Screen time and break reminders"
action: "Open wellbeing"
divider: false
action: "Open network"
onTriggered: SystemSettings.openGnomePanel("network")
onTriggered: SystemSettings.openGnomePanel("wellbeing")
}
}
}
@@ -40,7 +40,7 @@ Rectangle {
anchors.left: parent.left
anchors.leftMargin: 18
anchors.verticalCenter: parent.verticalCenter
text: "Panama Settings"
text: "Settings"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
@@ -99,6 +99,10 @@ Rectangle {
case "notifications": return notificationsPage;
case "screen-intelligence": return screenIntelligencePage;
case "shortcuts": return shortcutsPage;
case "mouse": return mousePage;
case "privacy": return privacyPage;
case "region": return regionPage;
case "accounts": return onlineAccountsPage;
case "accessibility": return accessibilityPage;
case "power": return powerPage;
case "datetime": return dateTimePage;
@@ -153,6 +157,10 @@ Rectangle {
Component { id: notificationsPage; NotificationsPage {} }
Component { id: screenIntelligencePage; ScreenIntelligencePage {} }
Component { id: shortcutsPage; ShortcutsPage {} }
Component { id: mousePage; MousePage {} }
Component { id: privacyPage; PrivacyPage {} }
Component { id: regionPage; RegionPage {} }
Component { id: onlineAccountsPage; OnlineAccountsPage {} }
Component { id: servicesPage; ServicesPage {} }
Component { id: aboutPage; AboutPage {} }
@@ -31,13 +31,17 @@ Rectangle {
{ page: "sound", label: "Sound", icon: "\u{F057E}" },
{ page: "notifications", label: "Notifications & Focus", icon: "\u{F009A}" },
{ page: "screen-intelligence", label: "Screen Intelligence", icon: "\u{F05A8}" },
{ page: "shortcuts", label: "Input & Shortcuts", icon: "\u{F030C}" },
{ page: "shortcuts", label: "Keyboard", icon: "\u{F030C}" },
{ page: "mouse", label: "Mouse & Touchpad", icon: "\u{F037D}" },
{ page: "privacy", label: "Privacy & Security", icon: "\u{F0483}" },
{ page: "region", label: "Region & Language", icon: "\u{F0AC2}" },
{ page: "accounts", label: "Online Accounts", icon: "\u{F0004}" },
{ page: "accessibility", label: "Accessibility", icon: "\u{F0208}" },
{ page: "power", label: "Power & Lock", icon: "\u{F0425}" },
{ page: "datetime", label: "Date & Time", icon: "\u{F0954}" },
{ page: "applications", label: "Applications", icon: "\u{F003B}" },
{ page: "services", label: "Startup & Services", icon: "\u{F0493}" },
{ page: "about", label: "About Panama", icon: "\u{F02FD}" }
{ page: "about", label: "About", icon: "\u{F02FD}" }
]
width: 272
@@ -56,15 +60,6 @@ Rectangle {
height: implicitHeight
spacing: 12
Text {
text: "PANAMA"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeLarge
font.weight: Font.DemiBold
font.letterSpacing: 2.2
}
Rectangle {
width: parent.width
height: 36
@@ -314,7 +309,7 @@ Rectangle {
anchors.left: parent.left
anchors.leftMargin: 36
anchors.verticalCenter: parent.verticalCenter
text: "Panama desktop is healthy"
text: "Desktop is healthy"
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
@@ -8,7 +8,7 @@ FloatingWindow {
readonly property var homePhoneDiagnostics: settingsShell.homePhoneDiagnostics
title: "Panama Settings"
title: "Settings"
visible: ShellState.settingsOpen
implicitWidth: 1120
implicitHeight: 760
@@ -28,24 +28,19 @@ SettingsPage {
SettingsCard {
title: "Keyboard"
TextRow {
label: "Keyboard layout"
detail: "XKB layout name. Changing it needs a compositor reload, so it is shown here rather than offered as a control that appears to apply instantly."
value: Settings ? DesktopPreferences.get("keyboardLayout") : "us"
}
// These were read-only text, on the grounds that a layout change needed
// a compositor reload. It does not: setting input:kb_variant through
// hl.config re-keymaps attached keyboards immediately -- verified by
// watching active_keymap on a real keyboard change and change back. So
// they are real controls.
TextEntryRow { setting: "keyboardLayout"; placeholder: "us" }
TextEntryRow { setting: "keyboardVariant"; placeholder: "none" }
TextEntryRow { setting: "keyboardOptions"; placeholder: "compose:ralt" }
SliderRow { setting: "keyRepeatDelay" }
SliderRow { setting: "keyRepeatRate" }
ToggleRow { setting: "numlockByDefault"; divider: false }
}
SettingsCard {
title: "Pointer"
ChoiceRow { setting: "followMouse" }
SliderRow { setting: "pointerSensitivity" }
SliderRow { setting: "cursorInactiveTimeout"; zeroLabel: "Never"; divider: false }
}
SettingsCard {
title: "Hardware input"
@@ -155,7 +150,7 @@ SettingsPage {
SettingsCard {
visible: Object.keys(Keybinds.overrides).length > 0
title: "Changed shortcuts"
subtitle: "Rebinding stores only the new chord; what a shortcut does always comes from Panama's configuration."
subtitle: "Rebinding stores only the new chord; what a shortcut does always comes from the desktop's configuration."
ActionRow {
label: "Restore every shipped shortcut"
@@ -0,0 +1,109 @@
// A free-text setting, bound to a schema key by name.
//
// TextEntryRow { setting: "keyboardOptions"; placeholder: "compose:ralt" }
//
// For the settings whose value is a short string with too many legal values to
// enumerate -- XKB layouts and options, chiefly. Label, explanation, and
// validation all come from PreferenceSchema, so a row cannot drift from the
// setting it edits.
//
// Rejected input is shown as rejected rather than silently dropped or quietly
// sanitised. Several of these strings are serialised into an hl.config payload,
// where stripping an unexpected character would turn a typo into a different
// working setting -- so the schema's pattern decides, the field turns red when
// it fails, and nothing is committed until it passes.
//
// Committed on Enter or when focus leaves, not per keystroke: half a layout
// name is a valid string that means something else, and each commit is a round
// trip to the compositor.
import QtQuick
import qs.config
import qs.services
SettingRow {
id: root
required property string setting
property string placeholder: ""
readonly property var spec: PreferenceSchema.spec(root.setting)
readonly property string stored: String(DesktopPreferences.get(root.setting) ?? "")
// Empty is always allowed to be typed through, even where the pattern
// forbids it, so a field can be cleared on the way to a new value.
readonly property bool valid: input.text === ""
|| !root.spec?.pattern
|| new RegExp(root.spec.pattern).test(input.text)
label: root.spec ? root.spec.label : root.setting
detail: root.spec ? root.spec.detail : ""
controlWidth: 210
function commit(): void {
if (!root.valid || input.text === root.stored)
return;
SystemSettings.commitPreference(root.setting, input.text);
}
function revert(): void {
input.text = root.stored;
}
// Follows the store when the value changes elsewhere -- a reset, a restored
// backup -- but never while the field has focus, which would overwrite what
// is being typed.
onStoredChanged: if (!input.activeFocus) input.text = root.stored
Rectangle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
width: root.controlWidth
height: 30
radius: 8
color: Theme.alpha(Theme.fg, 0.05)
border.width: 1
border.color: !root.valid
? Theme.danger
: (input.activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.12))
TextInput {
id: input
anchors.fill: parent
anchors.leftMargin: 10
anchors.rightMargin: 10
verticalAlignment: TextInput.AlignVCenter
clip: true
text: root.stored
color: root.valid ? Theme.fg : Theme.danger
font.family: Theme.fontMono
font.pixelSize: Theme.fontSizeSmall
selectByMouse: true
selectionColor: Theme.alpha(Theme.accent, 0.35)
onAccepted: root.commit()
// Commit, then show what is actually stored. If the compositor
// refused the value, the field snaps back to the value in effect
// rather than displaying a setting that was never applied; if it
// accepted, onStoredChanged brings the field back up to date the
// moment the write lands.
onActiveFocusChanged: if (!activeFocus) { root.commit(); root.revert(); }
Keys.onEscapePressed: {
root.revert();
input.focus = false;
}
Text {
anchors.verticalCenter: parent.verticalCenter
visible: input.text === "" && !input.activeFocus
text: root.placeholder
color: Theme.fgMuted
font: input.font
}
}
}
}
@@ -156,7 +156,7 @@ Item {
width: parent.width - 40
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap
text: "No images found. Panama looks in ~/Pictures/Wallpapers, ~/Pictures/Backgrounds, ~/.local/share/backgrounds, and /usr/share/backgrounds."
text: "No images found. Looked in ~/Pictures/Wallpapers, ~/Pictures/Backgrounds, ~/.local/share/backgrounds, and /usr/share/backgrounds."
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
@@ -46,3 +46,9 @@ SoundDeviceRow 1.0 SoundDeviceRow.qml
TimeOfDayRow 1.0 TimeOfDayRow.qml
LocationPicker 1.0 LocationPicker.qml
FontPicker 1.0 FontPicker.qml
MousePage 1.0 MousePage.qml
TextEntryRow 1.0 TextEntryRow.qml
PrivacyPage 1.0 PrivacyPage.qml
RegionPage 1.0 RegionPage.qml
SearchPicker 1.0 SearchPicker.qml
OnlineAccountsPage 1.0 OnlineAccountsPage.qml
+127
View File
@@ -0,0 +1,127 @@
#!/usr/bin/env bash
# What this machine is, as JSON: {label, value} pairs in display order.
#
# GNOME's About panel answers "what am I running on" in one screen, and
# fastfetch answers it in more detail; this covers both -- model, OS, kernel,
# uptime, package counts, shell, resolution, processor, memory, swap, disk and
# locale. Rows are ordered roughly the way fastfetch presents them: what the
# system is, then what is installed on it, then the hardware underneath.
#
# Graphics is deliberately absent: GraphicsDevices already enumerates GPUs for
# the vitals readout, and naming them again here would be a second source of
# truth that could disagree with the first. The page joins the two.
#
# Anything unreadable is omitted rather than reported as "Unknown". These are
# facts about hardware, and a row saying "Processor: Unknown" is noise where
# simply not having the row is not.
set -uo pipefail
facts=()
emit() {
[[ -n "${2:-}" ]] || return 0
facts+=("$(jq -cn --arg label "$1" --arg value "$2" '{label: $label, value: $value}')")
}
# ── System ───────────────────────────────────────────────────────────────────
if [[ -r /etc/os-release ]]; then
# Sourced in a subshell so the variables cannot leak into this script.
os="$( . /etc/os-release 2>/dev/null && printf '%s' "${PRETTY_NAME:-$NAME}" )"
emit "Operating system" "$os"
fi
# DMI strings are frequently placeholders ("To Be Filled By O.E.M.", "Default
# string"). Those are worse than nothing, so they are filtered out.
dmi() {
local value
value="$(cat "/sys/class/dmi/id/$1" 2>/dev/null)" || return 0
case "$value" in
""|"To Be Filled By O.E.M."*|"Default string"|"System Product Name"|"Unknown"|"None") return 0 ;;
esac
printf '%s' "$value"
}
vendor="$(dmi sys_vendor)"
product="$(dmi product_name)"
if [[ -n "$vendor" && -n "$product" ]]; then
emit "Model" "$vendor $product"
else
emit "Model" "${product:-$vendor}"
fi
emit "Hostname" "$(hostnamectl hostname 2>/dev/null || hostname 2>/dev/null)"
emit "Kernel" "$(uname -r 2>/dev/null)"
# `uptime -p` already reads as prose ("1 week, 23 hours, 5 minutes"); only the
# leading "up " needs removing.
emit "Uptime" "$(uptime -p 2>/dev/null | sed 's/^up //')"
# ── Installed ────────────────────────────────────────────────────────────────
# Counted rather than listed. rpm -qa on a full workstation is a few thousand
# lines and takes a moment, which is part of why this whole helper runs on
# demand rather than at startup.
packages=""
if command -v rpm >/dev/null 2>&1; then
rpm_count="$(rpm -qa 2>/dev/null | wc -l)"
[[ "$rpm_count" -gt 0 ]] && packages="$rpm_count rpm"
fi
if command -v flatpak >/dev/null 2>&1; then
flatpak_count="$(flatpak list --app 2>/dev/null | wc -l)"
if [[ "$flatpak_count" -gt 0 ]]; then
[[ -n "$packages" ]] && packages="$packages, "
packages="$packages$flatpak_count flatpak"
fi
fi
emit "Packages" "$packages"
# $SHELL is the login shell, which is the one worth reporting -- the shell this
# script happens to run under is an implementation detail of the caller.
if [[ -n "${SHELL:-}" ]]; then
shell_name="$(basename "$SHELL")"
shell_version="$("$SHELL" --version 2>/dev/null | head -1 | grep -oE '[0-9]+\.[0-9]+(\.[0-9]+)?' | head -1)"
emit "Shell" "${shell_name}${shell_version:+ $shell_version}"
fi
emit "Locale" "${LANG:-}"
case "${XDG_SESSION_TYPE:-}" in
wayland) emit "Windowing system" "Wayland" ;;
x11) emit "Windowing system" "X11" ;;
esac
# ── Hardware ─────────────────────────────────────────────────────────────────
# The focused monitor's actual mode, including the fractional scale, since a
# 4500x3000 panel at 1.5 presents very differently from one at 1.
if command -v hyprctl >/dev/null 2>&1; then
emit "Resolution" "$(hyprctl -j monitors 2>/dev/null \
| jq -r 'map(select(.focused)) + . | .[0]
| select(. != null)
| "\(.width)x\(.height) @ \(.refreshRate | floor)Hz · scale \(.scale)"' 2>/dev/null)"
fi
cpu="$(awk -F': ' '/^model name/ { print $2; exit }' /proc/cpuinfo 2>/dev/null)"
threads="$(nproc 2>/dev/null)"
if [[ -n "$cpu" ]]; then
# The marketing name usually already says "6-Core", so the thread count is
# the part that adds information.
[[ -n "$threads" ]] && cpu="$cpu ($threads threads)"
emit "Processor" "$cpu"
fi
# Reported as the kernel sees it, which is a little under the sticker figure
# because firmware and integrated graphics reserve some before Linux starts.
#
# Total rather than used: About is not a monitor, and a "12.4 GiB used" figure
# is stale before it finishes drawing. The Home page's vitals readout is where
# live numbers belong.
emit "Memory" "$(awk '/^MemTotal:/ { printf "%.1f GiB", $2 / 1048576 }' /proc/meminfo 2>/dev/null)"
swap="$(awk '/^SwapTotal:/ { if ($2 > 0) printf "%.1f GiB", $2 / 1048576 }' /proc/meminfo 2>/dev/null)"
emit "Swap" "$swap"
read -r size used avail <<<"$(df -h --output=size,used,avail / 2>/dev/null | tail -1)"
[[ -n "${size:-}" ]] && emit "Disk" "$avail free of $size"
printf '[%s]\n' "$(IFS=,; printf '%s' "${facts[*]}")"
+145
View File
@@ -0,0 +1,145 @@
#!/usr/bin/env python3
"""Online accounts, through GNOME Online Accounts.
GOA is a daemon plus a D-Bus API, and it is already running in this session --
gvfs activates it, and the four accounts on this machine work without
gnome-shell involved anywhere. What GNOME owns is only the *panel*; the accounts
themselves are ordinary D-Bus objects that anything may read and modify.
So everything except the initial sign-in is available to us: listing accounts,
turning individual services on and off, and removing an account. That is the
whole Online Accounts panel apart from one OAuth handshake.
Adding an account is the part that splits. The daemon's AddAccount takes
credentials as an ARGUMENT -- it stores them, it does not obtain them. For
password-based providers (Nextcloud, IMAP, WebDAV) that is a username and a
password, which a settings app can reasonably collect. For Google it is an OAuth
token, and the code that runs that exchange lives in libgoa-backend, which
Fedora ships without a GIR binding -- reachable from C only. Reimplementing it
would mean our own Google client credentials. So Google sign-in is handed to
GNOME's panel, and only the sign-in.
Usage:
panama-accounts list
panama-accounts set <object-path> <service> <true|false>
panama-accounts remove <object-path>
"""
import json
import sys
# Every service GOA models. The account object carries one interface per service
# it supports, so presence of the interface is what "this account can do mail"
# means -- there is no capability list to read.
SERVICES = [
("mail", "Mail", "get_mail", "mail_disabled"),
("calendar", "Calendar", "get_calendar", "calendar_disabled"),
("contacts", "Contacts", "get_contacts", "contacts_disabled"),
("files", "Files", "get_files", "files_disabled"),
("photos", "Photos", "get_photos", "photos_disabled"),
("music", "Music", "get_music", "music_disabled"),
("chat", "Chat", "get_chat", "chat_disabled"),
]
def load_client():
import gi
gi.require_version("Goa", "1.0")
from gi.repository import Goa
return Goa.Client.new_sync(None)
def describe(obj):
account = obj.get_account()
services = []
for key, label, getter, disabled_prop in SERVICES:
if getattr(obj, getter)() is None:
continue
services.append({
"key": key,
"label": label,
"enabled": not getattr(account.props, disabled_prop),
})
return {
"path": obj.get_object_path(),
"provider": account.props.provider_type,
"providerName": account.props.provider_name,
# PresentationIdentity is the human one (an email address); Identity is
# the internal handle and is not always readable.
"identity": account.props.presentation_identity or account.props.identity,
# GOA raises this when stored credentials stop working -- an expired
# token, a changed password. It is the one piece of state a user must
# act on, and nothing else surfaces it.
"needsAttention": bool(account.props.attention_needed),
"services": services,
}
def find(client, path):
for obj in client.get_accounts():
if obj.get_object_path() == path:
return obj
return None
def main():
action = sys.argv[1] if len(sys.argv) > 1 else "list"
try:
client = load_client()
except Exception as error: # noqa: BLE001 - any failure here means "no GOA"
# A machine without GOA is a legitimate state, not a crash.
print(json.dumps({
"accounts": [],
"error": f"GNOME Online Accounts is not available: {error}",
}))
return 0
if action == "list":
print(json.dumps({
"accounts": [describe(obj) for obj in client.get_accounts()],
"error": "",
}))
return 0
if action == "set":
if len(sys.argv) != 5:
print("usage: panama-accounts set <path> <service> <true|false>", file=sys.stderr)
return 2
path, service, value = sys.argv[2], sys.argv[3], sys.argv[4]
obj = find(client, path)
if obj is None:
print(f"panama-accounts: no account at {path}", file=sys.stderr)
return 1
match = next((s for s in SERVICES if s[0] == service), None)
if match is None:
print(f"panama-accounts: unknown service {service!r}", file=sys.stderr)
return 2
if getattr(obj, match[2])() is None:
print(f"panama-accounts: this account does not support {service}", file=sys.stderr)
return 1
# The property is "disabled", so enabling a service clears it.
setattr(obj.get_account().props, match[3], value.lower() not in ("true", "1", "yes"))
return 0
if action == "remove":
if len(sys.argv) != 3:
print("usage: panama-accounts remove <path>", file=sys.stderr)
return 2
obj = find(client, sys.argv[2])
if obj is None:
print(f"panama-accounts: no account at {sys.argv[2]}", file=sys.stderr)
return 1
obj.get_account().call_remove_sync(None)
return 0
print("usage: panama-accounts [list|set|remove]", file=sys.stderr)
return 2
if __name__ == "__main__":
sys.exit(main())
+157
View File
@@ -0,0 +1,157 @@
#!/usr/bin/env bash
# External monitor brightness over DDC/CI.
#
# A desktop with no backlight class device has no brightness control at all --
# brightnessctl only sees keyboard and NIC LEDs. The panel itself still has a
# brightness setting, reachable over the monitor's DDC/CI channel (VCP feature
# 0x10), which is what the buttons on the bezel drive.
#
# Usage:
# panama-brightness list -> {"displays":[...],"error":""}
# panama-brightness get <bus> -> integer percent
# panama-brightness set <bus> <pct> -> applies, prints nothing
#
# Displays are enumerated from sysfs rather than from `ddcutil detect`. The
# kernel publishes the connector-to-I2C-bus mapping directly, as
# /sys/class/drm/<card>-<connector>/ddc, along with whether anything is plugged
# in. That is better than parsing detect output in three ways: the format is
# stable where detect's brief output is undocumented, the connector name comes
# out exactly as Hyprland and the Displays page already spell it (DP-2), and
# only connectors with a monitor attached get probed -- one bus on this machine
# instead of fourteen, which is the difference between a fast scan and a slow
# one, since each probe of an empty bus waits for a timeout.
#
# No model name is reported. Hyprland already knows the human-readable
# description of every output, so the UI joins on the connector name rather than
# having two sources of truth for what a monitor is called.
set -uo pipefail
readonly VCP_BRIGHTNESS=0x10
# Test seams. The contract needs to exercise enumeration and parsing on a
# machine whose real monitors it must not touch, so both roots this script
# reads are overridable. Nothing sets them in normal use.
readonly DRM_ROOT="${PANAMA_BRIGHTNESS_DRM_ROOT:-/sys/class/drm}"
readonly DEV_ROOT="${PANAMA_BRIGHTNESS_DEV_ROOT:-/dev}"
emit_error() {
printf '{"displays":[],"error":%s}\n' "$(jq -Rn --arg e "$1" '$e')"
exit 0
}
command -v ddcutil >/dev/null 2>&1 || emit_error 'ddcutil is not installed'
# Reading a VCP value needs read/write access to the monitor's I2C bus. The
# udev rule ddcutil ships grants that to the seat user through uaccess, but only
# to devices created after the rule was installed -- so a machine that installed
# ddcutil without rebooting has the rule in place and no access to show for it.
# That is by far the most likely reason for an empty list, and it is fixable in
# one command, so say so rather than reporting "no displays".
has_accessible_bus() {
local dev
for dev in "$DEV_ROOT"/i2c-*; do
[[ -r "$dev" && -w "$dev" ]] && return 0
done
return 1
}
# The I2C bus that carries DDC/CI for one connector.
#
# There are two, and picking the wrong one finds no monitor at all:
#
# DisplayPort carries DDC/CI over the AUX channel. That adapter shows up as a
# child directory of the connector -- /sys/class/drm/card1-DP-2/i2c-9 -- and
# is the one ddcutil talks to.
#
# The connector's `ddc` symlink points at the classic I2C line used for EDID
# on HDMI and DVI. On a DisplayPort connector it still exists and still
# resolves, but nothing answers on it: this machine's DP-2 has ddc -> i2c-5,
# where ddcutil reports "No monitor detected", while i2c-9 answers VCP 0x10
# immediately.
#
# So prefer the AUX child and fall back to the symlink. Note the readlink: the
# entries under /sys/class/drm are symlinks, and `find` does not follow the path
# it is given, so searching the unresolved path silently finds nothing.
bus_for_connector() {
local path="$1" real aux ddc
real="$(readlink -f "$path")"
aux="$(find "$real" -maxdepth 1 -name 'i2c-*' -printf '%f' -quit 2>/dev/null)"
if [[ -n "$aux" ]]; then
printf '%s' "${aux#i2c-}"
return 0
fi
ddc="$(readlink -f "$path/ddc" 2>/dev/null)" || return 1
[[ -n "$ddc" ]] || return 1
ddc="$(basename "$ddc")"
printf '%s' "${ddc#i2c-}"
}
cmd_list() {
has_accessible_bus || emit_error 'no I2C bus is accessible. ddcutil ships a udev rule that grants this, but only to devices created after it was installed. Run: sudo udevadm control --reload-rules && sudo udevadm trigger --subsystem-match=i2c-dev --subsystem-match=drm'
local rows=() connector bus value path
for path in "$DRM_ROOT"/card*-*; do
[[ -e "$path/ddc" ]] || continue
[[ "$(cat "$path/status" 2>/dev/null)" == "connected" ]] || continue
# card1-DP-2 -> DP-2, the name Hyprland uses.
connector="$(basename "$path")"
connector="${connector#card*-}"
bus="$(bus_for_connector "$path")"
[[ "$bus" =~ ^[0-9]+$ ]] || continue
# A monitor that does not implement 0x10 is not an error; it simply
# cannot be controlled, and is left out rather than shown as a slider
# that does nothing.
value="$(cmd_get "$bus")" || continue
[[ -n "$value" ]] || continue
rows+=("$(jq -cn \
--argjson bus "$bus" \
--arg connector "$connector" \
--argjson value "$value" \
'{bus: $bus, connector: $connector, value: $value}')")
done
if [[ ${#rows[@]} -eq 0 ]]; then
emit_error 'no connected monitor reports DDC/CI brightness. Some panels implement it only when "DDC/CI" is enabled in their on-screen menu.'
fi
printf '{"displays":[%s],"error":""}\n' "$(IFS=,; printf '%s' "${rows[*]}")"
}
# Prints the current brightness as a whole percent, or nothing when the display
# cannot report it. `getvcp --brief` is documented as machine readable and
# answers "VCP 10 C <current> <max>"; the max is almost always 100 but is not
# guaranteed to be, so it is read rather than assumed.
cmd_get() {
local bus="$1" out current max
out="$(timeout 10 ddcutil --bus "$bus" getvcp "$VCP_BRIGHTNESS" --brief 2>/dev/null)" || return 1
read -r _ _ _ current max <<<"$out"
[[ "$current" =~ ^[0-9]+$ && "$max" =~ ^[0-9]+$ && "$max" -gt 0 ]] || return 1
printf '%s' "$(( current * 100 / max ))"
}
cmd_set() {
local bus="$1" percent="$2" max out
[[ "$percent" =~ ^[0-9]+$ ]] || return 1
(( percent > 100 )) && percent=100
out="$(timeout 10 ddcutil --bus "$bus" getvcp "$VCP_BRIGHTNESS" --brief 2>/dev/null)" || return 1
read -r _ _ _ _ max <<<"$out"
[[ "$max" =~ ^[0-9]+$ && "$max" -gt 0 ]] || max=100
timeout 10 ddcutil --bus "$bus" setvcp "$VCP_BRIGHTNESS" "$(( percent * max / 100 ))" >/dev/null 2>&1
}
case "${1:-list}" in
list) cmd_list ;;
get) cmd_get "${2:?bus required}" ;;
set) cmd_set "${2:?bus required}" "${3:?percent required}" ;;
*) printf 'usage: panama-brightness [list|get <bus>|set <bus> <percent>]\n' >&2; exit 2 ;;
esac
+138
View File
@@ -0,0 +1,138 @@
#!/usr/bin/env python3
"""The login keyring's lock state, and a way to unlock it.
Why this exists
---------------
GNOME unlocks the login keyring at sign-in through pam_gnome_keyring, and so
does this desktop -- the PAM stack is GDM's and it works. What GNOME also has,
and a bare Hyprland session does not, is anywhere to SEE that it failed.
It does fail, rarely. gnome-keyring-daemon can crash (an upstream abort in
service_method_open_session, seen once here), and when it does, D-Bus activates
a replacement. That replacement never received the login password, so the login
keyring comes back LOCKED in the middle of a session that unlocked it correctly
at login. Everything that stores a secret then starts failing in ways that do
not mention keyrings at all: a mail client that will not authenticate, a git
push that cannot find its key, an integration that reports "not configured".
So this reports the state plainly and offers the one action that fixes it.
Unlocking prompts
-----------------
`unlock` asks the Secret Service to unlock, which raises the gcr password
dialog. That is deliberate: the password is not ours to store or handle, and it
never passes through this script. The dialog is the same one GNOME shows.
Note that a locked keyring makes a NON-INTERACTIVE caller appear to hang -- it
is not hung, it is waiting for a dialog nobody is looking at. That is worth
knowing before debugging one for an hour.
Usage:
panama-keyring status -> {"available", "locked", "collections", "daemon"}
panama-keyring unlock -> raises the password prompt; prints the new state
"""
import json
import os
import re
import sys
def daemon_origin():
"""Whether the running secrets daemon came from PAM or from D-Bus activation.
A D-Bus-activated daemon is the signature of the crash-and-replace case
above: it is the one that cannot have the login password. PAM's daemon lives
outside the app slice, so the cgroup tells the two apart.
"""
try:
for pid in os.listdir("/proc"):
if not pid.isdigit():
continue
try:
with open(f"/proc/{pid}/cmdline", "rb") as handle:
cmdline = handle.read().decode("utf-8", "replace")
except OSError:
continue
if "gnome-keyring-daemon" not in cmdline:
continue
try:
with open(f"/proc/{pid}/cgroup", "r") as handle:
cgroup = handle.read()
except OSError:
return "unknown"
if re.search(r"dbus-.*org\.freedesktop\.secrets", cgroup):
return "dbus"
return "pam"
except OSError:
pass
return "none"
def load_service():
import gi
gi.require_version("Secret", "1")
from gi.repository import Secret
return Secret, Secret.Service.get_sync(Secret.ServiceFlags.LOAD_COLLECTIONS, None)
def report(service, Secret):
collections = [
{"label": c.get_label(), "locked": c.get_locked()}
for c in service.get_collections()
]
# The login keyring is the one that matters; the others are per-application
# stores that manage their own unlocking.
login = next((c for c in collections if c["label"] == "Login"), None)
return {
"available": True,
"locked": bool(login["locked"]) if login else False,
"hasLogin": login is not None,
"collections": collections,
"daemon": daemon_origin(),
"error": "",
}
def main():
action = sys.argv[1] if len(sys.argv) > 1 else "status"
if action not in ("status", "unlock"):
print("usage: panama-keyring [status|unlock]", file=sys.stderr)
return 2
try:
Secret, service = load_service()
except Exception as error: # noqa: BLE001 - any failure here is "no keyring"
# No Secret Service at all is a legitimate state, not a crash: report it
# so the UI can say so instead of showing an empty card.
print(json.dumps({
"available": False, "locked": False, "hasLogin": False,
"collections": [], "daemon": daemon_origin(),
"error": f"The secret service is not answering: {error}",
}))
return 0
if action == "unlock":
login = next(
(c for c in service.get_collections() if c.get_label() == "Login"), None)
if login is not None and login.get_locked():
try:
# Blocks until the dialog is answered or dismissed.
service.unlock_sync([login], None)
except Exception as error: # noqa: BLE001
state = report(service, Secret)
state["error"] = f"The keyring was not unlocked: {error}"
print(json.dumps(state))
return 0
# The collection object caches its state; re-read it.
Secret, service = load_service()
print(json.dumps(report(service, Secret)))
return 0
if __name__ == "__main__":
sys.exit(main())
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env bash
# System locale, via localectl.
#
# panama-locale list -> [{value, label, detail}]
# panama-locale get -> the current LANG, e.g. en_US.UTF-8
# panama-locale set <locale>
#
# Locale codes are not names. "pt_BR.UTF-8" tells you what it means only if you
# already know, which defeats the point of a picker, so codes are resolved
# against the iso-codes database into "Portuguese (Brazil)" the way GNOME does.
# The code stays visible as the row's detail, because it is what actually gets
# written and someone choosing between two Spanish variants needs to see it.
#
# The join happens in a single jq pass. Doing it per locale meant 327 jq
# invocations, which took long enough to be visible when opening the page.
#
# Setting the locale is a privileged operation: localectl goes through polkit,
# which prompts. It also only takes effect for programs started afterwards, so
# the caller is responsible for saying a sign-out is needed -- this script does
# not pretend the running session changed.
set -uo pipefail
readonly ISO_LANG=/usr/share/iso-codes/json/iso_639-2.json
readonly ISO_COUNTRY=/usr/share/iso-codes/json/iso_3166-1.json
cmd_get() {
localectl status 2>/dev/null \
| awk -F'LANG=' '/System Locale:/ { print $2; exit }' \
| tr -d '[:space:]'
}
cmd_list() {
local locales
locales="$(localectl list-locales 2>/dev/null)" || locales=""
if [[ -z "$locales" ]]; then
printf '[]\n'
return 0
fi
# Without iso-codes installed the codes are still perfectly usable; they
# just do not get friendly names. That is a degraded list, not a failure.
if [[ ! -r "$ISO_LANG" || ! -r "$ISO_COUNTRY" ]]; then
jq -Rn --rawfile raw /dev/stdin \
'[$raw | split("\n")[] | select(length > 0) | {value: ., label: ., detail: ""}]' \
<<<"$locales"
return 0
fi
jq -Rn \
--slurpfile languages "$ISO_LANG" \
--slurpfile countries "$ISO_COUNTRY" \
--rawfile raw /dev/stdin '
# alpha_2 -> name, for both databases. Languages without a two-letter
# code cannot appear in a locale name, so they are simply absent.
($languages[0]["639-2"] | map(select(.alpha_2)) | INDEX(.alpha_2) | map_values(.name)) as $lang
| ($countries[0]["3166-1"] | INDEX(.alpha_2) | map_values(.name)) as $country
| [ $raw
| split("\n")[]
| select(length > 0)
| . as $value
# en_US.UTF-8 -> ["en", "US"]; the codeset and any @modifier are
# not part of the human name.
| ($value | split(".")[0] | split("@")[0] | split("_")) as $parts
| ($lang[$parts[0]] // $parts[0]) as $language
| (if ($parts | length) > 1 then $country[$parts[1]] else null end) as $region
| {
value: $value,
label: (if $region then "\($language) (\($region))" else $language end),
detail: $value
}
]
| sort_by(.label)
' <<<"$locales"
}
cmd_set() {
local locale="${1:-}"
# Constrained rather than passed through: this reaches a privileged
# command, and the set of legal locale names is narrow and well known.
[[ "$locale" =~ ^[[email protected]]+$ ]] || {
printf 'panama-locale: refusing a locale name with unexpected characters\n' >&2
return 2
}
localectl list-locales 2>/dev/null | grep -qxF "$locale" || {
printf 'panama-locale: %s is not an installed locale\n' "$locale" >&2
return 2
}
localectl set-locale "LANG=$locale"
}
case "${1:-list}" in
list) cmd_list ;;
get) cmd_get ;;
set) shift; cmd_set "${1:-}" ;;
*) printf 'usage: panama-locale [list|get|set <locale>]\n' >&2; exit 2 ;;
esac
+141 -6
View File
@@ -2,6 +2,8 @@
set -u
readonly PANAMA_OSD_SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
strict_delivery() {
[[ ${PANAMA_OSD_STRICT:-false} == true || ${PANAMA_OSD_STRICT:-false} == 1 ]]
}
@@ -67,18 +69,151 @@ adjust_microphone() {
show_volume "$target" microphone
}
brightness_percent() {
local output="$1" percent
percent="$(awk -F, 'NR == 1 { value=$5; gsub(/%/, "", value); print value }' <<<"$output")"
[[ $percent =~ ^[0-9]+$ ]] || return 1
printf '%s\n' "$percent"
}
brightness_error() {
local detail="$1" label="External brightness unavailable"
if [[ $detail == *udev* || $detail == *accessible* || $detail == *permission* ]]; then
label="Brightness needs permission"
fi
show_message dialog-warning-symbolic "$label" || true
if command -v notify-send >/dev/null 2>&1; then
notify-send --app-name=Panama --icon=display-brightness-symbolic \
"Brightness unavailable" "$detail" >/dev/null 2>&1 || true
fi
}
discover_ddc_bus() {
local helper="$1" cache_file="$2" list_json focused selected error bus connector
command -v jq >/dev/null 2>&1 || {
brightness_error "jq is required to discover DDC/CI displays."
return 1
}
list_json="$("$helper" list 2>/dev/null)" || {
brightness_error "The external brightness helper could not inspect connected displays."
return 1
}
if ! jq -e 'type == "object" and (.displays | type == "array")' >/dev/null 2>&1 <<<"$list_json"; then
brightness_error "The external brightness helper returned invalid display information."
return 1
fi
error="$(jq -r '.error // empty' <<<"$list_json")"
if [[ -n $error ]]; then
brightness_error "$error"
return 1
fi
focused="$(hyprctl -j monitors 2>/dev/null \
| jq -r '.[] | select(.focused == true) | .name' 2>/dev/null \
| head -n1)"
selected="$(jq -r --arg connector "$focused" '
([.displays[] | select(.connector == $connector)][0] // .displays[0] // empty)
| [.bus, .connector]
| @tsv
' <<<"$list_json")"
IFS=$'\t' read -r bus connector <<<"$selected"
if [[ ! $bus =~ ^[0-9]+$ ]]; then
brightness_error "No connected monitor exposes DDC/CI brightness control."
return 1
fi
umask 077
printf '%s\t%s\n' "$bus" "$connector" >"$cache_file"
printf '%s\n' "$bus"
}
adjust_ddc_brightness() {
local action="$1" step="$2"
local helper="${PANAMA_OSD_BRIGHTNESS_HELPER:-$PANAMA_OSD_SCRIPT_DIR/panama-brightness}"
local runtime_dir="${PANAMA_OSD_RUNTIME_DIR:-${XDG_RUNTIME_DIR:-/tmp}/panama-osd-${UID}}"
local cache_file="$runtime_dir/brightness-bus" lock_file="$runtime_dir/brightness.lock"
local bus="" connector="" current target lock_fd
[[ -x $helper ]] || {
brightness_error "The external brightness helper is not installed."
return 0
}
mkdir -p "$runtime_dir" || return 0
chmod 700 "$runtime_dir" 2>/dev/null || true
exec {lock_fd}>"$lock_file" || return 0
# DDC transactions on one I2C bus cannot safely overlap. A short wait also
# sheds an excessive key-repeat backlog instead of replaying it seconds later.
flock -w 2 "$lock_fd" || return 0
if [[ -r $cache_file ]]; then
IFS=$'\t' read -r bus connector <"$cache_file" || true
[[ $bus =~ ^[0-9]+$ ]] || bus=""
fi
if [[ -n $bus ]]; then
current="$("$helper" get "$bus" 2>/dev/null)" || current=""
if [[ ! $current =~ ^[0-9]+$ ]]; then
: >"$cache_file"
bus=""
fi
fi
if [[ -z $bus ]]; then
bus="$(discover_ddc_bus "$helper" "$cache_file")" || return 0
current="$("$helper" get "$bus" 2>/dev/null)" || current=""
fi
if [[ ! $current =~ ^[0-9]+$ ]]; then
brightness_error "The selected monitor stopped responding over DDC/CI."
return 0
fi
if [[ $action == up ]]; then
target=$(( current + step ))
else
target=$(( current - step ))
fi
(( target > 100 )) && target=100
(( target < 0 )) && target=0
if ! "$helper" set "$bus" "$target" >/dev/null 2>&1; then
brightness_error "The selected monitor did not accept the brightness change."
return 0
fi
show_progress brightness "$target" "${target}%"
}
adjust_brightness() {
local action="${1:-}" step="${2:-5}" output percent
[[ $step =~ ^[0-9]+$ ]] || {
printf 'Usage: panama-osd brightness up|down [step]\n' >&2
return 2
}
case "$action" in
up) brightnessctl -e4 -n2 set "${step}%+" >/dev/null || return ;;
down) brightnessctl -e4 -n2 set "${step}%-" >/dev/null || return ;;
up|down) ;;
*) printf 'Usage: panama-osd brightness up|down [step]\n' >&2; return 2 ;;
esac
output="$(brightnessctl -m -c backlight 2>/dev/null)" || return 0
percent="$(awk -F, 'NR == 1 { value=$5; gsub(/%/, "", value); print value }' <<<"$output")"
[[ $percent =~ ^[0-9]+$ ]] || return 0
show_progress brightness "$percent" "${percent}%"
# Laptop panels expose a kernel backlight class and remain the fastest,
# most reliable path. Desktops fall through to DDC/CI monitor control.
output="$(brightnessctl -m -c backlight 2>/dev/null)" || output=""
if percent="$(brightness_percent "$output")"; then
if [[ $action == up ]]; then
brightnessctl -e4 -n2 -c backlight set "${step}%+" >/dev/null || return 0
else
brightnessctl -e4 -n2 -c backlight set "${step}%-" >/dev/null || return 0
fi
output="$(brightnessctl -m -c backlight 2>/dev/null)" || return 0
percent="$(brightness_percent "$output")" || return 0
show_progress brightness "$percent" "${percent}%"
return
fi
adjust_ddc_brightness "$action" "$step"
}
media_action() {
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env bash
# System power profile, via the PowerProfiles D-Bus API.
#
# GNOME's Power panel offers Balanced / Performance / Power Saver; this is the
# same daemon behind it. On Fedora 44 the implementation is tuned-ppd rather
# than power-profiles-daemon, but it serves the same net.hadess.PowerProfiles
# interface, which is why this talks to the interface rather than to either
# binary -- powerprofilesctl is not even installed here.
#
# Setting a profile needs no privileges: the daemon accepts a property write
# from the active session user.
#
# Usage:
# panama-power-profile list -> {"profiles":[...],"active":"...","degraded":"..."}
# panama-power-profile set <name>
#
# PerformanceDegraded is reported because it is the one thing that makes the
# choice a lie: a thermally throttled laptop reports "performance" while
# behaving otherwise, and GNOME surfaces exactly this. It is an empty string
# when nothing is wrong.
set -uo pipefail
readonly BUS_NAME=net.hadess.PowerProfiles
readonly OBJECT=/net/hadess/PowerProfiles
emit_error() {
printf '{"profiles":[],"active":"","degraded":"","error":%s}\n' "$(jq -Rn --arg e "$1" '$e')"
exit 0
}
command -v busctl >/dev/null 2>&1 || emit_error 'busctl is not available'
property() {
busctl get-property "$BUS_NAME" "$OBJECT" "$BUS_NAME" "$1" 2>/dev/null
}
cmd_list() {
# A machine with no power-profiles daemon is a normal state -- plenty of
# desktops have none -- so it is reported rather than treated as a failure.
busctl status "$BUS_NAME" >/dev/null 2>&1 \
|| emit_error 'No power profile service is running. GNOME uses power-profiles-daemon; Fedora ships tuned-ppd.'
local active degraded profiles
active="$(property ActiveProfile | sed 's/^s //; s/"//g')"
degraded="$(property PerformanceDegraded | sed 's/^s //; s/"//g')"
# Profiles is an array of dicts, which busctl renders flat:
# v aa{sv} 3 2 "Profile" s "power-saver" "Driver" s "tuned" 2 "Profile" ...
# so each profile is the string following its own "Profile" marker. Matching
# the marker matters: "Driver" values sit in the same stream, and on this
# machine the driver is called "tuned", which a looser pattern happily
# reports as a fourth profile that does not exist.
profiles="$(property Profiles \
| grep -oE '"Profile" s "[a-z-]+"' \
| sed 's/.*s "//; s/"$//' \
| awk '!seen[$0]++')"
[[ -n "$profiles" ]] || emit_error 'The power profile service reported no profiles.'
jq -cn \
--arg active "$active" \
--arg degraded "$degraded" \
--argjson profiles "$(printf '%s\n' "$profiles" | jq -Rn '[inputs | select(length > 0)]')" \
'{profiles: $profiles, active: $active, degraded: $degraded, error: ""}'
}
cmd_set() {
local profile="${1:-}"
# Constrained rather than passed through: this reaches a system service.
[[ "$profile" =~ ^[a-z-]+$ ]] || {
printf 'panama-power-profile: refusing a profile name with unexpected characters\n' >&2
return 2
}
busctl set-property "$BUS_NAME" "$OBJECT" "$BUS_NAME" ActiveProfile s "$profile" 2>&1 >/dev/null \
| head -2 >&2
return 0
}
case "${1:-list}" in
list) cmd_list ;;
set) shift; cmd_set "${1:-}" ;;
*) printf 'usage: panama-power-profile [list|set <profile>]\n' >&2; exit 2 ;;
esac
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env bash
# Device security facts, as JSON.
#
# Everything here is READ-ONLY and deliberately so. Secure Boot, TPM presence,
# disk encryption, SELinux mode and the firewall are set in firmware, at install
# time, or by system policy -- none of them is a desktop preference, and a
# settings app that offered to toggle them would either fail or do something
# far-reaching from a switch that looks like any other.
#
# What it is for is answering "is this machine set up the way I think it is",
# which is the question GNOME's Device Security panel exists to answer and which
# otherwise needs five commands and root.
#
# Each fact is reported as {value, ok} where `ok` marks the reassuring state, so
# the UI can highlight what deserves attention without hard-coding the meaning
# of each string. Anything that cannot be determined reports "Unknown" with
# ok:false rather than guessing, because a security readout that quietly reports
# "fine" when it failed to look is worse than no readout.
set -uo pipefail
fact() {
jq -cn --arg label "$1" --arg value "$2" --argjson ok "$3" --arg detail "${4:-}" \
'{label: $label, value: $value, ok: $ok, detail: $detail}'
}
facts=()
# ── Secure Boot ──────────────────────────────────────────────────────────────
if command -v mokutil >/dev/null 2>&1; then
case "$(mokutil --sb-state 2>/dev/null)" in
*"SecureBoot enabled"*) facts+=("$(fact "Secure Boot" "Enabled" true "Firmware verifies the bootloader and kernel signatures")" ) ;;
*"SecureBoot disabled"*) facts+=("$(fact "Secure Boot" "Disabled" false "Firmware does not verify what it boots")") ;;
*) facts+=("$(fact "Secure Boot" "Unknown" false "The firmware did not report a Secure Boot state")") ;;
esac
elif [[ -d /sys/firmware/efi ]]; then
facts+=("$(fact "Secure Boot" "Unknown" false "Install mokutil to report this")")
else
facts+=("$(fact "Secure Boot" "Not applicable" false "This machine booted in legacy BIOS mode")")
fi
# ── TPM ──────────────────────────────────────────────────────────────────────
tpm_major="$(cat /sys/class/tpm/tpm0/tpm_version_major 2>/dev/null || true)"
if [[ -n "$tpm_major" ]]; then
facts+=("$(fact "TPM" "Version $tpm_major" true "A trusted platform module is present and usable")")
elif [[ -e /sys/class/tpm/tpm0 ]]; then
facts+=("$(fact "TPM" "Present" true "A trusted platform module is present")")
else
facts+=("$(fact "TPM" "None" false "No trusted platform module, so keys cannot be sealed to this machine")")
fi
# ── Disk encryption ──────────────────────────────────────────────────────────
# Counts LUKS mappings rather than naming them: which volume is encrypted is
# more detail than this readout needs, and device names are not meaningful here.
crypt_count="$(lsblk -o TYPE 2>/dev/null | grep -c '^crypt$' || true)"
[[ "$crypt_count" =~ ^[0-9]+$ ]] || crypt_count=0
if (( crypt_count > 0 )); then
facts+=("$(fact "Disk encryption" "$crypt_count encrypted volume$( (( crypt_count == 1 )) || printf 's')" true "Data at rest is protected by LUKS")")
else
facts+=("$(fact "Disk encryption" "None" false "No LUKS volume is unlocked on this machine")")
fi
# ── SELinux ──────────────────────────────────────────────────────────────────
if command -v getenforce >/dev/null 2>&1; then
case "$(getenforce 2>/dev/null)" in
Enforcing) facts+=("$(fact "SELinux" "Enforcing" true "Policy violations are blocked")") ;;
Permissive) facts+=("$(fact "SELinux" "Permissive" false "Violations are logged but allowed")") ;;
Disabled) facts+=("$(fact "SELinux" "Disabled" false "Mandatory access control is off")") ;;
*) facts+=("$(fact "SELinux" "Unknown" false "")") ;;
esac
fi
# ── Firewall ─────────────────────────────────────────────────────────────────
if systemctl list-unit-files firewalld.service >/dev/null 2>&1; then
if [[ "$(systemctl is-active firewalld 2>/dev/null)" == "active" ]]; then
facts+=("$(fact "Firewall" "Active" true "firewalld is filtering incoming connections")")
else
facts+=("$(fact "Firewall" "Inactive" false "firewalld is installed but not running")")
fi
fi
printf '[%s]\n' "$(IFS=,; printf '%s' "${facts[*]}")"
+149
View File
@@ -0,0 +1,149 @@
#!/usr/bin/env bash
# Propagates the colour scheme to applications that do not read the desktop
# portal.
#
# Most modern applications DO follow org.freedesktop.portal.Settings and need
# nothing from us: GTK4/libadwaita, Qt6, Chromium and Electron all read
# org.freedesktop.appearance color-scheme, which xdg-desktop-portal-gtk serves
# from gsettings. Panama sets that, so those follow automatically.
#
# Terminals are the notable exception -- they predate the standard and carry
# their own palettes. kitty is handled here.
#
# GTK3 is the other one. Under GNOME, gnome-settings-daemon publishes the theme
# over XSETTINGS; under Hyprland nothing does, so ~/.config/gtk-3.0/settings.ini
# is authoritative for GTK3 applications. Pinned to dark, it contradicted the
# scheme in light mode, so it is generated from a template here instead.
#
# panama-theme-apps dark|light
#
# kitty gets it twice: the generated include file so terminals opened later
# start correct, and a live `set-colors` over its control socket so terminals
# already open change now. Without the second, a scheme change appears to do
# nothing until you open a new window.
set -euo pipefail
scheme="${1:-dark}"
case "$scheme" in
dark|light) ;;
*) printf 'usage: panama-theme-apps [dark|light]\n' >&2; exit 2 ;;
esac
# ── tmux ─────────────────────────────────────────────────────────────────────
# Generated like kitty's: tmux.conf sources current-theme.conf, and that file is
# machine state rather than configuration. Running servers are re-sourced so an
# open session changes now instead of at next launch -- tmux applies a
# source-file to every attached client immediately.
tmux_dir="${XDG_CONFIG_HOME:-$HOME/.config}/tmux"
tmux_theme="$tmux_dir/themes/tokyonight-moon.conf"
[[ "$scheme" == "light" ]] && tmux_theme="$tmux_dir/themes/tokyonight-day.conf"
status_tmux="skipped"
if [[ -r "$tmux_theme" ]]; then
if cp "$tmux_theme" "$tmux_dir/current-theme.conf.tmp" 2>/dev/null \
&& mv "$tmux_dir/current-theme.conf.tmp" "$tmux_dir/current-theme.conf" 2>/dev/null; then
status_tmux="written"
# Only if a server is actually running; `tmux source-file` would
# otherwise start one just to theme it.
if command -v tmux >/dev/null 2>&1 && tmux has-session 2>/dev/null; then
tmux source-file "$tmux_dir/current-theme.conf" 2>/dev/null \
&& status_tmux="applied to running sessions"
fi
else
rm -f "$tmux_dir/current-theme.conf.tmp"
status_tmux="failed"
fi
fi
# ── btop ─────────────────────────────────────────────────────────────────────
# Only the color_theme line is rewritten, in place. btop OWNS btop.conf -- it
# rewrites the whole file on exit -- so the config is not symlinked into Panama
# and not replaced wholesale here; just this one value is edited, and btop keeps
# it on the next write.
#
# btop reads its theme once at startup, so a running instance keeps the old
# colours until it is restarted. That is acceptable for a monitor you open when
# you want it, and forcing a restart would kill a process the user is watching.
btop_conf="${XDG_CONFIG_HOME:-$HOME/.config}/btop/btop.conf"
btop_theme="tokyonight-moon"
[[ "$scheme" == "light" ]] && btop_theme="tokyonight-day"
status_btop="skipped"
if [[ -w "$btop_conf" ]]; then
if sed -i "s|^color_theme *=.*|color_theme = \"$btop_theme\"|" "$btop_conf" 2>/dev/null; then
status_btop="written"
else
status_btop="failed"
fi
fi
# ── GTK ──────────────────────────────────────────────────────────────────────
# adw-gtk3, not Adwaita: no Adwaita GTK theme is installed on Fedora 44, and
# naming a theme that does not exist makes GTK fall back to its light default --
# which made dark mode silently produce light windows.
if [[ "$scheme" == "light" ]]; then
gtk_theme="adw-gtk3"
prefer_dark=0
else
gtk_theme="adw-gtk3-dark"
prefer_dark=1
fi
status_gtk="skipped"
for gtk_version in 3.0 4.0; do
gtk_dir="${XDG_CONFIG_HOME:-$HOME/.config}/gtk-$gtk_version"
template="$gtk_dir/settings.ini.template"
[[ -r "$template" ]] || continue
# Written atomically: a GTK application starting mid-write would otherwise
# read a truncated file and fall back to defaults.
if sed -e "s/@GTK_THEME@/$gtk_theme/" -e "s/@PREFER_DARK@/$prefer_dark/" "$template" \
>"$gtk_dir/settings.ini.tmp" 2>/dev/null \
&& mv "$gtk_dir/settings.ini.tmp" "$gtk_dir/settings.ini" 2>/dev/null; then
status_gtk="written"
else
rm -f "$gtk_dir/settings.ini.tmp"
status_gtk="failed"
fi
done
kitty_dir="${XDG_CONFIG_HOME:-$HOME/.config}/kitty"
theme_file="$kitty_dir/themes/tokyonight-moon.conf"
[[ "$scheme" == "light" ]] && theme_file="$kitty_dir/themes/tokyonight-day.conf"
status_kitty="skipped"
if [[ -r "$theme_file" ]]; then
# Written atomically: kitty may read this while a new window is starting.
if cp "$theme_file" "$kitty_dir/current-theme.conf.tmp" 2>/dev/null \
&& mv "$kitty_dir/current-theme.conf.tmp" "$kitty_dir/current-theme.conf" 2>/dev/null; then
status_kitty="written"
fi
# Live-apply to running terminals. kitty appends its PID to the socket name
# from listen_on, so there is one socket per instance -- "unix:@mykitty"
# alone reaches nothing, which is exactly how this looked like remote
# control being disabled when it was not.
if command -v kitty >/dev/null 2>&1 && command -v ss >/dev/null 2>&1; then
applied=0
while read -r socket; do
[[ -n "$socket" ]] || continue
if kitty @ --to "unix:@${socket#@}" set-colors --all --configured "$theme_file" >/dev/null 2>&1; then
applied=$((applied + 1))
fi
done < <(ss -xl 2>/dev/null | grep -oE '@mykitty[^[:space:]]*' | sort -u)
(( applied > 0 )) && status_kitty="applied to $applied"
fi
fi
# Every target reports what actually happened. A helper that says only
# "kitty: applied" while silently skipping three other applications is how a
# half-applied theme goes unnoticed.
jq -cn \
--arg scheme "$scheme" \
--arg kitty "$status_kitty" \
--arg gtk "$status_gtk" \
--arg btop "$status_btop" \
--arg tmux "$status_tmux" \
'{scheme: $scheme, kitty: $kitty, gtk: $gtk, btop: $btop, tmux: $tmux}'
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env bash
# A QR code for a saved Wi-Fi network, so a guest can join by pointing a camera.
#
# GNOME's Wi-Fi panel has this and it is the single most-used thing in it.
# The payload is the de-facto WIFI: URI that Android and iOS both scan:
#
# WIFI:T:WPA;S:<ssid>;P:<passphrase>;H:<hidden>;;
#
# HANDLING THE PASSPHRASE
#
# This image contains the network password in machine-readable form. Anyone who
# can read the file can read the password, so:
#
# * it is written under XDG_RUNTIME_DIR, which is 0700 and on tmpfs, so it
# never reaches disk and disappears at logout -- not /tmp, which is shared;
# * it is created with umask 077;
# * the passphrase is never printed, never passed as an argument (argv is
# world-readable via /proc), and never appears in an error message.
#
# It is piped to qrencode on stdin for that last reason.
#
# Usage:
# panama-wifi-qr list -> {"networks":[{"name","ssid","shareable"}]}
# panama-wifi-qr qr <name> -> {"path":"/run/user/…/….png"}
set -uo pipefail
emit_error() {
printf '{"networks":[],"path":"","error":%s}\n' "$(jq -Rn --arg e "$1" '$e')"
exit 0
}
command -v nmcli >/dev/null 2>&1 || emit_error 'NetworkManager is not available'
command -v qrencode >/dev/null 2>&1 || emit_error 'qrencode is not installed, so a Wi-Fi QR code cannot be drawn'
cmd_list() {
local rows=() name ssid psk
while IFS= read -r name; do
[[ -n "$name" ]] || continue
ssid="$(nmcli -g 802-11-wireless.ssid connection show "$name" 2>/dev/null)"
[[ -n "$ssid" ]] || ssid="$name"
# Only networks whose passphrase this user can actually read are
# shareable. An enterprise network has no passphrase to share at all,
# and a QR code for one would simply not work.
psk="$(nmcli -s -g 802-11-wireless-security.psk connection show "$name" 2>/dev/null)"
rows+=("$(jq -cn --arg name "$name" --arg ssid "$ssid" \
--argjson shareable "$([[ -n "$psk" ]] && echo true || echo false)" \
'{name: $name, ssid: $ssid, shareable: $shareable}')")
done < <(nmcli -t -f NAME,TYPE connection show 2>/dev/null \
| awk -F: '$2 == "802-11-wireless" { print $1 }')
if [[ ${#rows[@]} -eq 0 ]]; then
printf '{"networks":[],"path":"","error":"No saved Wi-Fi networks."}\n'
return 0
fi
printf '{"networks":[%s],"path":"","error":""}\n' "$(IFS=,; printf '%s' "${rows[*]}")"
}
# The WIFI: URI reserves \ ; , : and ", each escaped with a backslash. An SSID
# containing a semicolon would otherwise terminate the field early and produce a
# QR code for a different network entirely.
#
# Trailing newlines are stripped as well. nmcli terminates every value with one,
# and left in place it lands INSIDE the payload -- the code still decodes here,
# but a newline in the middle of a WIFI: URI is not something every phone's
# scanner tolerates, and the failure would look like "the QR code just does not
# work on my phone".
escape_field() {
sed -e 's/\\/\\\\/g' -e 's/;/\\;/g' -e 's/,/\\,/g' -e 's/:/\\:/g' -e 's/"/\\"/g' \
| tr -d '\n'
}
cmd_qr() {
local name="${1:-}"
[[ -n "$name" ]] || emit_error 'no network named'
local ssid hidden psk_file payload_file out_dir out_file
ssid="$(nmcli -g 802-11-wireless.ssid connection show "$name" 2>/dev/null)"
[[ -n "$ssid" ]] || emit_error "There is no saved network called \"$name\"."
hidden="$(nmcli -g 802-11-wireless.hidden connection show "$name" 2>/dev/null)"
[[ "$hidden" == "yes" ]] && hidden=true || hidden=false
out_dir="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/panama"
umask 077
mkdir -p "$out_dir" 2>/dev/null || emit_error 'could not create the runtime directory'
chmod 700 "$out_dir" 2>/dev/null || true
# Named after the connection, hashed, so repeated shares reuse one file
# instead of accumulating images of the password.
out_file="$out_dir/wifi-$(printf '%s' "$name" | sha256sum | cut -c1-16).png"
# Built in a file rather than a variable that could be echoed, and piped to
# qrencode on stdin so the passphrase never appears in argv.
payload_file="$(mktemp "$out_dir/payload.XXXXXX")" || emit_error 'could not create a temporary file'
trap 'rm -f "$payload_file"' RETURN
{
printf 'WIFI:T:WPA;S:'
printf '%s' "$ssid" | escape_field
printf ';P:'
nmcli -s -g 802-11-wireless-security.psk connection show "$name" 2>/dev/null | escape_field
printf ';H:%s;;' "$hidden"
} >"$payload_file"
if ! qrencode -o "$out_file" -s 8 -m 2 -l M <"$payload_file" 2>/dev/null; then
emit_error "Could not generate a QR code for \"$name\"."
fi
chmod 600 "$out_file" 2>/dev/null || true
jq -cn --arg path "$out_file" '{networks: [], path: $path, error: ""}'
}
case "${1:-list}" in
list) cmd_list ;;
qr) shift; cmd_qr "${1:-}" ;;
*) printf 'usage: panama-wifi-qr [list|qr <name>]\n' >&2; exit 2 ;;
esac
@@ -0,0 +1,163 @@
pragma Singleton
// Panel brightness for external monitors, over DDC/CI.
//
// brightnessctl covers laptop panels through the kernel's backlight class. A
// desktop driving a DisplayPort monitor has no such device, so it has no
// brightness control at all -- the only way to dim the screen is the buttons on
// the bezel. DDC/CI is the channel those buttons drive, and monitors expose it
// over the same I2C lines that carry EDID.
//
// Two things shape everything here:
//
// Detection is slow. Probing every I2C bus takes on the order of a second,
// which is far too slow to sit in front of a settings page opening. It runs
// once, on demand, and afterwards each display is addressed by its bus number
// directly.
//
// Writes are slow AND rate-limited by the monitor's firmware. A slider drag
// emits values continuously; sending each one produces a queue the panel
// works through seconds after the user let go, and some monitors drop or
// garble writes that arrive too fast. So `value` updates immediately for the
// UI and the hardware write is debounced, with only the latest value sent.
//
// Displays are keyed by DRM connector name (DP-2) so they line up with what
// Hyprland, the Displays page, and the monitor list already call them.
import Quickshell
import Quickshell.Io
import QtQuick
Singleton {
id: root
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-brightness"
// [{ bus, connector, model, value }] where value is 0..100.
property var displays: []
property bool scanning: false
// Empty when everything is fine. Carries the helper's explanation
// otherwise -- most usefully the udev command that grants I2C access,
// which is the difference between "brightness is unavailable" and
// "brightness is one command away".
property string lastError: ""
readonly property bool available: root.displays.length > 0
// True once a scan has completed, however it went. Lets the UI tell "not
// looked yet" apart from "looked and found nothing", which otherwise render
// identically and leave a permanently empty panel with no explanation.
property bool scanned: false
// Pending writes, keyed by bus. A monitor being dragged accumulates exactly
// one entry no matter how many values the slider emits.
property var pending: ({})
function refresh(): void {
if (root.scanning)
return;
root.scanning = true;
scan.running = true;
}
function displayFor(connector: string): var {
return root.displays.find(display => display.connector === connector) ?? null;
}
// Sets brightness for one display. The stored value moves at once so the
// slider tracks the pointer; the hardware follows when the drag settles.
function set(bus: int, percent: int): void {
const clamped = Math.max(0, Math.min(100, Math.round(percent)));
root.displays = root.displays.map(display =>
display.bus === bus ? Object.assign({}, display, { value: clamped }) : display);
const next = Object.assign({}, root.pending);
next[String(bus)] = clamped;
root.pending = next;
writeDebounce.restart();
}
Process {
id: scan
command: [root.helperPath, "list"]
stdout: StdioCollector {
onStreamFinished: {
try {
const parsed = JSON.parse(this.text);
root.displays = Array.isArray(parsed.displays) ? parsed.displays : [];
root.lastError = String(parsed.error ?? "");
} catch (error) {
root.displays = [];
root.lastError = "Could not read the brightness helper's output.";
console.warn("Brightness: could not parse helper output:", error);
}
root.scanning = false;
root.scanned = true;
}
}
}
// Long enough that a drag produces one write rather than dozens, short
// enough that a single click still feels immediate.
Timer {
id: writeDebounce
interval: 120
onTriggered: root.pump()
}
// Writes run one at a time, and each is read back.
//
// Serial because DDC/CI is a bus protocol with no arbitration: two ddcutil
// processes talking to the same monitor interleave their exchanges and both
// can come back with garbage. Read back because a write is not a promise --
// panels clamp to their own range, ignore values while waking from standby,
// and drop writes that arrive too quickly. Without the read the slider shows
// what Panama asked for rather than what the monitor did, which is the same
// class of lie as trusting `hyprctl keyword` to have applied something.
property int writingBus: -1
function pump(): void {
if (writer.running || reader.running)
return;
for (const bus in root.pending) {
const value = root.pending[bus];
const remaining = Object.assign({}, root.pending);
delete remaining[bus];
root.pending = remaining;
root.writingBus = parseInt(bus);
writer.command = [root.helperPath, "set", bus, String(value)];
writer.running = true;
return;
}
}
Process {
id: writer
onExited: {
reader.command = [root.helperPath, "get", String(root.writingBus)];
reader.running = true;
}
}
Process {
id: reader
stdout: StdioCollector {
onStreamFinished: {
const actual = parseInt(this.text.trim());
if (!isNaN(actual)) {
root.displays = root.displays.map(display =>
display.bus === root.writingBus
? Object.assign({}, display, { value: actual })
: display);
}
root.writingBus = -1;
// Anything queued while this write was in flight goes now.
root.pump();
}
}
}
}
+23 -3
View File
@@ -21,6 +21,8 @@ import qs.config
Singleton {
id: root
readonly property string appThemePath: Quickshell.shellDir + "/scripts/panama-theme-apps"
readonly property bool dark: DesktopPreferences.get("colorScheme") !== "light"
property string lastError: ""
@@ -76,9 +78,21 @@ Singleton {
root.lastError = "";
const scheme = root.dark ? "prefer-dark" : "prefer-light";
// Adwaita's light and dark are the same theme; only the preference and
// the -dark suffix differ, so applications that honour either agree.
const gtkTheme = root.dark ? "Adwaita-dark" : "Adwaita";
// adw-gtk3, not Adwaita. This is the bug that made dark mode look
// broken while light mode looked fine:
//
// Neither "Adwaita" nor "Adwaita-dark" is an installed theme on Fedora
// 44 -- only adw-gtk3 and adw-gtk3-dark are. Naming a theme that does
// not exist makes GTK fall back to its built-in default, which is
// LIGHT. So asking for light accidentally worked, asking for dark
// silently produced light, and applications that take their cue from
// the GTK theme rather than the portal -- Chromium and Electron, when
// built against GTK -- stayed light no matter what the portal said.
//
// gtk-theme-contract asserts these names are actually installed,
// because the failure mode is silent in exactly this way.
const gtkTheme = root.dark ? "adw-gtk3-dark" : "adw-gtk3";
const commands = [
["gsettings", "set", "org.gnome.desktop.interface", "color-scheme", scheme],
@@ -92,6 +106,12 @@ Singleton {
commands.push(["hyprctl", "eval",
`hl.config({ general = { col = { inactive_border = "${inactive}" } } })`]);
// Applications that predate org.freedesktop.appearance and carry their
// own palettes -- terminals, chiefly. Everything that reads the portal
// (GTK4, Qt6, Chromium, Electron) is already handled by the gsettings
// write above and needs nothing here.
commands.push([root.appThemePath, root.dark ? "dark" : "light"]);
root.enqueue(commands);
}
}
@@ -0,0 +1,54 @@
pragma Singleton
// Read-only device security facts: Secure Boot, TPM, disk encryption, SELinux,
// firewall.
//
// Nothing here is a preference. These are set in firmware, at install time, or
// by system policy, and a settings app that offered to change them from a
// switch would either fail or do something far-reaching from a control that
// looks like every other control. What this answers is "is this machine set up
// the way I think it is", which otherwise takes five commands and root.
//
// Read on demand. None of these can change while the desktop is running,
// short of a reboot.
import Quickshell
import Quickshell.Io
import QtQuick
Singleton {
id: root
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-security"
// [{ label, value, ok, detail }]
property var facts: []
property bool scanned: false
// The facts that are not in their reassuring state. The page leads with the
// count so a machine that is entirely fine says so in one line instead of
// making the user read five rows to find out.
readonly property int attentionCount: root.facts.filter(fact => !fact.ok).length
function refresh(): void {
if (!query.running)
query.running = true;
}
Process {
id: query
command: [root.helperPath]
stdout: StdioCollector {
onStreamFinished: {
try {
const parsed = JSON.parse(this.text);
root.facts = Array.isArray(parsed) ? parsed : [];
} catch (error) {
root.facts = [];
console.warn("DeviceSecurity: could not parse helper output:", error);
}
root.scanned = true;
}
}
}
}
@@ -0,0 +1,66 @@
pragma Singleton
// What input hardware this machine actually has.
//
// Exists so pages can hide controls for hardware that is not present. A
// touchpad card on a desktop is not merely useless -- it is misleading, because
// every switch on it appears to work: the preference is stored and Hyprland
// accepts the option for a device class it has no member of. The user is left
// toggling settings that will never affect anything, with nothing to say so.
//
// Touchpads are identified by name. libinput exposes them through the same
// "mice" list as everything else that reports pointer motion, and Hyprland
// passes the device name through, so an Elan or Synaptics touchpad arrives as
// something like "elan-touchpad". There is no device-class field to consult.
//
// Read on demand rather than polled. Input devices do come and go -- a mouse is
// unplugged, a receiver is moved -- so this also refreshes when Hyprland says
// the device list changed, which is the only moment the answer can differ.
import Quickshell
import Quickshell.Io
import Quickshell.Hyprland
import QtQuick
Singleton {
id: root
property var mice: []
property var keyboards: []
// True when anything that looks like a touchpad is attached.
readonly property bool hasTouchpad: root.mice.some(name =>
name.includes("touchpad") || name.includes("trackpad"))
readonly property bool hasMouse: root.mice.length > 0
function refresh(): void {
if (!query.running)
query.running = true;
}
Process {
id: query
running: true
command: ["hyprctl", "-j", "devices"]
stdout: StdioCollector {
onStreamFinished: {
try {
const parsed = JSON.parse(this.text);
root.mice = (parsed.mice ?? []).map(device => String(device.name ?? "").toLowerCase());
root.keyboards = (parsed.keyboards ?? []).map(device => String(device.name ?? "").toLowerCase());
} catch (error) {
console.warn("InputDevices: could not parse hyprctl devices:", error);
}
}
}
}
Connections {
target: Hyprland
function onRawEvent(event: var): void {
if (event.name === "device" || event.name === "configreloaded")
root.refresh();
}
}
}
@@ -0,0 +1,83 @@
pragma Singleton
// The login keyring's lock state.
//
// The keyring is unlocked at sign-in by pam_gnome_keyring, exactly as it is
// under GNOME. What a bare Hyprland session lacks is anywhere to see when that
// has stopped being true.
//
// It stops being true rarely but expensively: gnome-keyring-daemon can crash,
// D-Bus activates a replacement, and the replacement never received the login
// password -- so the keyring is locked in the middle of a session that unlocked
// it correctly. Nothing announces this. What the user sees instead is a mail
// account that will not authenticate, a git push that cannot find its key, or
// an integration reporting "not configured", none of which mention keyrings.
//
// Checked on demand and after an unlock, not polled: the state changes only
// when a daemon dies or a password is entered.
import Quickshell
import Quickshell.Io
import QtQuick
Singleton {
id: root
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-keyring"
property bool available: false
property bool locked: false
property bool scanned: false
property bool unlocking: false
property string lastError: ""
// "pam" when the daemon that holds the keyring is the one PAM started at
// login, "dbus" when it is a D-Bus-activated replacement -- which is the
// signature of the crash case, and worth showing, because a dbus daemon
// that is currently unlocked was unlocked by hand and will not survive.
property string daemon: ""
readonly property bool replacementDaemon: root.daemon === "dbus"
function refresh(): void {
if (!query.running)
query.running = true;
}
// Raises the standard password dialog. The password never passes through
// Panama -- the Secret Service prompts, the same way it does under GNOME.
function unlock(): void {
if (root.unlocking)
return;
root.unlocking = true;
unlockProcess.running = true;
}
function absorb(text: string): void {
try {
const parsed = JSON.parse(text);
root.available = parsed.available === true;
root.locked = parsed.locked === true;
root.daemon = String(parsed.daemon ?? "");
root.lastError = String(parsed.error ?? "");
} catch (error) {
root.available = false;
root.lastError = "Could not read the keyring helper's output.";
console.warn("Keyring: could not parse helper output:", error);
}
root.scanned = true;
}
Process {
id: query
command: [root.helperPath, "status"]
stdout: StdioCollector { onStreamFinished: root.absorb(this.text) }
}
Process {
id: unlockProcess
command: [root.helperPath, "unlock"]
stdout: StdioCollector { onStreamFinished: root.absorb(this.text) }
onExited: root.unlocking = false
}
}
@@ -0,0 +1,47 @@
pragma Singleton
// What this machine is: model, processor, memory, disk, OS, kernel.
//
// Read once, on demand. None of it changes while the desktop is running except
// free disk space, and About is not a monitor -- the vitals readout on the Home
// page is where live figures belong.
//
// Graphics is not here. GraphicsDevices already enumerates GPUs for the vitals
// readout, and naming them again would be a second source of truth that could
// disagree with the first; the About page joins the two instead.
import Quickshell
import Quickshell.Io
import QtQuick
Singleton {
id: root
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-about"
// [{ label, value }] in display order.
property var facts: []
property bool scanned: false
function refresh(): void {
if (!query.running)
query.running = true;
}
Process {
id: query
command: [root.helperPath]
stdout: StdioCollector {
onStreamFinished: {
try {
const parsed = JSON.parse(this.text);
root.facts = Array.isArray(parsed) ? parsed : [];
} catch (error) {
root.facts = [];
console.warn("MachineInfo: could not parse helper output:", error);
}
root.scanned = true;
}
}
}
}
@@ -0,0 +1,95 @@
pragma Singleton
// Online accounts, via GNOME Online Accounts.
//
// The daemon already runs in this session -- gvfs activates it, and accounts
// work without gnome-shell anywhere. Only the panel was GNOME's; the accounts
// are D-Bus objects anything may read and modify. So listing, per-service
// toggles, and removal all happen here, natively.
//
// Signing in is the exception, and only for OAuth providers. The daemon's
// AddAccount takes credentials as an argument rather than obtaining them, and
// the code that runs Google's OAuth exchange lives in libgoa-backend, which
// Fedora ships without a GIR binding. So that one step is handed to GNOME's
// panel and the user comes straight back here.
//
// Read on demand and after every change: accounts are added and removed by
// people, not by the system, so there is nothing to poll for.
import Quickshell
import Quickshell.Io
import QtQuick
Singleton {
id: root
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-accounts"
// [{ path, provider, providerName, identity, needsAttention, services: [{key,label,enabled}] }]
property var accounts: []
property bool scanned: false
property bool busy: false
property string lastError: ""
// Accounts whose stored credentials have stopped working -- an expired
// token, a changed password. GOA knows, and nothing outside its own panel
// ever says so, which is how an account quietly stops syncing for weeks.
readonly property int attentionCount: root.accounts.filter(a => a.needsAttention).length
readonly property bool available: root.lastError === ""
function refresh(): void {
if (!list.running)
list.running = true;
}
// Enabling a service clears GOA's "disabled" flag; the helper owns that
// inversion so the UI can speak in terms of what is on.
function setService(path: string, service: string, enabled: bool): void {
if (root.busy)
return;
root.busy = true;
write.command = [root.helperPath, "set", path, service, enabled ? "true" : "false"];
write.running = true;
}
function remove(path: string): void {
if (root.busy)
return;
root.busy = true;
write.command = [root.helperPath, "remove", path];
write.running = true;
}
Process {
id: list
command: [root.helperPath, "list"]
stdout: StdioCollector {
onStreamFinished: {
try {
const parsed = JSON.parse(this.text);
root.accounts = Array.isArray(parsed.accounts) ? parsed.accounts : [];
root.lastError = String(parsed.error ?? "");
} catch (error) {
root.accounts = [];
root.lastError = "Could not read the accounts helper's output.";
console.warn("OnlineAccounts: could not parse helper output:", error);
}
root.scanned = true;
}
}
}
Process {
id: write
stderr: StdioCollector {
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
}
// Re-read rather than assuming the write landed: GOA may refuse, and a
// toggle that sprang back is the honest outcome.
onExited: {
root.busy = false;
root.refresh();
}
}
}
@@ -0,0 +1,107 @@
pragma Singleton
// The system power profile: power-saver, balanced, or performance.
//
// The same daemon GNOME's Power panel drives. On Fedora 44 the implementation
// is tuned-ppd rather than power-profiles-daemon, but it serves the same
// net.hadess.PowerProfiles interface -- so this talks to the interface, not to
// either binary. powerprofilesctl is not installed here at all.
//
// Not a stored preference. The profile lives in the daemon, survives Panama
// restarts, and can be changed by anything else on the system; keeping a copy
// in settings.json would mean restoring a value the daemon had moved past.
// Same reasoning as monitor brightness.
//
// Read on demand and after each change. The daemon does emit PropertiesChanged,
// but subscribing to it would mean holding a bus connection open for a value
// that changes only when someone chooses it.
import Quickshell
import Quickshell.Io
import QtQuick
Singleton {
id: root
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-power-profile"
property var profiles: []
property string active: ""
property bool scanned: false
property bool busy: false
property string lastError: ""
// Non-empty when the machine cannot actually deliver the profile it is set
// to -- thermal throttling, or a laptop running on battery. Worth showing,
// because otherwise "performance" is a claim the hardware is not honouring.
property string degraded: ""
readonly property bool available: root.profiles.length > 0
// Presentation lives here rather than in the page so the Control Center and
// Settings cannot disagree about what a profile is called.
function label(profile: string): string {
switch (profile) {
case "power-saver": return "Power Saver";
case "balanced": return "Balanced";
case "performance": return "Performance";
default: return profile;
}
}
function detail(profile: string): string {
switch (profile) {
case "power-saver": return "Reduces performance to save energy and run quieter";
case "balanced": return "Standard behaviour, scaling up only when needed";
case "performance": return "Holds higher clocks, using more power and making more noise";
default: return "";
}
}
function refresh(): void {
if (!query.running)
query.running = true;
}
function set(profile: string): void {
if (root.busy || profile === root.active)
return;
root.busy = true;
apply.command = [root.helperPath, "set", profile];
apply.running = true;
}
Process {
id: query
command: [root.helperPath, "list"]
stdout: StdioCollector {
onStreamFinished: {
try {
const parsed = JSON.parse(this.text);
root.profiles = Array.isArray(parsed.profiles) ? parsed.profiles : [];
root.active = String(parsed.active ?? "");
root.degraded = String(parsed.degraded ?? "");
root.lastError = String(parsed.error ?? "");
} catch (error) {
root.profiles = [];
root.lastError = "Could not read the power profile helper's output.";
console.warn("PowerProfiles: could not parse helper output:", error);
}
root.scanned = true;
}
}
}
Process {
id: apply
stderr: StdioCollector {
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
}
// Re-read rather than assuming: the daemon may refuse, or may land on a
// different profile than the one asked for.
onExited: {
root.busy = false;
root.refresh();
}
}
}
@@ -33,6 +33,9 @@ Singleton {
"idle": "power",
"accessibility": "accessibility",
"input": "shortcuts",
"pointer": "mouse",
"touchpad": "mouse",
"multitasking": "desktop",
"weather": "appearance",
"notifications": "notifications",
"capture": "screen-intelligence"
@@ -92,7 +92,7 @@ Singleton {
}
function openSettings(page: string): void {
const allowed = ["home", "appearance", "displays", "connectivity", "home-phone", "desktop", "sound", "notifications", "screen-intelligence", "shortcuts", "accessibility", "power", "datetime", "applications", "services", "about"];
const allowed = ["home", "appearance", "displays", "connectivity", "home-phone", "desktop", "sound", "notifications", "screen-intelligence", "shortcuts", "mouse", "privacy", "region", "accounts", "accessibility", "power", "datetime", "applications", "services", "about"];
root.settingsPage = allowed.indexOf(page) >= 0 ? page : "home";
DesktopPreferences.set("lastPage", root.settingsPage);
root.settingsOpen = true;
@@ -0,0 +1,99 @@
pragma Singleton
// The system locale.
//
// Named SystemLocale, not Locale: QML has a built-in Locale value type, and a
// singleton of that name is silently shadowed by it. Every binding then reads
// properties off the wrong thing and the page renders empty with only
// "Cannot read property of undefined" to show for it.
//
// Changing it is privileged: localectl goes through polkit, which prompts
// (hyprpolkitagent serves that in this session). It also only applies to
// programs started afterwards, so `pendingRestart` goes true once a change is
// accepted and the page says a sign-out is needed. Reporting the new locale as
// simply "in effect" would be wrong -- almost nothing on screen would be using
// it yet.
import Quickshell
import Quickshell.Io
import QtQuick
Singleton {
id: root
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-locale"
// [{ value, label, detail }]
property var locales: []
property string current: ""
property bool scanning: false
property string lastError: ""
// True once a change has been accepted but the session has not restarted,
// so the UI can stop claiming the new locale is already in use.
property bool pendingRestart: false
readonly property string currentLabel: {
const match = root.locales.find(locale => locale.value === root.current);
return match ? match.label : root.current;
}
function refresh(): void {
if (root.scanning)
return;
root.scanning = true;
readCurrent.running = true;
list.running = true;
}
function set(value: string): void {
if (value === root.current)
return;
apply.command = [root.helperPath, "set", value];
apply.pendingValue = value;
apply.running = true;
}
Process {
id: list
command: [root.helperPath, "list"]
stdout: StdioCollector {
onStreamFinished: {
try {
const parsed = JSON.parse(this.text);
root.locales = Array.isArray(parsed) ? parsed : [];
} catch (error) {
root.locales = [];
console.warn("SystemLocale: could not parse the locale list:", error);
}
root.scanning = false;
}
}
}
Process {
id: readCurrent
command: [root.helperPath, "get"]
stdout: StdioCollector {
onStreamFinished: root.current = this.text.trim()
}
}
Process {
id: apply
property string pendingValue: ""
// A refused change -- polkit dismissed, or an unknown locale -- must not
// move the UI. The value is only adopted on a zero exit.
onExited: code => {
if (code === 0) {
root.current = apply.pendingValue;
root.pendingRestart = true;
root.lastError = "";
} else {
root.lastError = "The system did not accept that language. It may have needed a password.";
}
}
}
}
@@ -497,18 +497,35 @@ Singleton {
].indexOf(panel) >= 0;
}
function openGnomePanel(panel: string): bool {
// `subpage` reaches the panels GNOME 50 nests under System -- users,
// about, datetime, region -- which its own desktop entries open as
// `gnome-control-center system users`. Without it, a row labelled "Users"
// lands on System's front page and leaves the user to navigate, which is
// most of the way to a broken button.
function openGnomePanel(panel: string, subpage: string): bool {
if (!root.isGnomePanelAllowed(panel)) {
root.lastError = "That GNOME Settings panel is not available.";
return false;
}
const command = ["gnome-control-center", panel];
if (subpage !== undefined && subpage !== "" && root.isGnomeSubpageAllowed(panel, subpage))
command.push(subpage);
Quickshell.execDetached({
command: ["gnome-control-center", panel],
command: command,
environment: { "XDG_CURRENT_DESKTOP": "GNOME" }
});
return true;
}
// Only System nests panels, and only these. Read off the Exec lines of the
// gnome-*-panel desktop entries rather than guessed, for the same reason
// the panel list above was.
function isGnomeSubpageAllowed(panel: string, subpage: string): bool {
if (panel !== "system")
return false;
return ["users", "about", "datetime", "region", "remote-desktop"].indexOf(subpage) >= 0;
}
function openApplication(id: string): bool {
const commands = {
"nextcloud": ["nextcloud"],
@@ -0,0 +1,99 @@
pragma Singleton
// A QR code for a saved Wi-Fi network, so a guest can join by pointing a phone
// at the screen. GNOME's Wi-Fi panel has this and it is the most-used thing in
// it; reading a passphrase aloud is the alternative.
//
// The generated image contains the network password in machine-readable form,
// so the helper writes it under XDG_RUNTIME_DIR -- 0700, on tmpfs, gone at
// logout -- rather than anywhere persistent. Nothing here ever holds the
// passphrase itself; this service only ever sees a file path.
//
// Generated on demand. Producing a QR for every saved network up front would
// mean writing images of passwords nobody asked to see.
import Quickshell
import Quickshell.Io
import QtQuick
Singleton {
id: root
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-wifi-qr"
// [{ name, ssid, shareable }]
property var networks: []
property bool scanned: false
property string lastError: ""
// The network whose code is on screen, and where its image is. Empty when
// nothing is being shared.
property string sharing: ""
property string imagePath: ""
readonly property var shareable: root.networks.filter(n => n.shareable)
function refresh(): void {
if (!list.running)
list.running = true;
}
function share(name: string): void {
if (generate.running)
return;
// Cache-bust: the helper reuses one file per network, so a QML Image
// pointed at the same path would keep showing the previous render.
root.imagePath = "";
root.sharing = name;
generate.command = [root.helperPath, "qr", name];
generate.running = true;
}
function stopSharing(): void {
root.sharing = "";
root.imagePath = "";
}
Process {
id: list
command: [root.helperPath, "list"]
stdout: StdioCollector {
onStreamFinished: {
try {
const parsed = JSON.parse(this.text);
root.networks = Array.isArray(parsed.networks) ? parsed.networks : [];
root.lastError = String(parsed.error ?? "");
} catch (error) {
root.networks = [];
root.lastError = "Could not read the Wi-Fi helper's output.";
console.warn("WifiShare: could not parse helper output:", error);
}
root.scanned = true;
}
}
}
Process {
id: generate
stdout: StdioCollector {
onStreamFinished: {
try {
const parsed = JSON.parse(this.text);
const path = String(parsed.path ?? "");
const error = String(parsed.error ?? "");
if (error !== "" || path === "") {
root.lastError = error !== "" ? error : "No QR code was produced.";
root.sharing = "";
return;
}
root.lastError = "";
root.imagePath = path;
} catch (error) {
root.lastError = "Could not read the generated QR code's path.";
root.sharing = "";
console.warn("WifiShare: could not parse helper output:", error);
}
}
}
}
}
@@ -0,0 +1,30 @@
# Tokyo Night Day for tmux -- the light counterpart to tokyonight-moon.conf.
#
# Same structure, same roles, Day's palette. Mapped role for role rather than by
# hue: #1b1d2b was text drawn ON an accent block, so it becomes the LIGHT ground
# rather than a dark colour, or the status bar would be dark text on a dark
# accent in light mode.
#
# The accents are Day's DARKER variants, not its standard ones. Measured against
# the panel, the standard #2e7de9 and #9854f1 give 2.74:1 and 2.94:1 -- under
# the 3:1 floor for text. These give 4.42:1 and 4.17:1, and 5.01:1 / 4.73:1 for
# the light text drawn on top of them in the inverted blocks.
set -g mode-style "fg=#1c5bb8,bg=#d0d5e3"
set -g message-style "fg=#1c5bb8,bg=#d0d5e3"
set -g message-command-style "fg=#1c5bb8,bg=#d0d5e3"
set -g pane-border-style "fg=#1c5bb8"
set -g pane-active-border-style "fg=#7847bd"
set -g status-style "fg=#7847bd,bg=#d0d5e3"
set -g status-bg "#e1e2e7"
set -g status-left "#[fg=#e1e2e7,bg=#7847bd,bold] #S #[fg=#e1e2e7,bg=#7847bd,nobold,nounderscore,noitalics]"
set -g status-right "#[fg=#e1e2e7,bg=#1c5bb8,nobold,nounderscore,noitalics]#[fg=#7847bd,bg=#d0d5e3] #{prefix_highlight} #[fg=#d0d5e3,bg=#d0d5e3]#[fg=#7847bd,bg=#d0d5e3] %Y/%m/%d %I:%M %p #[fg=#1c5bb8,bg=#d0d5e3,nobold,nounderscore,noitalics]#[fg=#e1e2e7,bg=#7847bd,bold,italics] #h "
setw -g window-status-format "#[fg=#e1e2e7,bg=#d0d5e3,nobold,nounderscore,noitalics]#[fg=#1c5bb8,bg=#d0d5e3] #I #W #F #[fg=#e1e2e7,bg=#d0d5e3,nobold,nounderscore,noitalics]"
setw -g window-status-current-format "#[fg=#e1e2e7,bg=#d0d5e3,nobold,nounderscore,noitalics]#[fg=#7847bd,bg=#d0d5e3,bold] #I #W #F #[fg=#d0d5e3,bg=#d0d5e3,nobold,nounderscore,noitalics]"
setw -g window-status-separator ""
@@ -0,0 +1,25 @@
# Tokyo Night Moon for tmux -- the palette Panama ships.
#
# Extracted from tmux.conf so the two schemes can be swapped. tmux.conf sources
# current-theme.conf, which panama-theme-apps generates from one of these.
#
# Every colour here was already in tmux.conf; this file is that block verbatim.
set -g mode-style "fg=#82aaff,bg=#3b4261"
set -g message-style "fg=#82aaff,bg=#3b4261"
set -g message-command-style "fg=#82aaff,bg=#3b4261"
set -g pane-border-style "fg=#82aaff"
set -g pane-active-border-style "fg=#b172b0"
set -g status-style "fg=#b172b0,bg=#3b4261"
set -g status-bg "#222436"
set -g status-left "#[fg=#1b1d2b,bg=#b172b0,bold] #S #[fg=#1b1d2b,bg=#b172b0,nobold,nounderscore,noitalics]"
set -g status-right "#[fg=#1b1d2b,bg=#82aaff,nobold,nounderscore,noitalics]#[fg=#b172b0,bg=#3b4261] #{prefix_highlight} #[fg=#3b4261,bg=#3b4261]#[fg=#b172b0,bg=#3b4261] %Y/%m/%d %I:%M %p #[fg=#82aaff,bg=#3b4261,nobold,nounderscore,noitalics]#[fg=#1b1d2b,bg=#b172b0,bold,italics] #h "
setw -g window-status-format "#[fg=#1b1d2b,bg=#3b4261,nobold,nounderscore,noitalics]#[fg=#82aaff,bg=#3b4261] #I #W #F #[fg=#1b1d2b,bg=#3b4261,nobold,nounderscore,noitalics]"
setw -g window-status-current-format "#[fg=#1b1d2b,bg=#3b4261,nobold,nounderscore,noitalics]#[fg=#b172b0,bg=#3b4261,bold] #I #W #F #[fg=#3b4261,bg=#3b4261,nobold,nounderscore,noitalics]"
setw -g window-status-separator ""
+4 -18
View File
@@ -19,24 +19,10 @@ bind C-Space send-prefix
# Set status bar
#set -g status-bg pink
# Tokyo Night Moon color palette
set -g mode-style "fg=#82aaff,bg=#3b4261"
set -g message-style "fg=#82aaff,bg=#3b4261"
set -g message-command-style "fg=#82aaff,bg=#3b4261"
set -g pane-border-style "fg=#82aaff"
set -g pane-active-border-style "fg=#b172b0"
set -g status-style "fg=#b172b0,bg=#3b4261"
set -g status-bg "#222436"
set -g status-left "#[fg=#1b1d2b,bg=#b172b0,bold] #S #[fg=#1b1d2b,bg=#b172b0,nobold,nounderscore,noitalics]"
set -g status-right "#[fg=#1b1d2b,bg=#82aaff,nobold,nounderscore,noitalics]#[fg=#b172b0,bg=#3b4261] #{prefix_highlight} #[fg=#3b4261,bg=#3b4261]#[fg=#b172b0,bg=#3b4261] %Y/%m/%d %I:%M %p #[fg=#82aaff,bg=#3b4261,nobold,nounderscore,noitalics]#[fg=#1b1d2b,bg=#b172b0,bold,italics] #h "
setw -g window-status-format "#[fg=#1b1d2b,bg=#3b4261,nobold,nounderscore,noitalics]#[fg=#82aaff,bg=#3b4261] #I #W #F #[fg=#1b1d2b,bg=#3b4261,nobold,nounderscore,noitalics]"
setw -g window-status-current-format "#[fg=#1b1d2b,bg=#3b4261,nobold,nounderscore,noitalics]#[fg=#b172b0,bg=#3b4261,bold] #I #W #F #[fg=#3b4261,bg=#3b4261,nobold,nounderscore,noitalics]"
setw -g window-status-separator ""
# Colours live in themes/ and are generated into current-theme.conf by
# panama-theme-apps, which regenerates it whenever the desktop colour scheme
# changes. -q so a fresh checkout without the generated file still starts.
source-file -q "~/.config/tmux/current-theme.conf"
# Increase scrollback buffer
set -g history-limit 50000
@@ -0,0 +1,87 @@
# Tokyo Night Day — the light counterpart to tokyonight-moon.toml.
#
# Same schema and the same role for every colour; only the palette differs, so
# the launcher keeps its identity when the desktop switches to light rather
# than becoming a different-looking application.
#
# Colours are the Tokyo Night Day palette, taken from kitty/themes/tokyonight-day.conf
# so the launcher and the terminal cannot drift apart.
[meta]
version = 1
name = "Tokyo Night Day"
description = "Tokyo Night Day, matched to the Panama Hyprland desktop."
variant = "light"
[colors.core]
background = "#e1e2e7" # bg
foreground = "#3760bf" # fg
secondary_background = "#d0d5e3" # bg_dark, a shade below the ground
border = "#a8aecb" # a visible hairline on a light ground
accent = "#2e7de9" # blue
accent_foreground = "#e1e2e7"
[colors.accents]
blue = "#2e7de9"
green = "#587539"
magenta = "#9854f1"
orange = "#b15c00"
purple = "#7847bd"
red = "#f52a65"
yellow = "#8c6c3e"
cyan = "#007197"
[colors.main_window]
border = "#a8aecb"
footer = { background = "colors.core.secondary_background" }
[colors.settings_window]
border = "#c4c8da"
[colors.shortcut]
border = "colors.core.border"
[colors.text]
default = "colors.core.foreground"
muted = "#6172b0" # fg_dark, 4.0:1 on the background
danger = "#f52a65"
success = "#587539"
placeholder = "#7079a8" # 3.25:1; Day's own #848cb5 is 2.54:1, too faint to read
selection = { background = "#2e7de9", foreground = "#e1e2e7" }
[colors.text.links]
default = "#2e7de9"
visited = "#9854f1"
[colors.input]
border = "#c4c8da"
border_focus = "#2e7de9"
border_error = "#f52a65"
[colors.button.primary]
background = "#d4d6e4" # bg_highlight
foreground = "#3760bf"
hover = { background = "#c4c8da" }
focus = { outline = "colors.core.accent" }
[colors.list.item.hover]
foreground = "#3760bf"
secondary_foreground = "#6172b0"
[colors.list.item.selection]
background = "#c3cdf0" # blue side of the Prism selection, lightened
foreground = "#3760bf"
secondary_background = "#d8c8e4" # orchid side of the Prism selection, lightened
secondary_foreground = "#3760bf"
[colors.grid.item]
background = "#d7d9e5"
hover = { outline = "#2e7de9" }
selection = { outline = "#9854f1" }
[colors.scrollbars]
background = "#c4c8da"
[colors.loading]
bar = "#2e7de9"
spinner = "#3760bf"
+6 -4
View File
@@ -6,12 +6,14 @@
// and emoji-copy extensions, so its clipboard and emoji views are bound
// directly in hypr/keybinds.lua.
// Vicinae 0.26 selects a theme per system appearance. Both point to Moon so
// the launcher never flashes or falls back to its stock palette while the
// desktop appearance is being initialized.
// Vicinae 0.26 selects a theme per system appearance, and now there is a
// theme for each: the launcher follows Panama's light/dark setting instead of
// staying dark on a light desktop. Both are authored here rather than using
// Vicinae's bundled Tokyo Night, which ships Night and Storm but not the Moon
// and Day variants the rest of this desktop uses.
"theme": {
"light": {
"name": "tokyonight-moon",
"name": "tokyonight-day",
"icon_theme": "auto"
},
"dark": {
@@ -1,11 +1,11 @@
[Desktop Entry]
Type=Application
Name=Panama Settings
Name=Settings
GenericName=System Settings
Comment=Configure the Panama Hyprland desktop
Comment=Configure this desktop
Exec=qs ipc call settings open
Icon=panama-settings
Terminal=false
StartupNotify=false
Categories=Settings;
Keywords=Panama;Hyprland;Display;Dock;Notifications;Shortcuts;Services;
Keywords=Settings;Preferences;Configuration;Display;Sound;Network;Keyboard;Mouse;Privacy;Power;Shortcuts;
+105 -10
View File
@@ -70,23 +70,118 @@ for dir in "${dirs[@]}"; do
log "Linked $PANAMA_DOT/$dir → $CONFIG/$dir"
done
# tmux.conf ends with a source-file of current-theme.conf, generated from the
# colour scheme rather than committed. Seed it so a fresh checkout starts themed
# -- the source-file is -q, so a missing file is silent, which would leave tmux
# unstyled with nothing to explain it.
TMUX_THEME="$PANAMA_DOT/tmux/current-theme.conf"
if [ -e "$TMUX_THEME" ]; then
log "Keeping existing tmux theme at $TMUX_THEME"
elif [ -d "$PANAMA_DOT/tmux/themes" ]; then
tmux_scheme="dark"
tmux_prefs="${XDG_CONFIG_HOME:-$HOME/.config}/panama/settings.json"
if [ -r "$tmux_prefs" ]; then
tmux_stored="$(jq -r '.colorScheme // "dark"' "$tmux_prefs" 2>/dev/null || echo dark)"
[ "$tmux_stored" = "light" ] && tmux_scheme="light"
fi
[ "$tmux_scheme" = "light" ] && tmux_name="tokyonight-day" || tmux_name="tokyonight-moon"
cp "$PANAMA_DOT/tmux/themes/$tmux_name.conf" "$TMUX_THEME"
log "Seeded tmux $tmux_scheme theme ($tmux_name) → $TMUX_THEME"
fi
# btop reads themes from its own config directory, but OWNS btop.conf -- it
# rewrites that file on exit -- so only the theme files are exposed, per file,
# and the config itself is left to btop. panama-theme-apps edits the single
# color_theme line in place.
BTOP_THEME_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/btop/themes"
mkdir -p "$BTOP_THEME_DIR"
for btop_theme_src in "$PANAMA_DOT"/btop/themes/*.theme; do
[ -e "$btop_theme_src" ] || continue
btop_theme_dst="$BTOP_THEME_DIR/$(basename "$btop_theme_src")"
if [ -L "$btop_theme_dst" ]; then
rm "$btop_theme_dst"
fi
if [ -e "$btop_theme_dst" ]; then
log "Keeping existing btop theme at $btop_theme_dst"
else
ln -s "$btop_theme_src" "$btop_theme_dst"
log "Linked btop theme → $btop_theme_dst"
fi
done
# GTK3 has no include mechanism, so its settings.ini is generated whole from a
# template rather than layered. Without this, a fresh checkout has a template
# and no settings.ini, and GTK3 applications fall back to their built-in theme.
# panama-theme-apps rewrites both files on every scheme change after this.
for gtk_version in 3.0 4.0; do
gtk_template="$PANAMA_DOT/gtk-$gtk_version/settings.ini.template"
gtk_settings="$PANAMA_DOT/gtk-$gtk_version/settings.ini"
[ -r "$gtk_template" ] || continue
if [ -e "$gtk_settings" ]; then
log "Keeping existing GTK settings at $gtk_settings"
else
gtk_scheme="dark"
gtk_prefs="${XDG_CONFIG_HOME:-$HOME/.config}/panama/settings.json"
if [ -r "$gtk_prefs" ]; then
gtk_stored="$(jq -r '.colorScheme // "dark"' "$gtk_prefs" 2>/dev/null || echo dark)"
[ "$gtk_stored" = "light" ] && gtk_scheme="light"
fi
if [ "$gtk_scheme" = "light" ]; then
gtk_name="adw-gtk3"; gtk_dark=0
else
gtk_name="adw-gtk3-dark"; gtk_dark=1
fi
sed -e "s/@GTK_THEME@/$gtk_name/" -e "s/@PREFER_DARK@/$gtk_dark/" \
"$gtk_template" > "$gtk_settings"
log "Generated GTK $gtk_version settings ($gtk_name) → $gtk_settings"
fi
done
# kitty.conf ends with `include current-theme.conf`, and that file is generated
# from the desktop colour scheme rather than committed -- it is machine state.
# A fresh checkout therefore has no such file, and kitty starts by complaining
# about a missing include and falling back to its stock colours. Seed it from
# the scheme in settings.json (dark unless the user has chosen otherwise) so a
# first launch is themed; ColorScheme.qml overwrites it on every change after.
KITTY_THEME="$PANAMA_DOT/kitty/current-theme.conf"
if [ -e "$KITTY_THEME" ]; then
log "Keeping existing kitty theme at $KITTY_THEME"
else
scheme="dark"
settings="${XDG_CONFIG_HOME:-$HOME/.config}/panama/settings.json"
if [ -r "$settings" ]; then
stored="$(jq -r '.colorScheme // "dark"' "$settings" 2>/dev/null || echo dark)"
[ "$stored" = "light" ] && scheme="light"
fi
[ "$scheme" = "light" ] && theme="tokyonight-day" || theme="tokyonight-moon"
cp "$PANAMA_DOT/kitty/themes/$theme.conf" "$KITTY_THEME"
log "Seeded kitty $scheme theme ($theme) → $KITTY_THEME"
fi
# Vicinae 0.26 discovers user themes from its XDG data directory rather than
# from ~/.config/vicinae. Keep the authored theme in Panama with the rest of
# the launcher config and expose only that file at Vicinae's runtime path.
VICINAE_THEME_DIR="$HOME/.local/share/vicinae/themes"
VICINAE_THEME="$VICINAE_THEME_DIR/tokyonight-moon.toml"
mkdir -p "$VICINAE_THEME_DIR"
if [ -L "$VICINAE_THEME" ]; then
rm "$VICINAE_THEME"
fi
# Every authored theme, not just the dark one: vicinae.json selects a theme per
# system appearance, so linking only Moon left the launcher falling back to its
# stock palette whenever Panama was in light mode.
for theme_src in "$PANAMA_DOT"/vicinae/themes/*.toml; do
[ -e "$theme_src" ] || continue
theme_dst="$VICINAE_THEME_DIR/$(basename "$theme_src")"
if [ -e "$VICINAE_THEME" ]; then
log "Keeping existing Vicinae theme at $VICINAE_THEME"
else
ln -s "$PANAMA_DOT/vicinae/themes/tokyonight-moon.toml" "$VICINAE_THEME"
log "Linked Tokyo Night Moon theme → $VICINAE_THEME"
fi
if [ -L "$theme_dst" ]; then
rm "$theme_dst"
fi
if [ -e "$theme_dst" ]; then
log "Keeping existing Vicinae theme at $theme_dst"
else
ln -s "$theme_src" "$theme_dst"
log "Linked Vicinae theme → $theme_dst"
fi
done
# Panama-native applications live in the user data directory so launchers can
# discover them alongside system desktop entries. Keep each authored file in
+163
View File
@@ -0,0 +1,163 @@
#!/usr/bin/env bash
# panama-brightness enumerates monitors from sysfs and speaks DDC/CI to them.
#
# The parts worth pinning down are the ones that decide whether a slider appears
# at all, and whether it appears attached to the right screen:
#
# * only connectors with something plugged in are probed, because probing an
# empty bus costs a timeout each and there are fourteen of them here;
# * a panel that cannot report brightness is omitted rather than shown as a
# control that does nothing;
# * the connector name matches what Hyprland calls the output, since the UI
# joins on it to get the monitor's description;
# * no I2C access produces the udev command that fixes it, not "no displays".
#
# Runs entirely against fixtures. Real monitors are never touched: both the
# sysfs root and the device root are redirected, and ddcutil is replaced on PATH.
set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
helper="$repo_dir/config/dot/quickshell/scripts/panama-brightness"
fail() {
printf 'brightness helper contract: %s\n' "$1" >&2
exit 1
}
fixture="$(mktemp -d /tmp/panama-brightness.XXXXXX)"
trap 'rm -rf "$fixture"' EXIT
mkdir -p "$fixture/drm" "$fixture/dev" "$fixture/bin" "$fixture/i2c"
# Two connectors with a monitor, two without. DP-2 answers DDC; DP-3 is
# connected but does not implement brightness. HDMI-A-1 and DP-1 are empty and
# must never be probed at all.
# A connector has an EDID bus (the `ddc` symlink) and, on DisplayPort, an AUX
# bus that appears as a child directory. DDC/CI rides the AUX channel where one
# exists, and the `ddc` line answers nothing on a DP connector even though it
# still resolves -- so the aux argument here is what a real DisplayPort monitor
# looks like, and omitting it is what HDMI and DVI look like.
make_connector() {
local name="$1" ddc_bus="$2" status="$3" aux_bus="${4:-}"
local device="$fixture/devices/$name"
# /sys/class/drm/<connector> is a SYMLINK to the real device directory, and
# this fixture mirrors that rather than using a plain directory. It matters:
# `find` does not follow the path it is given, so code that searches the
# unresolved path finds nothing while appearing to work anywhere the entry
# happens to be a real directory.
mkdir -p "$device"
printf '%s\n' "$status" >"$device/status"
mkdir -p "$fixture/i2c/i2c-$ddc_bus"
ln -sfn "$fixture/i2c/i2c-$ddc_bus" "$device/ddc"
[[ -n "$aux_bus" ]] && mkdir -p "$device/i2c-$aux_bus"
mkdir -p "$fixture/drm"
ln -sfn "$device" "$fixture/drm/$name"
return 0
}
# DP-2 is the DisplayPort case: its EDID line is bus 5, which answers nothing,
# and its AUX child is bus 9, which does. Choosing bus 5 here finds no monitor
# at all, which is exactly the bug this pins down.
make_connector card1-DP-1 4 disconnected
make_connector card1-DP-2 5 connected 9
make_connector card1-DP-3 6 connected
make_connector card1-HDMI-A-1 7 disconnected
# has_accessible_bus only needs one readable/writable node to exist.
touch "$fixture/dev/i2c-5"
# Stub ddcutil. Records every bus it is asked about so the test can prove the
# disconnected ones were skipped. Bus 6 refuses, standing in for a panel without
# VCP 0x10.
#
# Bus 5 reports its brightness out of 200 rather than 100. Most panels do use
# 100, which is exactly the problem: with a maximum of 100 the scaling
# arithmetic is the identity, so a helper that ignored the reported maximum
# entirely would pass every assertion. 200 makes reads and writes that skip the
# conversion visibly wrong.
cat >"$fixture/bin/ddcutil" <<'STUB'
#!/usr/bin/env bash
bus=""
args=("$@")
for ((i = 0; i < ${#args[@]}; i++)); do
[[ "${args[$i]}" == "--bus" ]] && bus="${args[$((i + 1))]}"
done
printf '%s\n' "$bus" >>"$DDCUTIL_PROBE_LOG"
for arg in "$@"; do
if [[ "$arg" == "setvcp" ]]; then
printf 'set %s %s\n' "$bus" "${args[-1]}" >>"$DDCUTIL_SET_LOG"
exit 0
fi
done
case "$bus" in
9) printf 'VCP 10 C 120 200\n'; exit 0 ;;
*) exit 1 ;;
esac
STUB
chmod +x "$fixture/bin/ddcutil"
export DDCUTIL_PROBE_LOG="$fixture/probes.log"
export DDCUTIL_SET_LOG="$fixture/sets.log"
: >"$DDCUTIL_PROBE_LOG"
: >"$DDCUTIL_SET_LOG"
run_helper() {
PATH="$fixture/bin:$PATH" \
PANAMA_BRIGHTNESS_DRM_ROOT="$fixture/drm" \
PANAMA_BRIGHTNESS_DEV_ROOT="$fixture/dev" \
"$helper" "$@"
}
# ── Enumeration ──────────────────────────────────────────────────────────────
listing="$(run_helper list)"
jq -e . >/dev/null 2>&1 <<<"$listing" || fail "list did not emit JSON: $listing"
[[ "$(jq -r '.displays | length' <<<"$listing")" == "1" ]] \
|| fail "expected exactly one controllable display, got: $listing"
[[ "$(jq -r '.displays[0].connector' <<<"$listing")" == "DP-2" ]] \
|| fail "the connector name must match Hyprland's output name: $listing"
# The AUX bus, not the EDID bus its `ddc` symlink points at.
[[ "$(jq -r '.displays[0].bus' <<<"$listing")" == "9" ]] \
|| fail "the display was mapped to its EDID bus instead of its DisplayPort AUX bus, where nothing answers: $listing"
# 120 of a maximum of 200 is 60%.
[[ "$(jq -r '.displays[0].value' <<<"$listing")" == "60" ]] \
|| fail "brightness was not read as a percent of the reported maximum: $listing"
[[ "$(jq -r '.error' <<<"$listing")" == "" ]] \
|| fail "a successful listing must not carry an error: $listing"
# A connected panel that cannot report brightness is dropped, not listed.
jq -e '.displays | map(.connector) | index("DP-3") == null' >/dev/null <<<"$listing" \
|| fail 'a display without VCP 0x10 was listed as controllable'
# ── Disconnected connectors are never probed ─────────────────────────────────
if grep -qxE '4|7' "$DDCUTIL_PROBE_LOG"; then
fail "a disconnected connector was probed -- each empty bus costs a timeout: $(tr '\n' ' ' <"$DDCUTIL_PROBE_LOG")"
fi
# ── Writes scale to the reported maximum ─────────────────────────────────────
run_helper set 9 40
[[ "$(tail -1 "$DDCUTIL_SET_LOG")" == "set 9 80" ]] \
|| fail "set did not scale to the display's maximum: $(cat "$DDCUTIL_SET_LOG")"
run_helper set 9 500
[[ "$(tail -1 "$DDCUTIL_SET_LOG")" == "set 9 200" ]] \
|| fail "an out-of-range percent was not clamped: $(cat "$DDCUTIL_SET_LOG")"
# ── No I2C access explains itself ────────────────────────────────────────────
rm -f "$fixture/dev"/i2c-*
denied="$(run_helper list)"
[[ "$(jq -r '.displays | length' <<<"$denied")" == "0" ]] \
|| fail "displays were reported without I2C access: $denied"
grep -q 'udevadm' <<<"$(jq -r '.error' <<<"$denied")" \
|| fail "the no-access error must name the command that fixes it, got: $(jq -r '.error' <<<"$denied")"
printf 'brightness helper contract: PASS\n'
+19
View File
@@ -113,6 +113,25 @@ config_home="$(mktemp -d /tmp/panama-displays-config.XXXXXX)"
run() { XDG_CONFIG_HOME="$config_home" qs -p "$harness" "$@"; }
status() { run ipc call displays-test status; }
# The shipped geometry, read from the Hyprland config rather than from the
# running compositor.
#
# Everything below captures "original" from what it observes at start, which is
# correct only if the display is already in a good state. A previous run that
# failed mid-revert leaves the display changed, and the next run then captures
# THAT as the original and faithfully restores the desktop to a broken value.
# One flake becomes permanent. So refuse to run from a state that does not match
# what the config says, rather than laundering it.
shipped_scale="$(sed -n 's/^local shipped_scale *= *\([0-9.]*\).*/\1/p' \
"$repo_dir/config/dot/hypr/monitors.lua" | head -1)"
[[ -n "$shipped_scale" ]] || fail 'could not read the shipped scale from monitors.lua -- the guard below depends on it, and skipping it silently is how a dirty baseline gets laundered'
if [[ -n "$shipped_scale" ]]; then
live_scale="$(hyprctl -j monitors | jq -r '.[0].scale')"
if ! awk -v a="$live_scale" -v b="$shipped_scale" 'BEGIN { exit !(a == b) }'; then
fail "the display is at scale $live_scale but the config ships $shipped_scale -- refusing to capture a dirty state as the baseline. Restore it first: hyprctl eval 'hl.monitor({ output = \"DP-2\", mode = \"4500x3000@60\", scale = $shipped_scale, transform = 0 })'"
fi
fi
original_mode=""
original_scale=""
original_transform=""
+14
View File
@@ -13,6 +13,20 @@ cleanup() {
trap cleanup EXIT
qs ipc call focus end >/dev/null 2>&1 || true
# focus start records the caffeine state it found so end() can hand it back, and
# the assertion below requires caffeine to finish off. Both are only true if
# caffeine starts off.
#
# This test kills and restarts the live shell, so an interrupted run leaves
# caffeine on with nothing to turn it back off. The next run then records
# "previously on", faithfully restores it, and fails -- and every run after
# that fails identically, with the desktop quietly unable to idle or lock the
# whole time. Refuse to start from that state rather than latching it in.
if [[ "$(qs ipc call caffeine status 2>/dev/null)" != "false" ]]; then
fail 'caffeine is on before the session starts -- this test cannot distinguish "restored correctly" from "never released". Turn it off first: qs ipc call caffeine toggle'
fi
qs ipc call focus start >/dev/null
state_file=$(find "${XDG_STATE_HOME:-$HOME/.local/state}/quickshell/by-shell" -name focus-session.json -print -quit)
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env bash
# Every GTK theme name Panama sets must be a theme that is actually installed.
#
# This exists because of a bug that was invisible for weeks. ColorScheme set
# gtk-theme to "Adwaita-dark" for dark and "Adwaita" for light. Neither is
# installed on Fedora 44 -- only adw-gtk3 and adw-gtk3-dark are -- and GTK
# responds to an unknown theme name by silently falling back to its built-in
# default, which is LIGHT.
#
# So light mode appeared to work, dark mode produced light windows, and nothing
# anywhere reported an error. Applications that take their cue from the GTK
# theme rather than the portal -- Chromium and Electron among them -- were stuck
# light with no way to diagnose it from inside the application.
#
# The failure is silent by construction, so it needs a test rather than a
# comment. Checks the compositor-facing setting and the generated GTK config
# agree, and that both name something real.
set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
color_scheme="$repo_dir/config/dot/quickshell/services/ColorScheme.qml"
theme_apps="$repo_dir/config/dot/quickshell/scripts/panama-theme-apps"
fail() {
printf 'gtk theme contract: %s\n' "$1" >&2
exit 1
}
theme_installed() {
local name="$1" dir
for dir in /usr/share/themes "$HOME/.themes" "$HOME/.local/share/themes"; do
[[ -d "$dir/$name/gtk-3.0" ]] && return 0
done
return 1
}
# ── The names ColorScheme sets must exist ────────────────────────────────────
names="$(grep -oE 'root\.dark \? "[a-zA-Z0-9-]+" : "[a-zA-Z0-9-]+"' "$color_scheme" \
| grep -oE '"[a-zA-Z0-9-]+"' | tr -d '"' | grep -E '^adw-|^Adwaita' | sort -u)"
[[ -n "$names" ]] || fail 'could not find the GTK theme names in ColorScheme.qml -- this contract is not reading it correctly'
while read -r name; do
[[ -n "$name" ]] || continue
theme_installed "$name" \
|| fail "ColorScheme sets gtk-theme to \"$name\", which is not installed. GTK falls back to its light default when a theme is missing, so this produces light windows in dark mode with no error anywhere."
done <<<"$names"
# ── The generated GTK config must agree, in both directions ──────────────────
# Generated into a fixture rather than the live config, so running this cannot
# retheme the desktop it is running on.
fixture="$(mktemp -d /tmp/panama-gtk-theme.XXXXXX)"
trap 'rm -rf "$fixture"' EXIT
for version in 3.0 4.0; do
mkdir -p "$fixture/gtk-$version"
cp "$repo_dir/config/dot/gtk-$version/settings.ini.template" "$fixture/gtk-$version/" \
|| fail "gtk-$version has no settings.ini.template -- the generated file would never be produced"
done
for scheme in dark light; do
XDG_CONFIG_HOME="$fixture" "$theme_apps" "$scheme" >/dev/null 2>&1
for version in 3.0 4.0; do
generated="$fixture/gtk-$version/settings.ini"
[[ -r "$generated" ]] || fail "gtk-$version settings.ini was not generated for $scheme"
grep -q '@GTK_THEME@\|@PREFER_DARK@' "$generated" \
&& fail "gtk-$version settings.ini still contains an unsubstituted placeholder for $scheme"
theme="$(sed -n 's/^gtk-theme-name=//p' "$generated")"
prefer="$(sed -n 's/^gtk-application-prefer-dark-theme=//p' "$generated")"
theme_installed "$theme" \
|| fail "gtk-$version settings.ini names \"$theme\" for $scheme, which is not installed"
if [[ "$scheme" == "dark" ]]; then
[[ "$prefer" == "1" ]] || fail "gtk-$version asks for prefer-dark=$prefer in dark mode"
[[ "$theme" == *dark* ]] || fail "gtk-$version uses \"$theme\" in dark mode, which is not a dark theme"
else
[[ "$prefer" == "0" ]] || fail "gtk-$version asks for prefer-dark=$prefer in light mode"
[[ "$theme" != *dark* ]] || fail "gtk-$version uses \"$theme\" in light mode, which is a dark theme"
fi
done
done
printf 'gtk theme contract: PASS\n'
@@ -288,18 +288,18 @@ shell_pid="$(qs_for_test list | awk '/Process ID:/ { print $3; exit }')"
[[ "$shell_pid" =~ ^[0-9]+$ ]] || fail 'could not identify the branch shell process'
for _ in $(seq 1 40); do
if /usr/sbin/hyprctl -j clients | jq -e --argjson pid "$shell_pid" \
'[.[] | select(.pid == $pid and .title == "Panama Settings" and .floating == false)] | length == 1' >/dev/null; then
'[.[] | select(.pid == $pid and .title == "Settings" and .floating == false)] | length == 1' >/dev/null; then
break
fi
sleep 0.1
done
/usr/sbin/hyprctl -j clients | jq -e --argjson pid "$shell_pid" \
'[.[] | select(.pid == $pid and .title == "Panama Settings" and .floating == false)] | length == 1' >/dev/null \
'[.[] | select(.pid == $pid and .title == "Settings" and .floating == false)] | length == 1' >/dev/null \
|| fail 'the branch shell did not own exactly one tiled Panama Settings client'
if [[ -n "${PANAMA_TEST_SCREENSHOT_PATH:-}" ]]; then
geometry="$(/usr/sbin/hyprctl -j clients | jq -r --argjson pid "$shell_pid" \
'.[] | select(.pid == $pid and .title == "Panama Settings") | "\(.at[0]),\(.at[1]) \(.size[0])x\(.size[1])"')"
'.[] | select(.pid == $pid and .title == "Settings") | "\(.at[0]),\(.at[1]) \(.size[0])x\(.size[1])"')"
[[ -n "$geometry" ]] || fail 'could not resolve the Settings client geometry'
grim -g "$geometry" "$PANAMA_TEST_SCREENSHOT_PATH"
fi
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env bash
# panama-keyring reports the login keyring's state, and the Settings page reads
# nothing but its JSON.
#
# The state that matters is LOCKED, and it is also the one that cannot be
# rehearsed on a real desktop: locking the login keyring breaks every saved
# password on the machine and can only be undone by typing the password into a
# dialog. So the secret service is stubbed here instead. Nothing touches the
# real keyring -- this contract is safe to run on the daily driver, which is the
# entire reason it is written this way.
set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
helper="$repo_dir/config/dot/quickshell/scripts/panama-keyring"
fail() {
printf 'keyring helper contract: %s\n' "$1" >&2
exit 1
}
stub_dir="$(mktemp -d /tmp/panama-keyring.XXXXXX)"
trap 'rm -rf "$stub_dir"' EXIT
# A stand-in for the `gi` module the helper imports. PANAMA_KEYRING_FAKE decides
# what the fake service reports, so one stub covers every case.
mkdir -p "$stub_dir/gi/repository"
cat >"$stub_dir/gi/__init__.py" <<'STUB'
def require_version(*_args, **_kwargs):
return None
STUB
cat >"$stub_dir/gi/repository/__init__.py" <<'STUB'
import os
class _Collection:
def __init__(self, label, locked):
self._label = label
self._locked = locked
def get_label(self):
return self._label
def get_locked(self):
return self._locked
class _Service:
def get_collections(self):
mode = os.environ.get("PANAMA_KEYRING_FAKE", "unlocked")
if mode == "nologin":
return [_Collection("Some App", False)]
return [_Collection("Login", mode == "locked"), _Collection("", False)]
class _ServiceFactory:
@staticmethod
def get_sync(_flags, _cancellable):
if os.environ.get("PANAMA_KEYRING_FAKE") == "unavailable":
raise RuntimeError("no secret service")
return _Service()
# unlock_sync is what the `unlock` action calls; record that it was reached.
@staticmethod
def _noop(*_args, **_kwargs):
return None
class Secret:
class ServiceFlags:
LOAD_COLLECTIONS = 1
Service = _ServiceFactory
STUB
run() {
PYTHONPATH="$stub_dir" PANAMA_KEYRING_FAKE="$1" python3 "$helper" "${2:-status}"
}
# ── Unlocked: the normal state after any sign-in ─────────────────────────────
out="$(run unlocked)"
jq -e . >/dev/null 2>&1 <<<"$out" || fail "status did not emit JSON: $out"
jq -e '.available == true and .locked == false and .hasLogin == true' >/dev/null <<<"$out" \
|| fail "an unlocked login keyring was misreported: $out"
# ── Locked: the state the whole card exists for ──────────────────────────────
out="$(run locked)"
jq -e '.available == true and .locked == true' >/dev/null <<<"$out" \
|| fail "a locked login keyring was not reported as locked: $out"
# ── No secret service at all is a state, not a crash ─────────────────────────
out="$(run unavailable)"
jq -e . >/dev/null 2>&1 <<<"$out" \
|| fail "a missing secret service produced no JSON, so the page would show nothing: $out"
jq -e '.available == false and .error != ""' >/dev/null <<<"$out" \
|| fail "a missing secret service must be reported with a reason: $out"
# ── No login keyring: not locked, because there is nothing to lock ───────────
out="$(run nologin)"
jq -e '.available == true and .hasLogin == false and .locked == false' >/dev/null <<<"$out" \
|| fail "a machine with no login keyring must not report itself locked: $out"
# ── The daemon origin is reported, since it is the crash diagnostic ──────────
jq -e '.daemon | test("^(pam|dbus|none|unknown)$")' >/dev/null <<<"$(run unlocked)" \
|| fail "the daemon origin must be one of pam/dbus/none/unknown"
printf 'keyring helper contract: PASS\n'
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env bash
# Versioned upgrades for settings.json.
#
# The schema says what a setting IS; it cannot say what a setting USED to be.
# Rename a key, change its units, or split one setting into two, and the stored
# value stops being recognised -- and unrecognised keys are deliberately carried
# through untouched, so the user's choice silently stops taking effect with
# nothing to explain it.
#
# The list of migrations is empty today, which is exactly why this is tested
# now: the first time it runs for real will be against somebody's actual
# settings during an upgrade, and that is a poor moment to discover how it
# behaves. The harness supplies fixture steps, including one that throws.
set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
harness="$repo_dir/config/dot/quickshell/migrations-harness.qml"
fail() {
printf 'migrations contract: %s\n' "$1" >&2
exit 1
}
[[ -r "$harness" ]] || fail "the harness is missing: $harness"
out="$(timeout 60 qs -p "$harness" 2>&1 | grep -o 'PANAMA-MIGRATIONS .*' | sed 's/^PANAMA-MIGRATIONS //')"
[[ -n "$out" ]] || fail 'the harness produced no result'
jq -e . >/dev/null 2>&1 <<<"$out" || fail "the harness did not emit JSON: $out"
check() {
jq -e "$1" >/dev/null <<<"$out" || fail "$2 -- got $(jq -c "$3" <<<"$out")"
}
# A file written before versioning existed is stamped, NOT migrated. Running
# the list against it would apply upgrades designed for schemas it never had.
check '.unversioned.v == 1 and .unversioned.migrated == false and .unversioned.untouched == true' \
'a file with no schemaVersion must be stamped at the baseline without being migrated' '.unversioned'
# The stamp has to reach disk. Reported as changed-but-not-migrated, it would
# otherwise live only in memory and be redone on every single launch.
check '.unversioned.changed == true' \
'stamping a pre-versioning file must be reported as a change so it gets written' '.unversioned'
# A file from the future must not be rewritten at all.
check '.future.changed == false' \
'a file from a newer version must not be written back' '.future'
# Every step above the stored version runs, in order.
check '.upgrade.v == 3 and .upgrade.b == 2 and .upgrade.c == "three" and .upgrade.count == 2' \
'an older file must run each pending step in order and end at the current version' '.upgrade'
# Already current: nothing runs, nothing is touched.
check '.current.migrated == false and .current.kept == true and .current.b == true' \
'a file already at the current version must be left alone' '.current'
# A file from a NEWER Panama is left completely alone. Downgrading keys is not
# something this can do correctly, and unknown keys are already preserved.
check '.future.v == 9 and .future.migrated == false and .future.kept == true' \
'a file from a newer version must not be modified or downgraded' '.future'
# A failing step stops at the last good version. Skipping past it would lose
# the conversion forever; failing the whole load would cost every setting.
check '.failure.v == 3 and .failure.count == 2 and .failure.kept == true' \
'a failing step must stop at the last good version, keeping the steps that succeeded' '.failure'
# The promise that makes rollback safe.
check '.preserved.kept == true' \
'a migration must not discard keys it does not recognise' '.preserved'
printf 'migrations contract: PASS\n'
+158 -1
View File
@@ -26,10 +26,62 @@ printf 'brightnessctl' >>"$OSD_TEST_LOG"
printf ' <%s>' "$@" >>"$OSD_TEST_LOG"
printf '\n' >>"$OSD_TEST_LOG"
if [[ " $* " == *" -m "* && " $* " != *" set "* ]]; then
[[ ${BACKLIGHT_AVAILABLE:-true} == true ]] || exit 1
printf '%s\n' "${BRIGHTNESS_OUTPUT:-intel_backlight,backlight,500,1000,50%}"
fi
SH
cat >"$scratch/bin/panama-brightness" <<'SH'
#!/bin/bash
printf 'panama-brightness' >>"$OSD_TEST_LOG"
printf ' <%s>' "$@" >>"$OSD_TEST_LOG"
printf '\n' >>"$OSD_TEST_LOG"
case "${1:-}" in
list)
if [[ -n ${DDC_LIST_JSON:-} ]]; then
printf '%s\n' "$DDC_LIST_JSON"
else
printf '%s\n' '{"displays":[],"error":"No displays"}'
fi
;;
get)
[[ ${DDC_FAIL_GET_BUS:-} != "${2:-}" ]] || exit 1
if [[ -s $OSD_DDC_STATE ]]; then
cat "$OSD_DDC_STATE"
else
printf '%s\n' "${DDC_GET_VALUE:-40}"
fi
;;
set)
if [[ -n ${DDC_SET_DELAY:-} ]]; then
if ! mkdir "$OSD_DDC_PROBE" 2>/dev/null; then
printf 'ddc-overlap\n' >>"$OSD_TEST_LOG"
fi
sleep "$DDC_SET_DELAY"
rmdir "$OSD_DDC_PROBE" 2>/dev/null || true
fi
printf '%s\n' "${3:-0}" >"$OSD_DDC_STATE"
;;
*) exit 2 ;;
esac
SH
cat >"$scratch/bin/hyprctl" <<'SH'
#!/bin/bash
printf 'hyprctl' >>"$OSD_TEST_LOG"
printf ' <%s>' "$@" >>"$OSD_TEST_LOG"
printf '\n' >>"$OSD_TEST_LOG"
printf '[{"name":"%s","focused":true}]\n' "${FOCUSED_MONITOR:-DP-2}"
SH
cat >"$scratch/bin/notify-send" <<'SH'
#!/bin/bash
printf 'notify-send' >>"$OSD_TEST_LOG"
printf ' <%s>' "$@" >>"$OSD_TEST_LOG"
printf '\n' >>"$OSD_TEST_LOG"
SH
cat >"$scratch/bin/playerctl" <<'SH'
#!/bin/bash
printf 'playerctl' >>"$OSD_TEST_LOG"
@@ -53,9 +105,21 @@ SH
chmod +x "$scratch/bin/"*
run_helper() {
local runtime="${OSD_RUNTIME_DIR:-$scratch/runtime-default}"
mkdir -p "$runtime"
PATH="$scratch/bin:$PATH" OSD_TEST_LOG="$log" \
OSD_TEST_FAIL_QS="${OSD_TEST_FAIL_QS:-false}" \
PANAMA_OSD_STRICT="${PANAMA_OSD_STRICT:-false}" \
PANAMA_OSD_BRIGHTNESS_HELPER="$scratch/bin/panama-brightness" \
PANAMA_OSD_RUNTIME_DIR="$runtime" \
OSD_DDC_STATE="$runtime/ddc-state" \
OSD_DDC_PROBE="$runtime/ddc-probe" \
BACKLIGHT_AVAILABLE="${BACKLIGHT_AVAILABLE:-true}" \
DDC_LIST_JSON="${DDC_LIST_JSON:-}" \
DDC_GET_VALUE="${DDC_GET_VALUE:-40}" \
DDC_FAIL_GET_BUS="${DDC_FAIL_GET_BUS:-}" \
DDC_SET_DELAY="${DDC_SET_DELAY:-}" \
FOCUSED_MONITOR="${FOCUSED_MONITOR:-DP-2}" \
"$helper" "$@"
}
@@ -86,9 +150,102 @@ assert_line 'qs <ipc> <call> <osd> <progress> <microphone-muted> <72> <100> <Mut
: >"$log"
run_helper brightness up 5
assert_line 'brightnessctl <-e4> <-n2> <set> <5%+>'
assert_line 'brightnessctl <-m> <-c> <backlight>'
assert_line 'brightnessctl <-e4> <-n2> <-c> <backlight> <set> <5%+>'
assert_line 'qs <ipc> <call> <osd> <progress> <brightness> <50> <100> <50%>'
if grep -Fq 'panama-brightness' "$log"; then
printf 'osd helper contract: DDC fallback ran despite a native backlight\n' >&2
exit 1
fi
: >"$log"
OSD_RUNTIME_DIR="$scratch/runtime-ddc" \
BACKLIGHT_AVAILABLE=false \
DDC_LIST_JSON='{"displays":[{"bus":3,"connector":"HDMI-A-1","value":35},{"bus":5,"connector":"DP-2","value":40}],"error":""}' \
run_helper brightness up 5
assert_line 'hyprctl <-j> <monitors>'
assert_line 'panama-brightness <list>'
assert_line 'panama-brightness <get> <5>'
assert_line 'panama-brightness <set> <5> <45>'
assert_line 'qs <ipc> <call> <osd> <progress> <brightness> <45> <100> <45%>'
# A cached bus avoids the expensive display scan on subsequent key presses.
: >"$log"
OSD_RUNTIME_DIR="$scratch/runtime-ddc" \
BACKLIGHT_AVAILABLE=false \
DDC_LIST_JSON='{"displays":[{"bus":3,"connector":"HDMI-A-1","value":35},{"bus":5,"connector":"DP-2","value":45}],"error":""}' \
run_helper brightness down 5
assert_line 'panama-brightness <get> <5>'
assert_line 'panama-brightness <set> <5> <40>'
assert_line 'qs <ipc> <call> <osd> <progress> <brightness> <40> <100> <40%>'
if grep -Fq 'panama-brightness <list>' "$log"; then
printf 'osd helper contract: cached DDC bus triggered another display scan\n' >&2
exit 1
fi
# A disconnected cached monitor is discarded and rediscovered once.
mkdir -p "$scratch/runtime-ddc-stale"
printf '9\tDP-9\n' >"$scratch/runtime-ddc-stale/brightness-bus"
: >"$log"
OSD_RUNTIME_DIR="$scratch/runtime-ddc-stale" \
BACKLIGHT_AVAILABLE=false \
DDC_FAIL_GET_BUS=9 \
DDC_LIST_JSON='{"displays":[{"bus":5,"connector":"DP-2","value":40}],"error":""}' \
run_helper brightness up 5
assert_line 'panama-brightness <get> <9>'
assert_line 'panama-brightness <list>'
assert_line 'panama-brightness <get> <5>'
assert_line 'panama-brightness <set> <5> <45>'
# If the focused output is not DDC-capable, use the first discovered display.
: >"$log"
OSD_RUNTIME_DIR="$scratch/runtime-ddc-first" \
BACKLIGHT_AVAILABLE=false \
FOCUSED_MONITOR='eDP-1' \
DDC_GET_VALUE=35 \
DDC_LIST_JSON='{"displays":[{"bus":3,"connector":"HDMI-A-1","value":35},{"bus":5,"connector":"DP-2","value":40}],"error":""}' \
run_helper brightness down 10
assert_line 'panama-brightness <get> <3>'
assert_line 'panama-brightness <set> <3> <25>'
assert_line 'qs <ipc> <call> <osd> <progress> <brightness> <25> <100> <25%>'
# Permission and discovery errors must be visible, never masquerade as 0%.
: >"$log"
OSD_RUNTIME_DIR="$scratch/runtime-ddc-error" \
BACKLIGHT_AVAILABLE=false \
DDC_LIST_JSON='{"displays":[],"error":"Run sudo udevadm control --reload-rules && sudo udevadm trigger --subsystem-match=i2c-dev --subsystem-match=drm"}' \
run_helper brightness up 5
assert_line 'qs <ipc> <call> <osd> <message> <dialog-warning-symbolic> <Brightness needs permission>'
assert_line 'notify-send <--app-name=Panama> <--icon=display-brightness-symbolic> <Brightness unavailable> <Run sudo udevadm control --reload-rules && sudo udevadm trigger --subsystem-match=i2c-dev --subsystem-match=drm>'
if grep -Fq 'osd> <progress> <brightness>' "$log"; then
printf 'osd helper contract: unavailable brightness rendered a false percentage\n' >&2
exit 1
fi
# Separate key-repeat processes must not overlap their DDC transactions.
: >"$log"
OSD_RUNTIME_DIR="$scratch/runtime-ddc-lock" \
BACKLIGHT_AVAILABLE=false \
DDC_SET_DELAY=0.15 \
DDC_LIST_JSON='{"displays":[{"bus":5,"connector":"DP-2","value":40}],"error":""}' \
run_helper brightness up 5 &
first_pid=$!
OSD_RUNTIME_DIR="$scratch/runtime-ddc-lock" \
BACKLIGHT_AVAILABLE=false \
DDC_SET_DELAY=0.15 \
DDC_LIST_JSON='{"displays":[{"bus":5,"connector":"DP-2","value":40}],"error":""}' \
run_helper brightness up 5 &
second_pid=$!
wait "$first_pid"
wait "$second_pid"
if grep -Fqx 'ddc-overlap' "$log"; then
printf 'osd helper contract: concurrent DDC transactions overlapped\n' >&2
exit 1
fi
if [[ $(<"$scratch/runtime-ddc-lock/ddc-state") != 50 ]]; then
printf 'osd helper contract: serialized key repeats did not both apply\n' >&2
exit 1
fi
: >"$log"
run_helper media next
+103
View File
@@ -0,0 +1,103 @@
#!/usr/bin/env bash
# panama-power-profile reads and sets the system power profile.
#
# Runs against a stubbed busctl. The real daemon is a SYSTEM service shared with
# everything else on the machine, and a test that flipped the daily driver into
# power-saver and crashed before restoring would leave it there.
#
# The parsing is the fragile part. busctl renders the Profiles property flat:
#
# v aa{sv} 3 2 "Profile" s "power-saver" "Driver" s "tuned" 2 "Profile" ...
#
# so profile names and driver names sit in the same stream. A pattern loose
# enough to match both reports the driver as an extra profile -- and on this
# machine the driver is literally called "tuned", which reads exactly like a
# plausible fourth profile.
set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
helper="$repo_dir/config/dot/quickshell/scripts/panama-power-profile"
fail() {
printf 'power profile contract: %s\n' "$1" >&2
exit 1
}
stub_dir="$(mktemp -d /tmp/panama-power.XXXXXX)"
trap 'rm -rf "$stub_dir"' EXIT
cat >"$stub_dir/busctl" <<'STUB'
#!/usr/bin/env bash
# Records set-property calls so the test can assert what was written.
case "${1:-}" in
status)
[[ "${PANAMA_POWER_FAKE:-up}" == "down" ]] && exit 1
exit 0 ;;
get-property)
case "${5:-}" in
ActiveProfile) printf 's "performance"\n' ;;
PerformanceDegraded) printf 's "%s"\n' "${PANAMA_POWER_DEGRADED:-}" ;;
Profiles)
if [[ "${PANAMA_POWER_FAKE:-up}" == "empty" ]]; then
printf 'v aa{sv} 0\n'
else
printf 'v aa{sv} 3 2 "Profile" s "power-saver" "Driver" s "tuned" 2 "Profile" s "balanced" "Driver" s "tuned" 2 "Profile" s "performance" "Driver" s "tuned"\n'
fi ;;
esac
exit 0 ;;
set-property)
printf '%s\n' "${!#}" >>"$PANAMA_POWER_SET_LOG"
exit 0 ;;
esac
exit 0
STUB
chmod +x "$stub_dir/busctl"
export PANAMA_POWER_SET_LOG="$stub_dir/sets.log"
: >"$PANAMA_POWER_SET_LOG"
run() { PATH="$stub_dir:$PATH" "$helper" "$@"; }
# ── Parsing ──────────────────────────────────────────────────────────────────
out="$(run list)"
jq -e . >/dev/null 2>&1 <<<"$out" || fail "list did not emit JSON: $out"
[[ "$(jq -r '.profiles | length' <<<"$out")" == "3" ]] \
|| fail "expected exactly three profiles; a fourth usually means the Driver value was parsed as one: $out"
jq -e '.profiles == ["power-saver", "balanced", "performance"]' >/dev/null <<<"$out" \
|| fail "profiles were parsed wrongly or reordered: $out"
jq -e '.profiles | index("tuned") == null' >/dev/null <<<"$out" \
|| fail 'the driver name "tuned" was reported as a profile'
[[ "$(jq -r '.active' <<<"$out")" == "performance" ]] \
|| fail "the active profile was not read: $out"
# ── Degradation is surfaced, since it makes the active profile a lie ─────────
out="$(PANAMA_POWER_DEGRADED="lap-detected" run list)"
[[ "$(jq -r '.degraded' <<<"$out")" == "lap-detected" ]] \
|| fail "a degraded performance state was not reported: $out"
# ── Setting ──────────────────────────────────────────────────────────────────
run set balanced
[[ "$(tail -1 "$PANAMA_POWER_SET_LOG")" == "balanced" ]] \
|| fail "set did not write the requested profile: $(cat "$PANAMA_POWER_SET_LOG")"
before="$(wc -l <"$PANAMA_POWER_SET_LOG")"
run set 'evil; rm -rf /' 2>/dev/null
[[ "$(wc -l <"$PANAMA_POWER_SET_LOG")" == "$before" ]] \
|| fail 'a profile name with shell metacharacters reached the system service'
# ── No daemon, and a daemon with nothing to offer, are both states ───────────
out="$(PANAMA_POWER_FAKE=down run list)"
jq -e '.profiles == [] and .error != ""' >/dev/null <<<"$out" \
|| fail "a missing power daemon must be reported with a reason: $out"
out="$(PANAMA_POWER_FAKE=empty run list)"
jq -e '.profiles == [] and .error != ""' >/dev/null <<<"$out" \
|| fail "a daemon offering no profiles must be reported, not shown as an empty card: $out"
printf 'power profile contract: PASS\n'
@@ -61,6 +61,26 @@ restore() {
}
trap restore EXIT
# This contract shares its harness file with settings-hyprland-write-contract,
# and Quickshell identifies an instance by config path -- so if that run's
# instance is still alive, the IPC wait below is satisfied by ITS target. That
# direction is the dangerous one: this contract believes the compositor seam is
# stubbed, so it would happily drive the DAILY DESKTOP's real compositor while
# reporting isolation. Refuse to start rather than find out.
harness_instances() {
# rg -c prints nothing when there are no matches, so an unguarded
# substitution yields "" rather than "0".
local count
count="$(qs list 2>/dev/null | rg -c "^ Config path: $harness\$" || true)"
printf '%s' "${count:-0}"
}
for _ in $(seq 1 50); do
[[ "$(harness_instances)" == "0" ]] && break
sleep 0.1
done
[[ "$(harness_instances)" == "0" ]] \
|| fail 'another instance of the settings harness is still running -- this contract would drive it instead of its own isolated one, and that instance may be writing to the real compositor'
XDG_CONFIG_HOME="$config_home" XDG_STATE_HOME="$state_home" \
PANAMA_SETTINGS_TEST_ISOLATE_COMPOSITOR=1 qs -p "$harness" --daemonize >/dev/null
for _ in $(seq 1 40); do
@@ -77,6 +77,32 @@ target_auto_hdr_int=$([[ "$target_auto_hdr" == true ]] && printf 1 || printf 0)
target_vrr=$([[ "$original_vrr" == 3 ]] && printf 0 || printf 3)
target_direct=$([[ "$original_direct" == 2 ]] && printf 0 || printf 2)
# settings-commit-reset-contract drives the SAME harness file with
# PANAMA_SETTINGS_TEST_ISOLATE_COMPOSITOR=1, where the compositor write seam is
# stubbed out. Quickshell identifies an instance by its config path, so if that
# run's instance has not fully exited, the `ipc show` wait below is satisfied by
# ITS target -- and every write in this contract lands on the isolated instance
# and never reaches the compositor. That is exactly what "a typed batch did not
# reach the compositor" looks like when this fails in a full suite run but
# passes on its own.
#
# So wait for the harness to be clear first, and say so plainly if it is not,
# rather than silently talking to the wrong shell.
harness_instances() {
# rg -c prints nothing at all when there are no matches, so an unguarded
# substitution yields "" rather than "0" and every comparison against a
# count fails.
local count
count="$(qs list 2>/dev/null | rg -c "^ Config path: $harness\$" || true)"
printf '%s' "${count:-0}"
}
for _ in $(seq 1 50); do
[[ "$(harness_instances)" == "0" ]] && break
sleep 0.1
done
[[ "$(harness_instances)" == "0" ]] \
|| fail 'another instance of the settings harness is still running -- this contract would talk to it instead of its own, and its writes may be deliberately stubbed'
XDG_CONFIG_HOME="$config_home" qs -p "$harness" --daemonize >/dev/null
for _ in $(seq 1 40); do
if qs_for_harness ipc show 2>/dev/null | rg -q '^target settings-system-test$'; then
@@ -123,8 +149,23 @@ sleep 0.3
qs_for_harness ipc call settings-system-test applyJson \
'{"windowRounding": 7, "gapsOut": 23, "blurEnabled": false, "inactiveOpacity": 0.85}' >/dev/null
# 10 seconds, not 4. The write path verifies each option by reading it back off
# the compositor and retries a refused batch, so a busy machine legitimately
# takes longer than a quick apply -- and this contract runs in a suite alongside
# other tests driving the same compositor. Failing at 4 seconds reported a
# product bug ("did not reach the compositor") for what was queueing.
# Re-issued periodically, because this contract and the LIVE shell both write to
# the same compositor. When the running Panama re-applies its own preferences --
# which it does on any store change -- it overwrites the values this test just
# set, and the read-back below then sees Panama's shipped defaults with the
# writer reporting no error at all. That combination is the signature: a
# rejected write leaves an error, a clobbered one does not.
typed=false
for _ in $(seq 1 40); do
for attempt in $(seq 1 100); do
if (( attempt % 30 == 0 )); then
qs_for_harness ipc call settings-system-test applyJson \
'{"windowRounding": 7, "gapsOut": 23, "blurEnabled": false, "inactiveOpacity": 0.85}' >/dev/null
fi
if [[ "$(read_option decoration:rounding)" == "7" \
&& "$(hyprctl -j getoption general:gaps_out | jq -r .css | awk '{print $1}')" == "23" \
&& "$(hyprctl -j getoption decoration:blur:enabled | jq -r .bool)" == "false" \
@@ -135,10 +176,14 @@ for _ in $(seq 1 40); do
sleep 0.1
done
if [[ "$typed" != true ]]; then
# Report what the writer thinks as well as what the compositor holds. Those
# two disagreeing is a rejected write; both showing defaults is a write that
# never happened, and the messages should not look identical.
fail "a typed batch did not reach the compositor: rounding=$(read_option decoration:rounding), \
gaps=$(hyprctl -j getoption general:gaps_out | jq -r .css), \
blur=$(hyprctl -j getoption decoration:blur:enabled | jq -r .bool), \
opacity=$(hyprctl -j getoption decoration:inactive_opacity | jq -r .float)"
opacity=$(hyprctl -j getoption decoration:inactive_opacity | jq -r .float), \
writer-reported error=\"$(qs_for_harness ipc call settings-system-test status | jq -r .lastError)\""
fi
# Verification must recognise those shapes as success, not report them rejected.
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env bash
# Adding a settings page means editing four separate files, and missing one
# fails quietly rather than loudly:
#
# SettingsSidebar.qml the row you click
# SettingsShell.qml the case that maps that row to a component, AND the
# Component declaration itself
# ShellState.qml the allow-list openSettings() checks -- a page missing
# here silently redirects to Home, so a deep link or a
# search result lands on the wrong page with no error
# modules/settings/qmldir the component registration -- without it the page
# is "not a type" and the whole settings window fails
# to load, taking every other page with it
#
# Nothing at runtime cross-checks the four. This does, statically.
set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
settings_dir="$repo_dir/config/dot/quickshell/modules/settings"
sidebar="$settings_dir/SettingsSidebar.qml"
shell_file="$settings_dir/SettingsShell.qml"
qmldir="$settings_dir/qmldir"
shell_state="$repo_dir/config/dot/quickshell/services/ShellState.qml"
fail() {
printf 'settings nav contract: %s\n' "$1" >&2
exit 1
}
for required in "$sidebar" "$shell_file" "$qmldir" "$shell_state"; do
[[ -r "$required" ]] || fail "cannot read $required"
done
# ── Every sidebar row resolves everywhere ────────────────────────────────────
pages="$(grep -oE '\{ page: "[a-z-]+"' "$sidebar" | sed 's/.*"\(.*\)"/\1/')"
[[ -n "$pages" ]] || fail 'no pages found in the sidebar -- this contract is not reading it correctly'
allowed_line="$(grep -m1 'const allowed = \[' "$shell_state")" \
|| fail 'could not find the allow-list in ShellState'
while read -r page; do
[[ -n "$page" ]] || continue
# Home is the switch's default arm rather than a case, since it is also
# where an unknown page falls back to.
if [[ "$page" != "home" ]]; then
grep -qE "case \"$page\": return [a-zA-Z]+;" "$shell_file" \
|| fail "the sidebar offers \"$page\" but SettingsShell has no case for it, so clicking it shows Home"
fi
grep -qF "\"$page\"" <<<"$allowed_line" \
|| fail "\"$page\" is missing from ShellState's allow-list, so openSettings(\"$page\") silently redirects to Home"
done <<<"$pages"
# ── Every routed component is declared and registered ────────────────────────
# The case arms name a Component id; each must have a declaration, and the type
# it instantiates must appear in qmldir.
while read -r component; do
[[ -n "$component" ]] || continue
declaration="$(grep -oE "Component \{ id: $component; [A-Za-z]+ \{\} \}" "$shell_file")" \
|| fail "SettingsShell routes to \"$component\" but never declares it"
type_name="$(sed -E 's/.*; ([A-Za-z]+) \{\} \}/\1/' <<<"$declaration")"
grep -qE "^$type_name [0-9.]+ $type_name\.qml$" "$qmldir" \
|| fail "$type_name is not registered in modules/settings/qmldir -- it will fail to load as \"not a type\", and the whole settings window fails with it"
[[ -r "$settings_dir/$type_name.qml" ]] \
|| fail "$type_name is registered in qmldir but $type_name.qml does not exist"
done < <({
grep -oE 'case "[a-z-]+": return [a-zA-Z]+;' "$shell_file" | sed -E 's/.*return ([a-zA-Z]+);/\1/'
grep -oE 'default: return [a-zA-Z]+;' "$shell_file" | sed -E 's/.*return ([a-zA-Z]+);/\1/'
} | sort -u)
# ── Every page file is reachable ─────────────────────────────────────────────
# A page nobody can navigate to is dead code that still has to compile. The
# scaffold SettingsPage.qml is the one file here that is a base class rather
# than a page.
while read -r page_file; do
type_name="$(basename "$page_file" .qml)"
[[ "$type_name" == "SettingsPage" ]] && continue
grep -qE "; $type_name \{\} \}" "$shell_file" \
|| fail "$type_name.qml exists but nothing in SettingsShell instantiates it"
done < <(find "$settings_dir" -maxdepth 1 -name '*Page.qml')
printf 'settings nav contract: PASS\n'
+2 -2
View File
@@ -195,14 +195,14 @@ for page in "${pages[@]}"; do
done
[[ "$(qs_for_test ipc call settings status | jq -r .page)" == "$page" ]] || fail "$page did not route"
/usr/sbin/hyprctl -j clients | jq -e --argjson pid "$shell_pid" \
'[.[] | select(.pid == $pid and .title == "Panama Settings" and .floating == false)] | length == 1' >/dev/null \
'[.[] | select(.pid == $pid and .title == "Settings" and .floating == false)] | length == 1' >/dev/null \
|| fail "$page created a missing, floating, or duplicate Settings window"
done
qs_for_test ipc call settings page '__unsupported__' >/dev/null
[[ "$(qs_for_test ipc call settings status | jq -r .page)" == "home" ]] || fail 'unsupported page did not fall back to Home'
/usr/sbin/hyprctl -j binds | jq -e '.[] | select(.description == "Panama Settings" and .key == "I" and .modmask == 64)' >/dev/null \
/usr/sbin/hyprctl -j binds | jq -e '.[] | select(.description == "Settings" and .key == "I" and .modmask == 64)' >/dev/null \
|| fail 'Super+I is not registered as Panama Settings'
/usr/sbin/hyprctl -j binds | jq -e '.[] | select(.description == "Screen Intelligence" and .key == "S" and .modmask == 65)' >/dev/null \
|| fail 'Super+Shift+S is not registered as Screen Intelligence'
+6 -6
View File
@@ -16,14 +16,14 @@ qs ipc show | rg -q '^target settings$' || fail 'settings IPC target is missing'
qs ipc call settings open >/dev/null
for _ in $(seq 1 40); do
if hyprctl -j clients | jq -e '[.[] | select(.title == "Panama Settings")] | length == 1' >/dev/null; then
if hyprctl -j clients | jq -e '[.[] | select(.title == "Settings")] | length == 1' >/dev/null; then
break
fi
sleep 0.1
done
hyprctl -j clients | jq -e '[.[] | select(.title == "Panama Settings")] | length == 1' >/dev/null \
hyprctl -j clients | jq -e '[.[] | select(.title == "Settings")] | length == 1' >/dev/null \
|| fail 'exactly one Settings window did not map'
hyprctl -j clients | jq -e '.[] | select(.title == "Panama Settings" and .floating == false)' >/dev/null \
hyprctl -j clients | jq -e '.[] | select(.title == "Settings" and .floating == false)' >/dev/null \
|| fail 'Settings window is not tiled'
qs ipc call settings page displays >/dev/null
@@ -32,7 +32,7 @@ qs ipc call settings page displays >/dev/null
qs ipc call settings page desktop >/dev/null
[[ "$(qs ipc call settings status | jq -r .page)" == "desktop" ]] || fail 'Desktop page did not route'
address="$(hyprctl -j clients | jq -r '.[] | select(.title == "Panama Settings") | .address')"
address="$(hyprctl -j clients | jq -r '.[] | select(.title == "Settings") | .address')"
hyprctl dispatch "hl.dsp.window.close({ window = \"address:$address\" })" >/dev/null
for _ in $(seq 1 40); do
[[ "$(qs ipc call settings status | jq -r .open)" == "false" ]] && break
@@ -42,13 +42,13 @@ done
qs ipc call settings open >/dev/null
for _ in $(seq 1 40); do
hyprctl -j clients | jq -e '.[] | select(.title == "Panama Settings")' >/dev/null && break
hyprctl -j clients | jq -e '.[] | select(.title == "Settings")' >/dev/null && break
sleep 0.1
done
qs ipc call settings close >/dev/null
for _ in $(seq 1 40); do
if ! hyprctl -j clients | jq -e '.[] | select(.title == "Panama Settings")' >/dev/null; then
if ! hyprctl -j clients | jq -e '.[] | select(.title == "Settings")' >/dev/null; then
trap - EXIT
printf 'settings window contract: PASS\n'
exit 0
+126
View File
@@ -0,0 +1,126 @@
#!/usr/bin/env bash
# panama-wifi-qr renders a saved network as a QR code a phone can scan.
#
# The QR contains the network PASSWORD in machine-readable form, so most of what
# is worth testing here is about handling that safely rather than about QR
# codes. Both nmcli and qrencode are stubbed: the real ones would read this
# machine's actual passphrases, and a test that writes the daily driver's Wi-Fi
# password into a fixture directory is not one worth having.
set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
helper="$repo_dir/config/dot/quickshell/scripts/panama-wifi-qr"
fail() {
printf 'wifi qr contract: %s\n' "$1" >&2
exit 1
}
work="$(mktemp -d /tmp/panama-wifiqr.XXXXXX)"
trap 'rm -rf "$work"' EXIT
mkdir -p "$work/bin" "$work/run"
readonly SECRET='hunter2-secret'
cat >"$work/bin/nmcli" <<STUB
#!/usr/bin/env bash
# -t -f NAME,TYPE connection show
if [[ "\$*" == *"-f NAME,TYPE"* ]]; then
printf 'home net:802-11-wireless\n'
printf 'work-eap:802-11-wireless\n'
printf 'Wired connection 1:802-3-ethernet\n'
exit 0
fi
name="\${@: -1}"
case "\$*" in
*802-11-wireless.ssid*)
# An SSID containing reserved characters, to prove they are escaped.
case "\$name" in
"home net") printf 'home;net\n' ;;
"work-eap") printf 'work-eap\n' ;;
esac ;;
*802-11-wireless.hidden*) printf 'no\n' ;;
*802-11-wireless-security.psk*)
# work-eap is enterprise: no passphrase exists to share.
[[ "\$name" == "home net" ]] && printf '%s\n' "$SECRET" ;;
esac
exit 0
STUB
chmod +x "$work/bin/nmcli"
# Records its argv and its stdin separately, so the test can prove the secret
# arrived on stdin and never on the command line -- argv is world-readable
# through /proc while a process runs.
cat >"$work/bin/qrencode" <<'STUB'
#!/usr/bin/env bash
printf '%s\n' "$*" >>"$QRENCODE_ARGV_LOG"
out=""
prev=""
for arg in "$@"; do
[[ "$prev" == "-o" ]] && out="$arg"
prev="$arg"
done
cat >"$QRENCODE_STDIN_LOG"
printf 'fake-png' >"$out"
exit 0
STUB
chmod +x "$work/bin/qrencode"
export QRENCODE_ARGV_LOG="$work/argv.log"
export QRENCODE_STDIN_LOG="$work/stdin.log"
: >"$QRENCODE_ARGV_LOG"
: >"$QRENCODE_STDIN_LOG"
run() { PATH="$work/bin:$PATH" XDG_RUNTIME_DIR="$work/run" "$helper" "$@"; }
# ── Listing distinguishes shareable from not ────────────────────────────────
out="$(run list)"
jq -e . >/dev/null 2>&1 <<<"$out" || fail "list did not emit JSON: $out"
[[ "$(jq -r '.networks | length' <<<"$out")" == "2" ]] \
|| fail "only wireless connections belong in the list: $out"
jq -e '.networks[] | select(.name == "home net") | .shareable == true' >/dev/null <<<"$out" \
|| fail "a network with a passphrase must be shareable: $out"
jq -e '.networks[] | select(.name == "work-eap") | .shareable == false' >/dev/null <<<"$out" \
|| fail "an enterprise network has no passphrase, so a QR code for it cannot work: $out"
# ── The payload ─────────────────────────────────────────────────────────────
path="$(run qr 'home net' | jq -r .path)"
[[ -n "$path" && -e "$path" ]] || fail 'no image was produced'
payload="$(cat "$QRENCODE_STDIN_LOG")"
grep -q "P:$SECRET;" <<<"$payload" \
|| fail 'the passphrase did not reach the payload intact'
# The SSID is "home;net": unescaped, the semicolon ends the S: field early and
# the code describes a different network.
grep -qF 'S:home\;net;' <<<"$payload" \
|| fail "a reserved character in the SSID was not escaped: $payload"
[[ "$(wc -l <"$QRENCODE_STDIN_LOG")" == "0" ]] \
|| fail "the payload contains a newline; nmcli's trailing newline must be stripped: $(cat -A "$QRENCODE_STDIN_LOG")"
grep -q ';;$' <<<"$payload" || fail "the WIFI: URI must be terminated with ;;: $payload"
# ── The secret must never appear in argv ────────────────────────────────────
grep -q "$SECRET" "$QRENCODE_ARGV_LOG" \
&& fail 'the passphrase was passed as a command-line argument, where /proc exposes it to every process on the machine'
# ── The image and its directory must not be readable by others ──────────────
[[ "$(stat -c '%a' "$path")" == "600" ]] \
|| fail "the QR image is mode $(stat -c '%a' "$path"); it contains a password"
[[ "$(stat -c '%a' "$(dirname "$path")")" == "700" ]] \
|| fail "the directory holding QR images is mode $(stat -c '%a' "$(dirname "$path")")"
# ── No temporary payload files may survive ──────────────────────────────────
leftovers="$(find "$work/run" -name 'payload.*' | wc -l)"
[[ "$leftovers" == "0" ]] \
|| fail "$leftovers temporary payload file(s) containing the passphrase were left behind"
# ── An unknown network is an error, not an empty image ──────────────────────
out="$(run qr 'no-such-network')"
jq -e '.path == "" and .error != ""' >/dev/null <<<"$out" \
|| fail "an unknown network must be reported: $out"
printf 'wifi qr contract: PASS\n'