OpenAI ships an official Linux RPM now, so the community wrapper goes away:
`panama app chatgpt-desktop` built codex-desktop from the upstream macOS disk
image and ran a local rebuild daemon to keep it current, and the official
package comes from a repository that upgrades with everything else. The app
file, the help example and the dock's pinned id all move over, and a migration
replaces the build on machines that already have it -- official package on
before the community one comes off, so a failure part-way still leaves an app.
The install itself does not follow upstream's instructions. Those are "download
this RPM and install it", and the RPM's own root scriptlet is what writes the
repository file and drops the signing key into /etc/pki/rpm-gpg -- so root runs
an unverified download and then learns from it what to trust. That is the shape
the repository audit forbids: no network response is executed as root without a
verified digest or signature first.
OpenAI publishes no key and no fingerprint anywhere an install could fetch and
check them, so the key is pinned here instead. setup/keys/ carries it and says
where it came from, including the honest part -- this is trust established on
first use and then held, not trust verified against the publisher. setup/lib/
chatgpt-package verifies that copy's fingerprint, installs it, and writes the
repository with gpgcheck and repo_gpgcheck on before anything is installed, so
dnf checks the metadata signature and the package signature itself. It is byte
for byte the repository the scriptlet would have written, so nothing churns
afterwards, and every later upgrade goes through the same key. Both callers use
it; a verification failure skips ChatGPT rather than installing it anyway.
The contract proves the pinned key is the key the library names, that a
missing, unreadable or mismatched key writes nothing at all, that what is
written actually turns the checks on, and that neither caller hands root a
downloaded RPM.
Claude-Session: https://claude.ai/code/session_017zzbtfnMLoYrB8WesqANFY
A machine's role is now the interview's first question and the one answer
Panama records. Servers get the same shell minus the screen: core packages,
nvm, Bun, Claude Code and Codex (desktops get Codex too), linger, rootless
ports from 80, firewalld, the nginx-bridge network, and a nightly image
updater that replaced watchtower for cause.
server/containers/ carries junior's 23 compose services -- secrets moved to
per-machine .env files that never enter this public repo, every transformed
compose proven to render byte-identical to what is live. 'panama server'
enables, disables and relinks them; nothing here restarts a running service.
'boot --server' walks a fresh VPS from its root login to a normal install.
Five new contracts pin the secrets rule, the catalog's shape, panama-server's
behavior, the role plumbing, and the dotfile classification.
Claude-Session: https://claude.ai/code/session_01NU5JGiN3JfzqrLQB6wmJ1E
Desktop & Dock becomes Shell — Bar, Dock, Control Center, Tiling,
Workspaces — the home for everything Quickshell draws. The settings-
management cluster moves to System as Sync & Backup, Appearance's
Shell tab dissolves, and 24-hour time finally lives on Date & Time,
which always owned it.
The bar gets what it never had: a way to survive the wallpaper. A
second neutral text family (follow theme, or forced light or dark),
a one-layer shadow under every glyph, and a gradient scrim for
wallpapers nothing else survives — all off by default, pixel-identical
until asked. Widgets earn toggles (weather, media, clipboard, calendar
countdown), the vitals cluster stops leaving a dead pill behind, and
Control Center's sections learn to step aside.
The dock graduates from MVP: a context menu with window rows, pin,
unpin, quit and new-window; scroll an icon to cycle its windows; drag
to reorder on the dock itself; hover previews with one-shot captures;
and "Add App to Dock" in the launcher. Three real bugs died en route —
menus that slid away with the autohide, a readonly-property crash on
every menu open, and a drag that drifted half a slot per icon on side
docks. The pinned-apps editor in Settings becomes a drag strip.
166 contracts; the full suite is green except two live display and
switcher tests that cannot run behind a locked session — re-verified
on unlock.
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
Appearance now opens on Themes: light and dark side by side, each
remembering its own choice, over galleries of ten shipped themes —
Tokyo Moon and Day joined by Moon Rose, Catppuccin, Nord, Gruvbox and
Everforest in both modes. A theme is a complete palette: the catalog
lives in themes.json, Theme.qml reads every color token from the
active record, and one render pipeline carries it to kitty, tmux,
btop, GTK, Vicinae, Firefox's chrome, and the lock screen. The Theme
editor builds new ones from four wells — wheel, hex, or eyedropper —
with derived surfaces, a saturation slider, debounced fine-tune, and
effects that save with the theme. Custom edits finally keep GNOME's
accent, kitty's border, and hyprlock in sync.
Wallpapers can be video: mpvpaper per output, hardware-decoded, muted
and looped, supervised and respawned. Panama owns the pausing — games,
battery, and a bar pill for right now — because the compositor
rebuilds full-screen blur for every frame a video wallpaper draws.
The lock screen gets a still frame.
Titlebars stop lying. GNOME apps get close-only on your chosen side,
the maximize and double-click settings are gone, the Settings window
obeys the same rules, and its titlebar can be turned off entirely.
Typography becomes five labeled dropdowns instead of a wall of
samples.
Contracts updated and written throughout (165 now); per the redesign
workflow none were executed — the full sweep runs once at the end.
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
A clean Fedora Workstation ships ffmpeg-free, and desktop-packages listed
ffmpeg (RPM Fusion). Those two conflict rather than add: dnf will not erase
the preinstalled ffmpeg-free to make room, so the WHOLE desktop transaction
failed to resolve and no desktop package installed. The codec section already
does the trade correctly -- `dnf swap ffmpeg-free ffmpeg --allowerasing` --
so ffmpeg simply does not belong in the eager list. A machine that already
had ffmpeg (every one this repo was ever run on) sailed past this; the first
genuinely fresh Workstation install is what surfaced it.
Also: report_missing false-warned that awk was unavailable. rpm -q
--whatprovides matches a package's named provides, but awk is provided as the
file path /usr/bin/awk (by gawk), which that query misses. Fall back to
command -v so a capability provided by path is not reported as missing.
Found by a fresh-VM certification run, which is exactly the failure mode a
re-run on an already-configured machine cannot reproduce.
Claude-Session: https://claude.ai/code/session_01Epx9ZC1gwm81K3jm9x9CKh
The audit's fourth tier -- what a Framework owner reaches for in the first
week and found missing.
The power button stops being an instant, unconfirmed poweroff: a shipped
logind drop-in tells the daemon to stand down and the compositor binds the
key to the power menu, the way GNOME turns it into a question. Holding it
still hard-cuts through firmware. change-settings restarts logind so the
change applies without waiting for a boot, and the Power page says what the
button does now.
The function row fills in: F10 (XF86RFKill) toggles airplane mode through a
new panama-osd verb that blocks or unblocks every radio and says which way it
went; F9 (XF86Display) opens the Displays page, the honest action until
mirroring exists. And the lid becomes a switch bind: closing a docked lid
turns the internal panel off so nothing renders inside a closed shell and no
workspace strands on an invisible output, and opening it restores the panel
with the mode and scale chosen in Settings. panama-lid owns both decisions;
undocked machines suspend via logind before any of it matters.
The battery gets an endgame. On battery the screen dims to 30% two-thirds of
the way to blanking -- GNOME's single largest idle battery saver -- and
restores exactly the level it saved. At the urgent threshold the machine
suspends after a fifteen-second grace, cancelled by plugging in, because a
suspend preserves the session for days and a hard cut at 0% preserves
nothing; "Only warn" remains a choice on the Power page. Hibernate joins the
power menu, but only where logind answers CanHibernate with yes -- an entry
that fails silently is worse than none.
And brightness stops being two code paths: the Displays page now embeds the
same control the quick-settings panel uses, so the built-in backlight and
DDC/CI monitors share one surface that withdraws itself where neither
exists. The lid contract narrows to what its principle protects -- a
HandleLidSwitch drop-in -- so deliberate policy for other keys can ship.
Claude-Session: https://claude.ai/code/session_01Epx9ZC1gwm81K3jm9x9CKh
The audit's third tier: everything between this installer and a fresh machine
it has never met.
The one path that could cost a person their display: the interview probes
Secure Boot with mokutil, which install-packages had not installed yet, so on
a minimal base the MOK question silently never fired -- and install-hardware
still installed akmod-nvidia and blacklisted nouveau, arming a reboot into an
unloadable driver with its fallback disabled. The probe tools (pciutils,
mokutil, fwupd) now bootstrap beside gum, and install-hardware re-checks
Secure Boot for itself and refuses the driver rather than the display.
Secrets leave the checkout: the personal environment moves to
~/.config/panama/env at mode 600 by migration, and .bashrc sources it with a
permission check that quietly re-tightens drift. change-settings no longer
overwrites /etc/dnf/dnf.conf -- two performance keys are set additively, the
defaultyes=True that made every `dnf remove` treat Enter as yes is gone, and
a migration strips it from machines that already received it.
Package installation survives the world changing: the initial and desktop
lists run with --skip-unavailable and a report_missing pass that names what
was skipped (resolved through --whatprovides, so capability names like awk
do not cry wolf); the openh264, appstream and core-group extras go through
soft; RustDesk resolves its RPM for the machine's own architecture; and the
Claude Desktop repository script is fetched to a kept file and run, never
piped from the network into root.
The hardware predicates stop guessing: a wireless mouse's scope=Device
battery no longer turns a tower into a laptop, USB-PD-only machines read
their power state from the battery's own status instead of being permanently
"on AC", the lid falls back to logind's LidClosed where ACPI is silent, and
charge limits reach every pack of a two-battery machine in one authorization
-- with the reported percentage summed across packs.
And the parsers stop assuming this machine: snapper is read through
--machine-readable csv with named columns instead of a localized box-drawing
table, and reports whether snapshots are even possible so ext4 and
unconfigured-btrfs stop looking identical; fprintd is parsed under LC_ALL=C;
the hypridle drop-in resolves the binary it points at; the recorder's render
node became an "auto" token resolved at record time; update-grub writes the
config its firmware actually boots; the nvm prompt hook and the SSH tmux
takeover are guarded; hipblas and rocm-opencl move to an opt-in gpu-compute
category; and the two interactive python tools' libraries are declared.
Claude-Session: https://claude.ai/code/session_01Epx9ZC1gwm81K3jm9x9CKh
The audit's second tier: values that were measurements of the author's
desktop, shipped to every machine as if they were defaults.
Settings greeted every human as Gabriel; it now greets whoever
accountsservice says is signed in, and nobody when it says nothing. The
weather shipped his home coordinates and confidently reported his forecast
anywhere on earth; it now ships unset, fetches nothing until a location is
chosen, and the location row says so. The GTK bookmarks carried seven
/home/gib paths and his file server into every file dialog; they are now
generated per machine from a template and gitignored -- Nautilus edits the
instance freely, the way settings.ini already worked one file over. Web
search routed through his personal bang redirector; the engine is now the
webSearchUrl preference with a DuckDuckGo default, read by both the script
command and the suggestions extension, which the launcher-search contract
already pins to one another. The GPU vitals path defaulted to his card1 and
lost the readout on any machine enumerated differently; a machine with
exactly one GPU now adopts it. And the Containers and Snapshots pages hide
once a scan proves their backing stack absent, instead of rendering
permanently empty on machines that never had podman or snapper.
Lesser residue swept in the same pass: the DP-2 hyprpaper block one machine
needed, the author's username-typo expansions (moved to his personal seed in
user/, where personal content belongs), a capture fallback into /home/gib,
and a parity table asserting one machine's hardware as fact.
Claude-Session: https://claude.ai/code/session_01Epx9ZC1gwm81K3jm9x9CKh
The audit's first tier, in one change: every case found where the interface
asserted something the system did not do.
Twenty-one compositor-owned preferences -- the whole Mouse & Touchpad page,
plus window layout, snapping, dim-inactive and the magnifier -- had the live
half (hyprctl eval) and not the config-time half, so they quietly reverted on
every hyprctl reload. All 70 hypr-backed keys now have a prefs.get() in the
Lua, and hypr-prefs-contract pins both the presence and that the Lua fallback
equals the schema default, which is how the touchpad page misreported natural
scrolling on first boot.
The idle generator fell back from unwritten battery keys to the AC values
while the Power page displayed the schema defaults: a fresh laptop showed
"suspend at 20 minutes" and generated no suspend listener, then discharged to
zero in a bag. Unwritten keys now use the defaults the page shows
(idle-defaults-contract pins generator to schema; idle-config-contract
re-pinned to the new rule with the tradeoff recorded), and change-settings
enables managed idle on any machine with a battery -- without starting
hypridle in whatever session the installer runs under.
The per-app lock-screen notification switches wrote fields nothing read:
hyprlock cannot render notifications. Removed, with the rule model shrunk to
{enabled}, stale stored fields dropped at normalization, and the contract now
forbidding the page from growing lock-screen switches it cannot honor.
The battery warning thresholds were searchable, documented as "Found on
Power & Lock", and rendered nowhere -- and crossing the low threshold changed
only a glyph's color. Both sliders now exist where search was already sending
people, and low battery publishes a real notification at important priority.
Three handoffs opened GNOME panels that are inert in a Hyprland session. The
keyboard handoff is gone (that panel writes gsettings nothing here reads, and
the working controls sat on the same page); Connectivity gains a Wi-Fi row
that opens GNOME's actual Wi-Fi panel -- hidden SSIDs and 802.1X finally have
a road -- beside the network row that legitimately drives NetworkManager; the
universal-access handoff is gone, its few working toggles being controls this
app already owns. And the accessibility page now gives the true reason sticky
keys are missing: each Wayland compositor implements its own and Hyprland
does not yet -- not "an X11 feature with no Wayland equivalent," which sent
people to the wrong conclusion about the platform.
Claude-Session: https://claude.ai/code/session_01Epx9ZC1gwm81K3jm9x9CKh
Three surfaces the first laptop install showed were missing.
The bar's battery icon gets an optional exact number beside it -- GNOME's
"Show Battery Percentage", off by default for GNOME's reason, one color with
the icon so it reads as one indicator.
The Power page says what closing the lid does. The policy already existed
(LidPolicy holds a suspend inhibitor while an external display is connected)
but was surfaced nowhere, so the machine's most physical behavior was
undiscoverable -- and the deliberate absence of an override deserves stating
rather than leaving someone to hunt for a switch that does not exist.
And the Users page grows a Fingerprint card, because fingerprint login is two
systems that fail silently when they disagree: fprintd holds the enrolled
prints, authselect decides whether PAM ever asks the reader. This machine
arrived with a finger enrolled from its GNOME days and with-fingerprint off,
which reads as "the reader is broken". The card shows both facts, flips the
authselect feature through polkit with a stated reason, and hands enrollment
to GNOME's Users panel, which owns the only good capture dialog -- a named
exception in the handoff contract. Everything through scripts/panama-fingerprint,
pinned by a stub-driven contract.
Claude-Session: https://claude.ai/code/session_01Epx9ZC1gwm81K3jm9x9CKh
Two ways a fresh machine's unattended run could still stall. The stages call
sudo dozens of times across twenty-plus minutes, and the timestamp expires
five minutes after whichever call came last -- so any single dnf step that
outlasted it turned the next stage into a password prompt nobody was there to
answer. ./install now authenticates once, right after the interview, and a
refresher holds the timestamp for exactly as long as the script lives;
cleanup() kills it on every exit path, so nothing outlives the install with
ambient credentials.
And `dnf copr` comes from dnf5-plugins, which a machine that has used copr
carries and a fresh Workstation does not -- a missing plugin there would have
cost the entire desktop. Declared in initial-packages, where the repositories
stage can rely on it.
Claude-Session: https://claude.ai/code/session_01Epx9ZC1gwm81K3jm9x9CKh
Two more things the Framework's install surfaced.
gstreamer1-plugin-fmp4 and gstreamer1-plugin-mp4 no longer exist in any repo
this configuration enables — not Fedora 44, not RPM Fusion, not the Hyprland
COPR. Machines installed on older Fedora carry them as orphans, which is why
the list looked fine from one. dnf5 refuses a whole transaction over one
missing name, and desktop-packages runs above the Hyprland block, so two dead
codec entries cost the entire desktop.
The solopasha migration preferred panama-sudo, and pkexec with no polkit agent
to answer it does not fail — it waits forever, which held an unattended
./install hostage at the one stage that promised never to ask. The stated
reason is now reserved for a terminal someone is sitting at; a non-interactive
run takes plain sudo, which either succeeds quietly or fails fast enough for
panama-migrate to retry at the next login, as it promises.
Verified by running install-packages end to end on the machine that broke it:
Terra guard holds, desktop packages resolve, Hyprland 0.56.2 lands, and the
stage reaches its stepped-over report. All 22 setup contracts pass.
Claude-Session: https://claude.ai/code/session_01Epx9ZC1gwm81K3jm9x9CKh
soft() swallowed stderr, which hid both halves of what matters: the dnf error
explaining the failure, and the sudo password prompt on a machine that asks for
one. stdout only.
Two bugs, one story: ./install on a machine that had been half set up before
produced no Hyprland at all, and said so in one line among twenty minutes of
scrollback.
Terra bootstraps itself with --repofrompath, which defines a throwaway repo id
just long enough to install terra-release. Run it again on a machine that
already has terra-release and dnf5 refuses the whole transaction -- the
throwaway id collides with the real one. That step sits above everything, so
set -e ended the stage before a single package was considered. It is skipped
now when terra-release is already installed.
The rest is the reason one failed repo cost the desktop. Hyprland was installed
near the bottom of the stage, below a codec swap, two group updates and a
GStreamer glob, any one of which can fail for reasons outside this repository.
It now installs directly after the packages it needs and before anything
optional, and everything fragile below it runs through a soft helper that logs
and continues rather than ending the run. What was stepped over is listed at
the end, because tolerating a failure only beats aborting on it if somebody is
told.
A missing Hyprland is still fatal, and now says so in words.
Also removes the leftover disabled solopasha/hyprland COPR, which would mix
with lionheartp's the moment anyone enabled it while debugging.
Fixes the usage widget reading 1500%: the endpoint reports percentages, not
0..1 fractions. Clamped as well, and the widget answers a click now -- it set
interactive:false, which disables the mouse area its own handler needed.
Agent instructions, skills, SSH host aliases and expansion triggers are worth
having identical on every machine one person owns, and belong in none of the
shared configuration. They live in user/ now, with a manifest saying where each
piece goes and a link-user stage that puts it there.
That stage does nothing unless the machine said yes. Somebody who clones Panama
to try the desktop keeps their own ~/.claude/CLAUDE.md exactly where it was;
the question names the destinations and defaults to no. Anything displaced goes
to config/old rather than being deleted.
~/.claude/CLAUDE.md and ~/.codex/AGENTS.md were byte-identical copies of one
file, which is the drift this exists to prevent.
Also adds the vitals toggles for the battery and Claude usage readouts, which
had preferences and no way to reach them.
Two of Section F.
Hooks are the pressure valve. "Can Panama also do X when the theme
changes" is now a five-line file in ~/.config/panama/hooks rather than
a fork, a feature request, or a patch somebody rebases forever. Each
name takes a single file and a .d directory so several things can react
without fighting over one, and a broken hook is reported and stepped
over: somebody's script must never cost a theme change, an upgrade or a
login. Wired at theme-set, post-upgrade and post-migrate. This is the
thirty-line version of the plugin host the upstream ledger defers, and
it has no API to keep stable beyond "we will run your script and tell
you what happened".
Testing it caught a real bug the reading would not have: run_one
captured the script path but never shifted it off, so every hook got
its own filename as $1 and the real arguments arrived one place late. A
hook reading $1 as the colour scheme got a path.
The crash watcher notices when a program dumps core and says so. Under
GNOME, ABRT does this; here nothing did, and applications died silently,
which is most of how "Linux is flaky" gets earned.
Once per program per session is the entire design, not a nicety. This
machine's portal backend crashes between eleven and sixty times a day,
and a notification per crash would be one every few minutes for
something nobody can act on. The first is news; the fortieth is why
people turn notifications off. The health page keeps the running count.
It waits for the notification server before reporting, because the
crash most worth hearing about is the one that took the shell with it,
and it names the executable rather than the kernel's comm field, which
truncates at fifteen characters. Verified against real segfaults.
Right-click a file to send it to your phone, or a video to make it
smaller. macOS has both behind the Share sheet and Quick Actions;
Windows has "Send to"; a stock Linux file manager has neither, and the
usual answer for the second one is a web uploader or an ffmpeg
incantation looked up again every time.
Neither adds machinery. Sending reuses panama-kdeconnect, the same
helper the Home & Phone page and quick settings already drive, so
there is one way to talk to a phone rather than two. The entry appears
only when a phone is actually reachable: an item that is present and
fails is worse than one that is absent, because the absence explains
itself.
The transcoder's two rules are both about not losing work. It never
writes to its input, and it never writes over an earlier output -- a
second run produces -2 rather than eating the first result. Verified
against a real encode: 1920x1080 became 854x480, with an even width
because H.264 rejects an odd one at the very end of a long encode,
which is the worst possible moment to find out.
Menus decide by mime type rather than extension, act on one file at a
time, and refuse anything that is not a local path. nautilus-python
turned out to be declared already; it now says it carries Panama's own
extensions too.
The launcher's Copy Password command, built alone and last as the plan
required, because every line of it is the security design: the secret
travels rbw to wl-copy through a pipe -- never argv, never a file --
and the copy carries wl-clipboard's --sensitive hint, which vicinae's
clipboard history documents it ignores. That claim was not taken on
faith: a plain probe landed in the live history database and a
sensitive one did not, before any of this was written. A transient
timer clears the clipboard after thirty seconds. An unconfigured rbw
gets a setup message; a vault that locks between list and get gets an
honest failure instead of an empty copy claiming success. rbw joins
desktop-packages, and the contract pins the whole journey with a stub
vault, including that the secret never appears on a command line.
The :fn/:em espanso triggers hardcoded one person's name and email in a
match file shipped to every user -- a misfire that lands inside their
own prose, where they are least likely to notice. setup-identity now
seeds match/identity.yml, per-machine and gitignored, from the same
interview answers that already configure git.
vim aliased to vimx, which no list installed; bat invoked by its Debian
name, which Fedora does not ship; sunshine restarted by an alias when
nothing here installs sunshine. vim-X11 is now declared so vimx exists,
ff previews with bat, and the sunshine alias moved to the gitignored
env of the one machine that has it.
The comment above the prompt says the password never reaches a command
line; --generate-hash="$first" put it on one, world-readable in
/proc/<pid>/cmdline while mokutil ran. It now arrives on stdin, and the
hash is taken from the last output line because mokutil prints its
prompts to stdout too.
install and link-dotfiles treat it as a default; install-packages and
change-settings overwrote an exported override, so a clone anywhere but
~/.local/share/Panama sourced the extras catalog from a path that does
not exist and copied config/copy from the wrong tree.
Four installer bugs, all in the space between exit codes and intent:
- A flatpak-only extras category -- most of them -- died at the grep
that filters out its dnf half, because grep exits 1 on zero matches
and set -e read that as failure. sed deletes lines without editorial
comment. The extras contract now runs a flatpak-only category under
the installer's own strict options so this stays fixed.
- A rate-limited GitHub API call aborted the whole package stage while
resolving the RustDesk URL, even though the empty-result fallback was
sitting right below it. The pipeline is now guarded so the fallback
is reachable.
- Ctrl-C did not stop the install: the INT trap ran cleanup and bash
carried on with the remaining stages, MOK enrollment and firmware
included. INT and TERM now exit explicitly; cleanup rides EXIT.
- change-settings and link-dotfiles ran without set -e, so a failed
copy over / or a failed symlink fell through to guarded no-ops and
the stage reported success. Turning strictness on immediately caught
what it had been hiding: link-dotfiles never created ~/.config, so on
a truly fresh HOME every symlink was failing silently.
espanso-wayland was installed and its config linked, and then nothing
anywhere launched it -- the RPM ships no unit, autostart.lua started
every session daemon except this one, and the seed machine only worked
because the service had once been registered by hand. change-settings
now runs `espanso service register` (upstream's own mechanism, and
unlike hypridle it conflicts with nothing GNOME runs), and autostart.lua
starts the unit so the first Hyprland login after a fresh install
expands text rather than the second.
No flatpak would launch. Every one of them failed in bwrap with "Can't find
source path /run/user/1000/doc/by-app/<id>", because xdg-document-portal's fuse
mount was gone -- /run/user/1000/doc was a plain empty directory. That mount is
bound into every sandbox bwrap builds, so losing it takes out all 34 flatpaks at
once, never a subset.
It had been gone for three days. The shipped unit is Type=dbus with Restart=no,
so nothing retries it on failure: after it exited 21 it came back only because a
flatpak called its bus name two seconds later, and that activation landed on the
dying instance's mountpoint and came up with no mount at all. systemctl reports
active (running) either way -- the fusermount3 helper is still sitting there as a
child, in this case for two and a half days without ever completing the mount.
Nothing running notices, which is what makes it so quiet. A sandbox needs the
mount only while it is being constructed, so everything already open keeps
working and the symptom arrives whenever you next open a flatpak you had not
opened yet. Here that was three days later, and it presented as "gearlever is
missing" -- an application that was installed, healthy, and entirely innocent.
Two changes, because there are two failures: it does not recover, and nothing
says so.
The drop-in clears the mountpoint before each start, so an activation that
follows a crash lands on clean ground. ExecStartPre rather than ExecStopPost
precisely because nothing restarts this unit -- the next start is whenever
something next wants it, and that is the moment that has to be safe. `-` because
a clean start has nothing to unmount and fusermount3 exits 1 saying so.
/etc/systemd/user rather than a per-user drop-in so it covers every account, and
change-settings reloads the user manager so it applies without a re-login.
The check asks the mount table whether $XDG_RUNTIME_DIR/doc is mounted
fuse.portal. Deliberately not a service probe, and deliberately not folded into
desktop.portals: that one asks about xdg-desktop-portal, a different service
which was up and healthy throughout. Service state is exactly the question that
lied here, so asking it again in a new place would have been no check at all.
Warning carries a restart repair, verified end to end rather than assumed.
The mount table is injectable, like every other path this script reads, so the
contract covers unmounted, wrong-filesystem-at-the-right-path, and unreadable
against written fixtures rather than against whatever /proc happens to say --
coupling the test to this machine's live flatpak state is the same mistake in
miniature. Stubbing the check to always return ok fails the contract, which was
confirmed rather than hoped.
What is not fixed is the crash itself: one occurrence, and restarting the
service to get the desktop working destroyed the evidence. The exit was 21, it
landed 21 seconds after xdg-desktop-portal restarted, and that is one sample and
not a theory. What this buys is that the next one is a two-second blip the
doctor names, rather than three silent days.
Second time for this bug. ac231ee found the same dead mount in August while
chasing "can't open Bitwarden", fixed it by hand, and recorded it as "not a
config issue, so nothing to commit there". That judgement is why it was paid for
twice, and it is the part most worth writing down.
It was `panama app claude-desktop`: a source build, because nothing packaged it.
Upstream publishes an RPM repository now, so the exception shrinks to adding
that repository -- and the result upgrades with `dnf upgrade` along with
everything else instead of needing a slow rebuild every time a version ships.
That moves it out of setup/apps/ and into the third-party section of
install-packages, beside Bun, Claude Code and RustDesk. It also means it is part
of ./install for the first time. The reason it was kept out was the build: slow,
wants the network throughout, and fails on an upstream that moved, which is the
failure the interview exists to prevent. A dnf install is none of those, and the
default dock pins com.anthropic.Claude, so a fresh machine was shipping a pin
for something the install had never put there.
The repository is added with upstream's own setup script rather than a .repo
file written out here. A baseurl copied into this repository is a pin by another
name; the script is the part upstream keeps correct. Both halves are skipped
when already present, so a re-run costs nothing.
The desktop entry id does not change -- it is still com.anthropic.Claude -- so
the dock pin and the notification rules carry over untouched. The new package
drops the nodejs dependency, which is what used to hide the missing-npm bug the
launcher search contract guards; its comment said "depends" in the present
tense and now says what actually happened.
The search extension is compiled, and the stage that compiles it checked for npm
and skipped quietly when it found none. On this machine it always found one --
but only because Claude Desktop depends on nodejs and dragged a system npm in.
Nowhere else would. Node moved to nvm when the shell config turned out to have
been assuming it for months, and nvm is a shell function in a file only an
interactive shell sources; a stage is not one. So a fresh install would have set
up the launcher, printed one line about extensions not being built, and left
somebody wondering why typing in it suggested nothing.
The stage sources nvm before looking, and the contract pins that it does --
checking for npm is not the same as being able to find it.
Claude-Session: https://claude.ai/code/session_01Q84axqUE5inJhf5Jz9CFy1
Super+D holds the microphone open, releasing it transcribes on the GPU and
types the result wherever the cursor is. Roughly 150ms for a normal utterance
once the model is resident, measured rather than hoped for.
Getting there meant discarding two approaches. Fedora 44 cannot install any
GPU-capable Whisper for Python -- openai-whisper needs a numba that needs an
llvmlite that does not exist for 3.14, and faster-whisper needs a ctranslate2
nobody packaged. The whisper-cpp package IS built with HIP but ships libraries
with no binary and no bindings, and hand-writing ctypes for a large by-value
struct is a segfault waiting for a version bump. So a container, as suggested.
Vulkan rather than ROCm, and upstream's image rather than one built here. ROCm
is seven gigabytes and serves AMD alone; Vulkan compute runs on the AMD, Intel
and NVIDIA machines this config is used on, in a twentieth of the space. The
Vulkan tag already contains whisper-server, so there is no Containerfile to keep
working -- an earlier draft of this commit had one, and it was strictly worse.
Two bugs found by using it rather than by reading it. Whisper describes silence
as the literal text "[BLANK_AUDIO]", and the first working version pasted that
string into the clipboard; a transcription that is nothing but such markers is
now discarded. And the server answers with a line per segment, which typed into
a window is an Enter press -- sending the half-written message, submitting the
form. Whitespace is collapsed to one line.
Neither the image nor the model is installed by ./install. Together they are
over two gigabytes that want the network, and Settings offers both as one
action instead. Nothing starts at login either: whisper-server holds the model
from the moment it starts, so the first press of the key is what brings it up.
The contract pins both text bugs, that the server stays on loopback, and that it
does not start at login. Reverting the [BLANK_AUDIO] guard did not fail it at
first -- the check was still correct, it had simply stopped being called -- so
it now checks the call site too.
Claude-Session: https://claude.ai/code/session_01Q84axqUE5inJhf5Jz9CFy1
Four things a Hyprland desktop can do that this one was not.
Searching from the launcher needed no launcher work at all: Vicinae already
models it, so this is a script command with one percent-encoded argument. Make
it the fallback command and anything typed that matches nothing else offers to
search it. Bangs come free -- they are a property of where the query is sent,
not of the launcher -- so !yt reaches YouTube without a line of bang parsing.
Suggestions could not be a script command. They need a view that reacts as you
type, which is an extension: TypeScript, compiled, querying the same endpoint
Firefox's address bar uses. It debounces, and aborts the request in flight on
every keystroke -- typing is faster than the network, and an older answer
landing after a newer one leaves the list describing a query that is no longer
on screen. A bang skips suggestions entirely, because Google has no useful
guesses about "!yt".
The engine is now written down twice, once in each. The contract pins that they
agree, since searching from the fallback and searching from the suggestions
reaching different places is the kind of wrong that looks fine.
Gestures mirror GNOME: three fingers sideways for workspaces, up for the
overview, down to dismiss it. Open and close rather than toggle both ways --
toggling means swiping up from an open overview closes it, which is not what the
fingers meant. Hyprland reads gesture registrations at startup so they cannot be
a setting, but distance and direction can be, and are.
Window swallowing is off by default and a preference like every other misc
setting here. A terminal that vanishes when you did not ask for it is confusing
rather than broken, which is worse.
Claude-Session: https://claude.ai/code/session_01Q84axqUE5inJhf5Jz9CFy1
config/bash/shell sources /etc/profile.d/nvm.sh, switches Node per project from
.nvmrc, and puts PNPM_HOME on PATH. None of it worked on a fresh machine. nvm
was never installed -- it is a Terra package, present here since before Panama
-- and the source was unconditional, so every shell on a new box opened with an
error before it got as far as failing to find nvm.
That is the second instance of the same bug. $HOME/.cargo/env was the first, and
fixing it one file at a time is why this one survived: the dependency contract
scanned setup/scripts, bin and the quickshell helpers, but never config/bash --
the one place in this repository whose entire job is to name tools and source
the files that provide them.
So it scans it now, and checks the shape rather than the instance: a literal
path sourced without testing it exists is a finding, wherever it appears. It
found the nvm line, and authselect behind the fingerprint aliases.
Node and pnpm move to nvm with it. They were declared as dnf packages while the
machine ran them from ~/.nvm, which is not a preference so much as a
contradiction -- a system Node earlier on PATH wins every `nvm use`, so the
per-project switching this shell config sets up could never have worked. nvm
install --lts, then pnpm inside it, so pnpm travels with the Node version it
belongs to instead of outliving it.
Claude-Session: https://claude.ai/code/session_01Q84axqUE5inJhf5Jz9CFy1
The catalog held fourteen applications. This machine runs thirty-four flatpaks,
so most of what is actually used had no way to be installed from here at all --
Zoom, Slack, Obsidian, Spotify, LibreOffice, OBS and its sixteen plugins.
So the catalog is seeded from the machine, and `panama apps` opens it: pick a
category, tick what you want, install just those. ./install still offers the
same catalog as whole categories, because during a first install you want coarse
and fast. Both read setup/lib/extras-catalog. Two parsers would eventually
disagree about what a category contains, and the one that disagreed quietly
would be the one that runs unattended.
Two pieces of syntax earn their keep. A `| Name` suffix gives the menu something
readable, since com.obsproject.Studio is not a name anybody wants to pick from a
list. An indented line belongs to the entry above it, which is how OBS carries
its plugins as one thing to tick rather than seventeen -- they are extensions of
the flatpak, useless alone.
That is also why creative moved from dnf to Flathub: the plugins attach only to
the flatpak, so the dnf build cannot have them. The rest of the category
followed rather than leave one machine with GIMP from dnf and its neighbour from
Flathub.
The contract now reads the catalog through the same parser instead of keeping a
third idea of the format, and checks the two things this syntax can break
silently: a label leaking into an install command, and a bundle that installs
the application without its plugins. It caught a typo in the Pixelorama id on
the first run.
It also got slow enough to be worth fixing -- fifty-one names, each its own
network call. One bulk query per manager took it from minutes to four seconds.
That query needs `flatpak remote-ls --all`: without it, end-of-life applications
are hidden and read as missing, which reported yuzu as gone from Flathub when it
installs perfectly well.
Claude-Session: https://claude.ai/code/session_01Q84axqUE5inJhf5Jz9CFy1