8 Commits
Author SHA1 Message Date
Gabriel Brown 4c77bc2f61 Decide whether the other screens join in on workspaces
GNOME's Multitasking panel asked one workspace question worth reproducing, and
it is not which workspace goes on which screen. It is whether the second screen
participates at all: workspaces on the primary display only, or each screen with
its own. Ten rows of per-workspace assignment would be more powerful and worse.

Off is Hyprland's own behaviour and emits nothing. On pins workspaces 1 to 10 --
however many ALT+1..ALT+0 actually reach, read from keybinds.lua rather than
written down twice -- to whichever output is recorded as primary. With no
primary recorded, nothing is pinned: guessing one would move every workspace
onto whichever output happened to sort first, and this machine is in exactly
that state.

Applying is a reload, which is the part that shaped the design. Hyprland reads
workspace rules at config time and will not remove one afterwards -- a rule
written with an empty monitor keeps its old binding, which was checked rather
than assumed. Only a reload clears them, so the config is the only honest source
and the page cannot pretend a change has landed before one happens. Hence a
service that reads `hyprctl workspacerules` back rather than inferring success
from having written the preference, and a Reload row that exists only while the
two disagree.

Verified end to end against the live compositor and put back: off emits nothing,
on emits ten rules naming the primary, and turning it off clears them. The
settings file came back byte-identical.

Claude-Session: https://claude.ai/code/session_01Q84axqUE5inJhf5Jz9CFy1
2026-08-21 02:16:29 -04:00
Gabriel Brown 9092a80f66 Search from the launcher, and give the touchpad something to do
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
2026-08-21 01:49:29 -04:00
Gabriel Brown 62dce86b4e Let the prompt survive not being installed yet
oh-my-posh moved from a curl installer to a Fedora package, and the shell config
invoked it unconditionally. That is fine during a full install, where
install-packages runs before the bashrc is linked -- and not fine in every other
order: a stage re-run by hand, an install that failed partway, or the moment
between removing the old binary and installing the package.

Same guard as the nvm source above it. An unthemed prompt is a worse shell; one
printing command-not-found before every prompt is a machine that looks broken.

Claude-Session: https://claude.ai/code/session_01Q84axqUE5inJhf5Jz9CFy1
2026-08-21 00:45:15 -04:00
Gabriel Brown 725e274ef4 Install the Node this shell config has always assumed
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
2026-08-21 00:30:48 -04:00
Gabriel Brown f09763ef5d Check the two facts the README states about itself
It claimed 121 contracts when there were 125, one day after the number was
written, and it documented every panama subcommand except the one added last --
so `panama apps` existed and the README did not mention it.

A number in prose is worth something as a claim somebody relies on and nothing
once it is wrong, so it is either checked or it should not be there. This checks
it, counted the way the runner collects the suite rather than by a second idea
of what a contract is, and checks that every subcommand the README documents is
one the dispatcher actually handles -- a listed command that errors reads as a
broken install rather than a stale document.

Claude-Session: https://claude.ai/code/session_01Q84axqUE5inJhf5Jz9CFy1
2026-08-21 00:11:04 -04:00
Gabriel Brown 215da285f3 Let applications be chosen a few at a time
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
2026-08-20 23:58:36 -04:00
Gabriel Brown 48f7c1e962 Name the Gaming page, and stop the docs inventing names
The settings reference is generated, and a contract already fails when the
committed copy is stale -- so it was current. It was also wrong: the Gaming page
was documented as "Found on **gaming**" while every other group named a real
page, because a routed page with no entry in PAGE_TITLES fell back to printing
its own id.

The staleness contract could not see it. Regenerating reproduced the same wrong
file, so the copy was current and wrong at the same time -- a check that compares
output against itself cannot catch a generator that is confidently mistaken.

So the fallback is gone. A routed page with no title now refuses to render and
says which page needs one, which is what makes the next page added here
impossible to miss.

Claude-Session: https://claude.ai/code/session_01Q84axqUE5inJhf5Jz9CFy1
2026-08-20 23:32:46 -04:00
Gabriel Brown f457c1eb9f Build the two applications nobody packages, on purpose rather than in passing
Claude Desktop and ChatGPT Desktop ship for macOS and Windows. The Linux path for
both is a community wrapper that converts the official build into an RPM -- so
what lands is still a package dnf owns and can remove, which is the part of the
dnf/flatpak rule that actually matters. What they need an exception for is the
build itself, and there is no packaged form to prefer over it.

`panama app` builds one by name, and is deliberately not part of ./install. A
source build is slow, wants the network throughout, and depends on an upstream
that moves -- twenty minutes in, an error, with nobody at the keyboard, which is
the exact failure the interview exists to prevent. Asking for one is something
you do on purpose, and it is also the rebuild path when a new version ships.

Nothing is pinned. Each build takes the current default branch and the current
upstream release, and reports a failure rather than working around it, leaving
the tree where the error can be read. sunhat pinned versions and every pin was a
404 within a release cycle.

Adding one is adding a file to setup/apps/, and the file has to say why the
exception exists -- the contract fails a definition that does not, because the
guard against this list growing by habit is having to write the reason down.
sunhat had seventy-odd installers and a reason recorded for none of them.

The contract had a bug worth recording: `while read` on the right of a pipe runs
in a subshell, so two of its three per-definition checks recorded findings into
an array that went out of scope at the end of the loop. It reported PASS on a
definition with no description and no build function. Found by standing one in
deliberately and noticing only the third check spoke up.

Also: nautilus-open-any-terminal is now declared, and Panama's copy of the
extension is gone. Fedora packages that extension AND its gsettings schema, and
Panama shipped its own fork of the .py over the same path while declaring
neither -- so a fresh machine got an extension whose schema did not exist. It
worked here only because the RPM has been installed since sunhat. The fork was
also 63 lines behind the packaged version, missing its newer Nautilus and Caja
handling.

Auditing the rest of config/copy for the same shape found nothing else: dnf.conf
is a config file its package expects to be replaced, and the GPU udev rules are
Panama's own.

125 contracts pass.

Claude-Session: https://claude.ai/code/session_01NvgBuSWB5sE43yWmg21ozj
2026-08-20 23:00:21 -04:00
42 changed files with 1803 additions and 675 deletions
+8
View File
@@ -22,3 +22,11 @@ __pycache__/
/config/dot/gtk-4.0/settings.ini /config/dot/gtk-4.0/settings.ini
/config/dot/tmux/current-theme.conf /config/dot/tmux/current-theme.conf
/config/dot/hypr/hyprlock.conf /config/dot/hypr/hyprlock.conf
# Build products of the Vicinae extension. The source is the repository's; the
# dependency tree and the bundle it produces are machine state, rebuilt by
# `panama apps`.
/config/local/share/vicinae/extensions/*/node_modules/
/config/local/share/vicinae/extensions/*/dist/
/config/local/share/vicinae/extensions/*/build/
/config/local/share/vicinae/extensions/*/package-lock.json
+30 -3
View File
@@ -64,7 +64,7 @@ is what decides; anything not on it is a panel Panama owns itself.
|---|---| |---|---|
| `config/dot/hypr/` | Compositor config. **Lua, not hyprlang** — see its README | | `config/dot/hypr/` | Compositor config. **Lua, not hyprlang** — see its README |
| `config/dot/quickshell/` | The shell: bar, dock, Continuum overview, 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/vicinae/` | Raycast-style launcher, themed. Its commands live in `config/local/share/vicinae/` — script commands, and one compiled extension that adds web search with live suggestions |
| `config/dot/uwsm/` | Session environment (see the uwsm caveat in the hypr README) | | `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 | | `config/dot/wofi/` | Fallback launcher, in case the shell fails to start |
| `config/dot/xdg-desktop-portal/` | Portal backend routing | | `config/dot/xdg-desktop-portal/` | Portal backend routing |
@@ -84,10 +84,13 @@ config/
copy/ Files copied verbatim over / (needs sudo) copy/ Files copied verbatim over / (needs sudo)
dot/ Symlinked into ~/.config dot/ Symlinked into ~/.config
firefox/ Vendored Firefox chrome, linked into the browser profile firefox/ Vendored Firefox chrome, linked into the browser profile
local/ Icons and the cursor theme, linked into ~/.local/share local/ Icons, the cursor theme, and the launcher's commands and
extensions, linked into ~/.local/share
old/ Backups of whatever was replaced (gitignored) old/ Backups of whatever was replaced (gitignored)
wallpapers/ Copied into ~/Pictures/Wallpapers when absent wallpapers/ Copied into ~/Pictures/Wallpapers when absent
setup/ setup/
apps/ Applications built from source, one file each
lib/ Shared by more than one stage; the extras catalog reader
packages/ One package per line; extras/ holds the optional categories packages/ One package per line; extras/ holds the optional categories
scripts/ Run in order by ./install scripts/ Run in order by ./install
tests/ Contracts. See below tests/ Contracts. See below
@@ -96,7 +99,7 @@ docs/ Settings reference, and the design specs behind the work
## Tests ## Tests
121 of them, under `tests/`. Run the lot, or a subset by pattern: 129 of them, under `tests/`. Run the lot, or a subset by pattern:
```sh ```sh
panama test # everything panama test # everything
@@ -126,8 +129,32 @@ panama edit # open it in Neovim
panama doctor # what is actually running, not what was installed panama doctor # what is actually running, not what was installed
panama test # every contract, or a subset by pattern panama test # every contract, or a subset by pattern
panama upgrade # re-run ./install from anywhere panama upgrade # re-run ./install from anywhere
panama apps # choose applications to install, by category
panama app # applications no repository carries; build one by name
``` ```
`panama apps` is the optional-application catalog, opened after the fact. The
interview offers the same categories during `./install`, whole; this picks a
category and then the applications inside it, so a machine can acquire Slack in
March without having wanted Discord in January. Both read
`setup/lib/extras-catalog`, so the two cannot describe different catalogues.
A category is one file under `setup/packages/extras/`. A bare line is a dnf
package, a `flatpak:` line is a Flathub id, `| Name` gives the menu something
readable, and an indented line belongs to the entry above it — which is how OBS
carries its sixteen plugin extensions as one thing to tick.
`panama app` is deliberately not part of `./install`. Everything else Panama
installs comes from dnf or Flathub; these are built from source because no
packaged form exists, and a source build is slow, wants the network throughout,
and depends on an upstream that moves. That is the failure the interview exists
to prevent, so asking for one is something you do on purpose — and it is also
how you rebuild when a new version ships. Nothing is pinned: each build takes
the current upstream and reports a failure rather than working around it.
Adding one is adding a file to `setup/apps/`, and the file has to say why the
exception exists.
None of the scripts in this repository carry a `.sh` extension. A shebang and None of the scripts in this repository carry a `.sh` extension. A shebang and
the executable bit already select the interpreter, and the extension only the executable bit already select the interpreter, and the extension only
becomes something to keep in sync — which it did not stay. becomes something to keep in sync — which it did not stay.
+202
View File
@@ -10,6 +10,8 @@
# doctor Report what is actually running on this machine # doctor Report what is actually running on this machine
# test Run every contract under tests/ # test Run every contract under tests/
# upgrade Re-run the installer from anywhere # upgrade Re-run the installer from anywhere
# apps Choose applications to install, by category
# app Build and install an application that no repository packages
# help Show this help # help Show this help
# #
# Designed to grow: add new subcommands as cmd_<name> functions and # Designed to grow: add new subcommands as cmd_<name> functions and
@@ -71,6 +73,11 @@ ${BOLD}Commands:${RESET}
a subset: 'panama test dock' runs the ones matching 'dock'. a subset: 'panama test dock' runs the ones matching 'dock'.
${GREEN}upgrade${RESET} Re-run ./install from anywhere. Safe: every stage is ${GREEN}upgrade${RESET} Re-run ./install from anywhere. Safe: every stage is
idempotent and this is the documented upgrade path. idempotent and this is the documented upgrade path.
${GREEN}apps${RESET} Choose applications to install: pick a category, then tick
what you want. The same catalog ./install offers, minus the
install.
${GREEN}app${RESET} Build and install an application that neither dnf nor
Flathub carries. With no name, lists what is available.
${GREEN}help${RESET} Show this help (also -h, --help). ${GREEN}help${RESET} Show this help (also -h, --help).
${BOLD}Options:${RESET} ${BOLD}Options:${RESET}
@@ -83,6 +90,9 @@ ${BOLD}Examples:${RESET}
$PROGRAM doctor --summary $PROGRAM doctor --summary
$PROGRAM test dock $PROGRAM test dock
$PROGRAM upgrade $PROGRAM upgrade
$PROGRAM apps
$PROGRAM app
$PROGRAM app claude-desktop
EOF EOF
} }
@@ -311,6 +321,196 @@ cmd_upgrade() {
exec "$installer" "$@" exec "$installer" "$@"
} }
# ----------------------------------------------------------------------------
# Command: app
# ----------------------------------------------------------------------------
#
# The applications that neither dnf nor Flathub carries, built from source into
# a package dnf can still own and remove.
#
# Deliberately NOT part of ./install. A source build is slow, wants the network
# for the whole of it, and depends on an upstream that moves -- which is exactly
# the failure the interview exists to prevent: twenty minutes in, a prompt or an
# error, with nobody at the keyboard. Asking for one of these is a thing you do
# on purpose, and it is also the rebuild path when a new version ships.
#
# Nothing is pinned. Each build takes the current default branch and the current
# upstream release, and says so when it fails. A recorded version is a 404
# waiting to happen -- sunhat proved that three times over.
APPS_DIR="$PANAMA_DIR/setup/apps"
APPS_WORK="${XDG_CACHE_HOME:-$HOME/.cache}/panama/apps"
cmd_app() {
local name="${1:-}"
if [[ -z "$name" ]]; then
header "Applications"
printf 'Built from source, because no repository carries them.\n\n'
local file
for file in "$APPS_DIR"/*; do
[[ -f "$file" ]] || continue
local description=""
# shellcheck source=/dev/null
source "$file"
printf ' %s%-18s%s %s\n' "$GREEN" "$(basename "$file")" "$RESET" "$description"
done
printf '\nBuild one with: %s%s app <name>%s\n' "$BOLD" "$PROGRAM" "$RESET"
return 0
fi
local definition="$APPS_DIR/$name"
if [[ ! -f "$definition" ]]; then
err "No such application: '$name'"
printf "Run '%s app' to see what is available.\n" "$PROGRAM" >&2
exit 1
fi
local description="" repo=""
# shellcheck source=/dev/null
source "$definition"
[[ -n "$repo" ]] || { err "$name declares no repository"; exit 1; }
# The checkout lives in the cache because it is entirely rebuildable and
# should never be mistaken for something to keep. Existing checkouts are
# reset to upstream rather than merged: a local edit in a build tree is not
# something to preserve silently.
local tree="$APPS_WORK/$name"
if [[ -d "$tree/.git" ]]; then
info "Updating $name"
git -C "$tree" fetch --depth 1 origin HEAD || { err "Could not reach $repo"; exit 1; }
git -C "$tree" reset --hard FETCH_HEAD >/dev/null
else
info "Cloning $name"
mkdir -p "$APPS_WORK"
rm -rf "$tree"
git clone --depth 1 "$repo" "$tree" || { err "Could not clone $repo"; exit 1; }
fi
info "Building ${BOLD}${name}${RESET} — this takes a while and needs the network"
if ( cd "$tree" && build ); then
ok "$name installed"
printf 'Built from %s\n' "$(git -C "$tree" rev-parse --short HEAD)"
else
err "$name failed to build"
printf 'The tree is left at %s so the failure can be read.\n' "$tree" >&2
printf 'This builds against upstream HEAD, so a break there breaks this.\n' >&2
exit 1
fi
}
# ----------------------------------------------------------------------------
# Command: apps
# ----------------------------------------------------------------------------
# The optional applications, chosen a few at a time rather than all at once.
#
# ./install offers the same catalog as whole categories, because during a first
# install you want coarse and fast. This is the other end of it: pick a
# category, then tick the applications inside, and install just those. Both read
# setup/lib/extras-catalog, so neither can drift from the other.
#
# Source-built applications appear here too, as their own category. They are not
# part of ./install for good reason -- a build is slow, wants the network
# throughout, and can fail on an upstream that moved -- but "choose, then
# install" is exactly the right shape for them, which is what this is.
cmd_apps() {
if ! command -v gum >/dev/null 2>&1; then
err "gum is not installed. It is in setup/packages/initial-packages; run 'panama upgrade'."
exit 1
fi
# shellcheck source=../setup/lib/extras-catalog
source "$PANAMA_DIR/setup/lib/extras-catalog"
local extras_dir="$PANAMA_DIR/setup/packages/extras"
local apps_dir="$PANAMA_DIR/setup/apps"
local -a categories=()
mapfile -t categories < <(catalog_categories "$extras_dir")
[[ -d "$apps_dir" ]] && categories+=("built from source")
(( ${#categories[@]} > 0 )) || { err "No application categories found."; exit 1; }
local category
category="$(gum choose --header "Which kind of application?" "${categories[@]}")" || return 0
[[ -n "$category" ]] || return 0
if [[ "$category" == "built from source" ]]; then
cmd_apps_source "$apps_dir"
return
fi
local file="$extras_dir/$category"
local -a labels=() targets=()
local target label marker
while IFS=$'\t' read -r target label; do
[[ -n "$target" ]] || continue
# Marked, not hidden: reinstalling what is present is harmless, but a menu
# that silently omits it leaves you wondering where it went.
if catalog_installed "$target"; then marker=" (installed)"; else marker=""; fi
targets+=("$target")
labels+=("$label$marker")
done < <(catalog_entries "$file")
local chosen
chosen="$(gum choose --no-limit --header "$category — space to select, enter to accept" "${labels[@]}")" || return 0
[[ -n "$chosen" ]] || { info "Nothing selected."; return 0; }
# Back from labels to install targets, then out to everything each one carries.
local -a install_targets=()
local pick i
while IFS= read -r pick; do
[[ -n "$pick" ]] || continue
for i in "${!labels[@]}"; do
if [[ "${labels[$i]}" == "$pick" ]]; then
mapfile -t -O "${#install_targets[@]}" install_targets < <(catalog_targets "$file" "${targets[$i]}")
break
fi
done
done <<< "$chosen"
local -a dnf_packages=() flatpak_ids=()
for target in "${install_targets[@]}"; do
if [[ "$target" == flatpak:* ]]; then flatpak_ids+=("${target#flatpak:}"); else dnf_packages+=("$target"); fi
done
header "About to install"
(( ${#dnf_packages[@]} > 0 )) && printf ' dnf %s\n' "${dnf_packages[*]}"
(( ${#flatpak_ids[@]} > 0 )) && printf ' flatpak %s\n' "${flatpak_ids[*]}"
echo
confirm "Install these?" || { warn "Nothing installed."; return 0; }
if (( ${#dnf_packages[@]} > 0 )); then
info "Installing ${#dnf_packages[@]} package(s) with dnf"
sudo dnf install -y "${dnf_packages[@]}" || warn "Some packages did not install"
fi
if (( ${#flatpak_ids[@]} > 0 )); then
info "Installing ${#flatpak_ids[@]} flatpak(s)"
sudo flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo >/dev/null
sudo flatpak install -y flathub "${flatpak_ids[@]}" || warn "Some flatpaks did not install"
fi
ok "Done."
}
# The source-built applications, offered by the same two-step flow and handed to
# the existing `panama app` so there is one build path, not two.
cmd_apps_source() {
local apps_dir="$1"
local -a names=()
mapfile -t names < <(for f in "$apps_dir"/*; do [[ -f "$f" ]] && basename "$f"; done)
(( ${#names[@]} > 0 )) || { err "No applications in $apps_dir."; return 1; }
local chosen
chosen="$(gum choose --no-limit --header "Built from source — these take a while" "${names[@]}")" || return 0
[[ -n "$chosen" ]] || { info "Nothing selected."; return 0; }
local name
while IFS= read -r name; do
[[ -n "$name" ]] || continue
header "Building $name"
cmd_app "$name" || warn "$name did not build"
done <<< "$chosen"
}
# ---------------------------------------------------------------------------- # ----------------------------------------------------------------------------
# Dispatcher # Dispatcher
# ---------------------------------------------------------------------------- # ----------------------------------------------------------------------------
@@ -322,6 +522,8 @@ main() {
doctor) shift; cmd_doctor "$@" ;; doctor) shift; cmd_doctor "$@" ;;
test) shift; cmd_test "$@" ;; test) shift; cmd_test "$@" ;;
upgrade) shift; cmd_upgrade "$@" ;; upgrade) shift; cmd_upgrade "$@" ;;
app) shift; cmd_app "$@" ;;
apps) shift; cmd_apps "$@" ;;
help|-h|--help|"") usage ;; help|-h|--help|"") usage ;;
--version) printf '%s %s\n' "$PROGRAM" "$VERSION" ;; --version) printf '%s %s\n' "$PROGRAM" "$VERSION" ;;
*) *)
+12 -3
View File
@@ -22,8 +22,10 @@ export DOTNETPATH="$HOME/.dotnet/tools"
# Set complete path # Set complete path
export PATH="$HOME/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:$PANAMA_PATH/bin:$BUN_INSTALL/bin:$CARGO_PATH/bin:$PNPM_HOME/bin:$PYENV_ROOT/bin:$HOME/.rbenv/bin:/usr/lib/ccache/bin/:$GOPATH/bin:$DOTNETPATH" export PATH="$HOME/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:$PANAMA_PATH/bin:$BUN_INSTALL/bin:$CARGO_PATH/bin:$PNPM_HOME/bin:$PYENV_ROOT/bin:$HOME/.rbenv/bin:/usr/lib/ccache/bin/:$GOPATH/bin:$DOTNETPATH"
# Nvm # Nvm. Guarded because the file belongs to the nvm package: before that is
source /etc/profile.d/nvm.sh # installed it does not exist, and an unconditional source means every shell on
# a fresh machine opens with an error.
[ -f /etc/profile.d/nvm.sh ] && source /etc/profile.d/nvm.sh
# Auto-switch Node version when entering a directory with .nvmrc # Auto-switch Node version when entering a directory with .nvmrc
_nvm_auto_use() { _nvm_auto_use() {
if [[ -f .nvmrc ]]; then if [[ -f .nvmrc ]]; then
@@ -41,4 +43,11 @@ fi
eval "$(zoxide init bash)" eval "$(zoxide init bash)"
# Oh My Posh # Oh My Posh
eval "$(oh-my-posh init bash --config $PANAMA_PATH/config/dot/ohmyposh/gib.omp.json)" # Guarded for the same reason the nvm source above is: oh-my-posh is a package,
# and a shell opened before it is installed -- a stage re-run by hand, an
# install that failed partway -- would otherwise print command-not-found on
# every prompt. An unthemed prompt is a worse shell; an erroring one is a
# broken-looking machine.
if command -v oh-my-posh >/dev/null 2>&1; then
eval "$(oh-my-posh init bash --config "$PANAMA_PATH/config/dot/ohmyposh/gib.omp.json")"
fi
@@ -1,614 +0,0 @@
"""nautilus extension: nautilus_open_any_terminal"""
# based on: https://github.com/gnunn1/tilix/blob/master/data/nautilus/open-tilix.py
import ast
import re
import shlex
from dataclasses import dataclass, field
from functools import cache
from gettext import gettext, translation
from os.path import expanduser
from subprocess import Popen
from typing import Optional
from urllib.parse import quote, unquote, urlparse
from gi import require_version
try:
require_version("Nautilus", "4.1")
except ValueError:
require_version("Nautilus", "4.0")
require_version("Gtk", "4.0")
from gi.repository import Nautilus as FileManager
API_VERSION = "4.1"
from gi.repository import Gio, GLib, GObject, Gtk # noqa: E402 pylint: disable=wrong-import-position
@dataclass(frozen=True)
class Terminal:
"""Data class representing a terminal configuration."""
name: str
workdir_arguments: Optional[list[str]] = None
new_tab_arguments: Optional[list[str]] = None
new_window_arguments: Optional[list[str]] = None
command_arguments: list[str] = field(default_factory=lambda: ["-e"])
flatpak_package: Optional[str] = None
_ = gettext
for localedir in [expanduser("~/.local/share/locale"), "/usr/share/locale"]:
try:
trans = translation("nautilus-open-any-terminal", localedir)
trans.install()
_ = trans.gettext
break
except FileNotFoundError:
continue
TERMINALS = {
"alacritty": Terminal("Alacritty"),
"app2unit-term": Terminal("app2unit-term"),
"blackbox": Terminal(
"Black Box",
workdir_arguments=["--working-directory"],
command_arguments=["-c"],
flatpak_package="com.raggesilver.BlackBox",
),
"blackbox-terminal": Terminal(
"Black Box",
workdir_arguments=["--working-directory"],
command_arguments=["-c"],
),
"bobcat": Terminal(
"Bobcat",
workdir_arguments=["--working-dir"],
command_arguments=["--"],
),
"cool-retro-term": Terminal("cool-retro-term", workdir_arguments=["--workdir"]),
"custom": Terminal(_("Terminal"), command_arguments=[]),
"contour": Terminal(
"Contour",
workdir_arguments=["--working-directory"],
flatpak_package="org.contourterminal.Contour",
),
"cosmic-term": Terminal("COSMIC Terminal"),
"deepin-terminal": Terminal("Deepin Terminal"),
"ddterm": Terminal(
"Drop down Terminal extension",
workdir_arguments=["--working-directory"],
flatpak_package="com.github.amezin.ddterm",
),
"foot": Terminal("Foot"),
"footclient": Terminal("FootClient"),
"ghostty": Terminal("Ghostty"),
"gnome-terminal": Terminal("Terminal", new_tab_arguments=["--tab"], command_arguments=["--"]),
"guake": Terminal("Guake", workdir_arguments=["--show", "--new-tab"]),
"kermit": Terminal("Kermit"),
"kgx": Terminal("Console", new_tab_arguments=["--tab"]),
"kitty": Terminal("Kitty"),
"konsole": Terminal("Konsole", new_tab_arguments=["--new-tab"]),
"mate-terminal": Terminal("Mate Terminal", new_tab_arguments=["--tab"]),
"mlterm": Terminal("Mlterm"),
"ptyxis": Terminal(
"Ptyxis",
workdir_arguments=["-d"],
command_arguments=["--"],
new_tab_arguments=["--tab"],
new_window_arguments=["--new-window"],
flatpak_package="app.devsuite.Ptyxis",
),
"ptyxis-nightly": Terminal(
"Ptyxis",
workdir_arguments=["-d"],
command_arguments=["--"],
new_tab_arguments=["--tab"],
new_window_arguments=["--new-window"],
flatpak_package="org.gnome.Ptyxis.Devel",
),
"qterminal": Terminal("QTerminal"),
"rio": Terminal("Rio"),
"sakura": Terminal("Sakura"),
"st": Terminal("Simple Terminal"),
"tabby": Terminal("Tabby", command_arguments=["run"], workdir_arguments=["open"]),
"terminator": Terminal("Terminator", new_tab_arguments=["--new-tab"]),
"terminology": Terminal("Terminology"),
"terminus": Terminal("Terminus"),
"termite": Terminal("Termite"),
"tilix": Terminal("Tilix", flatpak_package="com.gexperts.Tilix"),
"urxvt": Terminal("rxvt-unicode"),
"urxvtc": Terminal("urxvtc"),
"uwsm-terminal": Terminal("uwsm-terminal"),
"uxterm": Terminal("UXTerm"),
"warp": Terminal(
"Warp",
new_tab_arguments=["--virtual-arg-for-tabs"], # This is just to indicate tab support
),
"wezterm": Terminal(
"Wez's Terminal Emulator",
workdir_arguments=["--cwd"],
new_tab_arguments=["start", "--new-tab"],
new_window_arguments=["start"],
flatpak_package="org.wezfurlong.wezterm",
),
"xfce4-terminal": Terminal("Xfce Terminal", new_tab_arguments=["--tab"]),
"xterm": Terminal("XTerm"),
}
FLATPAK_PARMS = ["off", "system", "user"]
terminal = "gnome-terminal"
terminal_cmd: list[str] = None # type: ignore
terminal_data: Terminal = TERMINALS["gnome-terminal"]
new_tab = False
flatpak = FLATPAK_PARMS[0]
custom_local_command: str
custom_remote_command: str
GSETTINGS_PATH = "com.github.stunkymonkey.nautilus-open-any-terminal"
GSETTINGS_KEYBINDINGS = "keybindings"
GSETTINGS_BIND_REMOTE = "bind-remote"
GSETTINGS_TERMINAL = "terminal"
GSETTINGS_NEW_TAB = "new-tab"
GSETTINGS_FLATPAK = "flatpak"
GSETTINGS_USE_GENERIC_TERMINAL_NAME = "use-generic-terminal-name"
GSETTINGS_CUSTOM_LOCAL_COMMAND = "custom-local-command"
GSETTINGS_CUSTOM_REMOTE_COMMAND = "custom-remote-command"
REMOTE_URI_SCHEME = ["ftp", "sftp"]
# Adapted from https://www.freedesktop.org/software/systemd/man/latest/os-release.html
def read_os_release():
"""Read and parse the OS release information."""
possible_os_release_paths = ["/etc/os-release", "/usr/lib/os-release"]
for file_path in possible_os_release_paths:
try:
with open(file_path, mode="r", encoding="utf-8") as os_release:
for line_number, line in enumerate(os_release, start=1):
line = line.rstrip()
if not line or line.startswith("#"):
continue
result = re.match(r"([A-Z][A-Z_0-9]+)=(.*)", line)
if result:
name, val = result.groups()
if val and val[0] in "\"'":
val = ast.literal_eval(val)
yield name, val
else:
raise OSError(f"{file_path}:{line_number}: bad line {line!r}")
except FileNotFoundError:
continue
@cache
def distro_id() -> set[str]:
"""get the set of distribution ids"""
try:
os_release = dict(read_os_release())
except OSError:
return set(["unknown"])
ids = [os_release["ID"]]
if id_like := os_release.get("ID_LIKE"):
ids.extend(id_like.split(" "))
return set(ids)
def parse_custom_command(command: str, data: str | list[str]) -> list[str]:
"""Substitute every '%s' in the command with data and split it into arguments"""
if isinstance(data, str):
data = [data]
return shlex.split(command.replace("%s", shlex.join(data)))
def run_command_in_terminal(command: list[str], *, cwd: str | None = None):
if terminal == "custom":
cmd = parse_custom_command(custom_remote_command, command)
else:
cmd = terminal_cmd.copy()
if cwd and terminal_data.workdir_arguments:
cmd.extend(terminal_data.workdir_arguments)
cmd.append(cwd)
cmd.extend(terminal_data.command_arguments)
cmd.extend(command)
Popen(cmd, cwd=cwd) # pylint: disable=consider-using-with
def ssh_command_from_uri(uri: str, *, is_directory: bool):
"""Creates an ssh command that executes or cd's into remote uri"""
result = urlparse(uri)
cmd = ["ssh", "-t"]
if result.username:
cmd.append(f"{result.username}@{result.hostname}")
else:
cmd.append(result.hostname) # type: ignore
if result.port:
cmd.append("-p")
cmd.append(str(result.port))
target = shlex.quote(unquote(result.path))
if is_directory:
cmd.extend(["cd", target, ";", "exec", "${SHELL:-/bin/sh}", "-l"])
else:
cmd.extend(["exec", target])
return cmd
def open_remote_terminal_in_uri(uri: str):
"""Open a new remote terminal"""
run_command_in_terminal(ssh_command_from_uri(uri, is_directory=True))
def open_local_terminal_in_uri(uri: str):
"""open the new terminal with correct path"""
result = urlparse(uri)
filename = unquote(result.path)
if result.scheme == "admin":
run_command_in_terminal(["sudo", "-s"], cwd=filename)
return
if terminal == "warp":
# Force new_tab to be considered even without traditional tab arguments
Popen( # pylint: disable=consider-using-with
["xdg-open", f"warp://action/new_{'tab' if new_tab else 'window'}?path={result.path}"]
)
return
cmd = terminal_cmd.copy()
if terminal == "custom":
cmd = parse_custom_command(custom_local_command, filename)
elif filename and terminal_data.workdir_arguments:
cmd.extend(terminal_data.workdir_arguments)
cmd.append(filename)
Popen(cmd, cwd=filename) # pylint: disable=consider-using-with
def directory_menu_item_id(*, foreground: bool, remote: bool):
return f"OpenTerminal::open{'_' if foreground else '_bg_'}{'remote' if remote else 'file'}_item"
def executable_menu_item_id(*, remote: bool):
return f"OpenTerminal::execute{'_remote_' if remote else '_file_'}item"
def get_directory_menu_items(
file: FileManager.FileInfo, callback, *, foreground: bool, terminal_name: str | None = None
):
items = []
remote = file.get_uri_scheme() in REMOTE_URI_SCHEME
terminal_name = terminal_name or terminal_data.name
if remote:
if foreground:
REMOTE_LABEL = _("Open in Remote {}")
REMOTE_TIP = _("Open Remote {} in {}")
LOCAL_LABEL = _("Open in Local {}")
LOCAL_TIP = _("Open Local {} in {}")
tip = REMOTE_TIP.format(terminal_name, file.get_name())
else:
REMOTE_LABEL = _("Open Remote {} Here")
REMOTE_TIP = _("Open Remote {} in This Directory")
LOCAL_LABEL = _("Open Local {} Here")
LOCAL_TIP = _("Open Local {} in This Directory")
tip = REMOTE_TIP.format(terminal_name)
item = FileManager.MenuItem(
name=directory_menu_item_id(foreground=foreground, remote=True),
label=REMOTE_LABEL.format(terminal_name),
tip=tip,
)
item.connect("activate", callback, file, True)
items.append(item)
elif foreground:
LOCAL_LABEL = _("Open in {}")
LOCAL_TIP = _("Open {} in {}")
else:
LOCAL_LABEL = _("Open {} Here")
LOCAL_TIP = _("Open {} in This Directory")
# Let wezterm handle opening a local terminal
if terminal == "wezterm" and flatpak == "off":
return items
if foreground:
tip = LOCAL_TIP.format(terminal_name, file.get_name())
else:
tip = LOCAL_TIP.format(terminal_name)
item = FileManager.MenuItem(
name=directory_menu_item_id(foreground=foreground, remote=False),
label=LOCAL_LABEL.format(terminal_name),
tip=tip,
)
item.connect("activate", callback, file, False)
items.append(item)
return items
def get_executable_menu_items(file: FileManager.FileInfo, callback, *, terminal_name: str | None = None):
items = []
remote = file.get_uri_scheme() in REMOTE_URI_SCHEME
terminal_name = terminal_name or terminal_data.name
if remote:
REMOTE_LABEL = _("Execute in Remote {}")
REMOTE_TIP = _("Execute {} in {} via SSH")
LOCAL_LABEL = _("Execute in Local {}")
LOCAL_TIP = _("Execute {} in Local {}")
tip = REMOTE_TIP.format(file.get_name(), terminal_name)
item = FileManager.MenuItem(
name=executable_menu_item_id(remote=True),
label=REMOTE_LABEL.format(terminal_name),
tip=tip,
)
item.connect("activate", callback, file, True)
items.append(item)
else:
LOCAL_LABEL = _("Execute in {}")
LOCAL_TIP = _("Execute {} in {}")
tip = LOCAL_TIP.format(file.get_name(), terminal_name)
item = FileManager.MenuItem(
name=executable_menu_item_id(remote=False),
label=LOCAL_LABEL.format(terminal_name),
tip=tip,
)
item.connect("activate", callback, file, False)
items.append(item)
return items
def is_executable(file: Gio.File) -> bool:
try:
attributes = file.query_info("access::can-execute", Gio.FileQueryInfoFlags.NONE)
except GLib.Error:
return False
return attributes.get_attribute_boolean("access::can-execute")
def set_terminal_args(*_args):
# pylint: disable=possibly-used-before-assignment
"""set the terminal_cmd to the correct values"""
global new_tab
global flatpak
global terminal_cmd
global terminal_data
global custom_local_command
global custom_remote_command
value = _gsettings.get_string(GSETTINGS_TERMINAL)
newer_tab = _gsettings.get_boolean(GSETTINGS_NEW_TAB)
flatpak = FLATPAK_PARMS[_gsettings.get_enum(GSETTINGS_FLATPAK)]
new_terminal_data = TERMINALS.get(value)
if not new_terminal_data:
print(f'open-any-terminal: unknown terminal "{value}"')
return
global terminal
terminal = value
terminal_data = new_terminal_data
if newer_tab and terminal_data.new_tab_arguments:
new_tab = newer_tab
new_tab_text = "opening in a new tab"
else:
new_tab_text = "opening a new window"
if newer_tab and not terminal_data.new_tab_arguments:
new_tab_text += " (terminal does not support tabs)"
if flatpak != FLATPAK_PARMS[0] and terminal_data.flatpak_package is not None:
terminal_cmd = ["flatpak", "run", "--" + flatpak, terminal_data.flatpak_package]
flatpak_text = f"with flatpak as {flatpak}"
else:
terminal_cmd = [terminal]
if terminal == "blackbox" and "fedora" in distro_id():
# It's called like this on fedora
terminal_cmd[0] = "blackbox-terminal"
flatpak = FLATPAK_PARMS[0]
flatpak_text = ""
if terminal == "custom":
terminal_cmd = []
custom_local_command = _gsettings.get_string(GSETTINGS_CUSTOM_LOCAL_COMMAND)
custom_remote_command = _gsettings.get_string(GSETTINGS_CUSTOM_REMOTE_COMMAND)
elif new_tab and terminal_data.new_tab_arguments:
terminal_cmd.extend(terminal_data.new_tab_arguments)
elif terminal_data.new_window_arguments:
terminal_cmd.extend(terminal_data.new_window_arguments)
print(f'open-any-terminal: terminal is set to "{terminal}" {new_tab_text} {flatpak_text}')
if API_VERSION == ("4.0", "4.1"):
class OpenAnyTerminalShortcutProvider(GObject.GObject, FileManager.MenuProvider):
"""Provide keyboard shortcuts for opening terminals in Nautilus."""
def __init__(self):
super().__init__()
self.previous_cwd = expanduser("~")
gsettings_source = Gio.SettingsSchemaSource.get_default()
if gsettings_source.lookup(GSETTINGS_PATH, True):
self._gsettings = Gio.Settings.new(GSETTINGS_PATH)
self._setup_keybindings()
def get_background_items(self, current_folder: FileManager.FileInfo):
"""Update current URI when folder changes."""
if current_folder:
if current_folder.get_uri_scheme() in REMOTE_URI_SCHEME:
folder_path = current_folder.get_uri()
else:
folder_path = current_folder.get_location().get_path()
if folder_path and folder_path != self.previous_cwd:
self.previous_cwd = folder_path
return []
def _open_terminal(self, *_args):
"""Open the terminal at the specified URI."""
if self._gsettings.get_boolean(GSETTINGS_BIND_REMOTE):
open_remote_terminal_in_uri(self.previous_cwd)
else:
open_local_terminal_in_uri(self.previous_cwd)
def _setup_keybindings(self):
"""Set up custom keybindings for the extension."""
self.app = Gtk.Application.get_default()
if self.app is None:
print("No Gtk.Application found. Keybindings cannot be set.")
return
action = Gio.SimpleAction.new("open_any_terminal", None)
action.connect("activate", self._open_terminal)
self.app.add_action(action)
self._bind_shortcut()
self._gsettings.connect("changed", self._update_shortcut)
def _update_shortcut(self, _gsettings, key):
"""remove keybinding"""
if key == GSETTINGS_KEYBINDINGS:
self.app.set_accels_for_action("app.open_any_terminal", [])
self._bind_shortcut()
def _bind_shortcut(self):
"""Parse and update keybindings when settings change."""
shortcut = self._gsettings.get_string(GSETTINGS_KEYBINDINGS)
if not shortcut:
self.app.set_accels_for_action("app.open_any_terminal", [])
return
valid, key, mods = Gtk.accelerator_parse(shortcut)
if not valid:
print("Invalid shortcut in GSettings: %r", shortcut)
self.app.set_accels_for_action("app.open_any_terminal", [])
return
normalized = Gtk.accelerator_name(key, mods)
self.app.set_accels_for_action("app.open_any_terminal", [normalized])
elif API_VERSION in ("3.0", "2.0"):
class OpenAnyTerminalShortcutProviderLegacy(GObject.GObject, FileManager.LocationWidgetProvider):
"""Provide keyboard shortcuts for opening terminals in Nautilus/Caja."""
def __init__(self):
super().__init__()
gsettings_source = Gio.SettingsSchemaSource.get_default()
if gsettings_source.lookup(GSETTINGS_PATH, True):
self._gsettings = Gio.Settings.new(GSETTINGS_PATH)
self._gsettings.connect("changed", self._bind_shortcut)
self._create_accel_group()
self._window = None
self._uri = None
def _create_accel_group(self):
self._accel_group = Gtk.AccelGroup()
shortcut = self._gsettings.get_string(GSETTINGS_KEYBINDINGS)
key, mod = Gtk.accelerator_parse(shortcut)
self._accel_group.connect(key, mod, Gtk.AccelFlags.VISIBLE, self._open_terminal)
def _bind_shortcut(self, _gsettings, key):
if key == GSETTINGS_KEYBINDINGS:
self._accel_group.disconnect(self._open_terminal)
self._create_accel_group()
def _open_terminal(self, *_args):
if _gsettings.get_boolean(GSETTINGS_BIND_REMOTE):
open_local_terminal_in_uri(self._uri)
else:
open_remote_terminal_in_uri(self._uri)
def get_widget(self, uri, window):
"""follows uri and sets the correct window"""
self._uri = uri
if self._window:
self._window.remove_accel_group(self._accel_group)
if self._gsettings:
window.add_accel_group(self._accel_group)
self._window = window
class OpenAnyTerminalExtension(GObject.GObject, FileManager.MenuProvider):
"""Provide context menu items for opening terminals in Nautilus."""
def __init__(self):
super().__init__()
gsettings_source = Gio.SettingsSchemaSource.get_default()
if gsettings_source.lookup(GSETTINGS_PATH, True):
self._gsettings = Gio.Settings.new(GSETTINGS_PATH)
def _get_terminal_name(self):
if self._gsettings.get_boolean(GSETTINGS_USE_GENERIC_TERMINAL_NAME):
return _("Terminal")
return None
def _menu_dir_activate_cb(self, menu, file_, remote: bool):
if remote:
open_remote_terminal_in_uri(file_.get_uri())
else:
if file_.get_uri_scheme() == "smb":
file_uri = "file://" + quote(file_.get_location().get_path())
else:
file_uri = file_.get_uri()
open_local_terminal_in_uri(file_uri)
def _menu_exe_activate_cb(self, menu, file_, remote: bool):
if remote:
cmd = ssh_command_from_uri(file_.get_uri(), is_directory=False)
else:
result = urlparse(file_.get_uri())
file = unquote(result.path)
if result.scheme == "admin":
cmd = ["sudo", file]
elif terminal in ["xterm", "uxterm"]:
cmd = [f"exec {shlex.quote(file)}"]
else:
cmd = [file]
run_command_in_terminal(cmd)
def get_file_items(self, *args):
"""Generates a list of menu items for a file or folder in the Nautilus file manager."""
# `args` will be `[files: List[Nautilus.FileInfo]]` in Nautilus 4.0 API,
# and `[window: Gtk.Widget, files: List[Nautilus.FileInfo]]` in Nautilus 3.0 API.
files = args[-1]
if len(files) != 1:
return []
file_ = files[0]
if file_.is_directory():
return get_directory_menu_items(
file_, self._menu_dir_activate_cb, foreground=True, terminal_name=self._get_terminal_name()
)
if is_executable(file_.get_location()):
return get_executable_menu_items(file_, self._menu_exe_activate_cb, terminal_name=self._get_terminal_name())
return []
def get_background_items(self, *args):
"""Generates a list of background menu items for a file or folder in the Nautilus file manager."""
# `args` will be `[folder: Nautilus.FileInfo]` in Nautilus 4.0 API,
# and `[window: Gtk.Widget, file: Nautilus.FileInfo]` in Nautilus 3.0 API.
file_ = args[-1]
return get_directory_menu_items(
file_, self._menu_dir_activate_cb, foreground=False, terminal_name=self._get_terminal_name()
)
source = Gio.SettingsSchemaSource.get_default()
if source is not None and source.lookup(GSETTINGS_PATH, True):
_gsettings = Gio.Settings.new(GSETTINGS_PATH)
_gsettings.connect("changed", set_terminal_args)
set_terminal_args()
+23 -2
View File
@@ -44,7 +44,28 @@ hl.config({
}, },
}) })
-- Desktop machine: no touchpad, no gestures worth wiring. If a laptop ever -- ── Touchpad gestures ───────────────────────────────────────────────────────
-- runs this config, add touchpad settings in overrides.lua. --
-- GNOME's gestures, reproduced: three fingers sideways moves between
-- workspaces, three fingers up opens the overview, three fingers down closes
-- it. That is the same muscle memory the keybinds were built to preserve.
--
-- Registered unconditionally rather than behind a preference. Hyprland 0.56
-- dropped `gestures:workspace_swipe` in favour of this `gesture` keyword, and a
-- registration is read at config time -- so a toggle would need a reload to
-- take effect, which is worse than the nothing these cost on a machine with no
-- touchpad. What IS tunable at runtime lives in Settings: how far a swipe has
-- to travel, and which way round it goes.
--
-- open and close rather than toggle twice: with a toggle on both directions,
-- swiping up from an already-open overview would close it, and swiping down
-- would reopen it. GNOME does not do that, and neither does this.
local overview = function(fn)
return function() hl.exec_cmd("qs ipc call overview " .. fn) end
end
hl.gesture({ fingers = 3, direction = "horizontal", action = "workspace" })
hl.gesture({ fingers = 3, direction = "up", action = overview("open") })
hl.gesture({ fingers = 3, direction = "down", action = overview("close") })
return true return true
+12
View File
@@ -195,6 +195,18 @@ hl.config({
-- Don't let apps steal focus by shouting; matches GNOME's behavior. -- Don't let apps steal focus by shouting; matches GNOME's behavior.
focus_on_activate = false, focus_on_activate = false,
-- Window swallowing: a terminal hides itself while a graphical
-- application launched from it is open, and comes back when that
-- application exits. Off by default -- it is a real change in how the
-- desktop behaves, and one that is confusing rather than broken if you
-- did not ask for it: your terminal appears to vanish.
--
-- The regex is narrow on purpose. Anything matching it can swallow, so
-- a permissive pattern means windows disappearing in cases nobody
-- intended. Only the two terminals this desktop actually ships.
enable_swallow = prefs.get("windowSwallow", false),
swallow_regex = "^(kitty|com\\.mitchellh\\.ghostty)$",
}, },
render = { render = {
+36
View File
@@ -172,4 +172,40 @@ hl.monitor({
scale = "auto", scale = "auto",
}) })
-- ── Workspaces on the primary display only ──────────────────────────────────
--
-- GNOME offered one workspace choice worth reproducing: whether the other
-- screens join in. Off, every monitor has its own workspaces and switching
-- affects whichever one has focus -- Hyprland's own behaviour, so it needs no
-- rules at all. On, workspaces 1-10 are pinned to the primary display and a
-- second screen keeps a workspace of its own that stays put.
--
-- Ten because that is how many the keybinds reach: ALT+1 through ALT+0 in
-- keybinds.lua. Binding more would pin workspaces nothing can navigate to, and
-- binding fewer would leave the last few behaving differently from the rest for
-- no reason a person could see.
--
-- The rules are emitted here rather than written live because Hyprland reads
-- them at config time and offers no way to remove one afterwards: writing an
-- empty monitor leaves the previous binding in place. So the config is the only
-- honest source, and applying a change is a reload.
if prefs.get("workspacesOnPrimaryOnly", false) == true then
local primary = nil
for output, entry in pairs(displays) do
if type(entry) == "table" and entry.primary == true
and type(output) == "string" and output:match("^[%w_.-]+$") ~= nil then
primary = output
break
end
end
-- Without a primary there is nothing to pin to, and guessing one would move
-- every workspace onto whichever screen happened to sort first.
if primary ~= nil then
for i = 1, 10 do
hl.workspace_rule({ workspace = tostring(i), monitor = primary })
end
end
end
return true return true
@@ -179,6 +179,19 @@ Singleton {
// These three are written to the compositor and verified by read-back. // These three are written to the compositor and verified by read-back.
// See services/SystemSettings.qml for why the exit code cannot be // See services/SystemSettings.qml for why the exit code cannot be
// trusted for either hyprctl keyword or hyprctl eval. // trusted for either hyprctl keyword or hyprctl eval.
// GNOME's Multitasking panel had exactly this choice, and it is the one
// worth reproducing: not which workspace goes on which screen, but
// whether the second screen participates in workspaces at all.
//
// No `hypr` block, because this is not an option. It becomes workspace
// rules in monitors.lua, and Hyprland reads those at config time and
// will not let one be removed afterwards -- so applying a change is a
// reload rather than a write, which is what Workspaces.qml owns.
{
key: "workspacesOnPrimaryOnly", type: "bool", def: false, group: "display",
label: "Workspaces on the primary display only",
detail: "Other screens keep one workspace of their own rather than switching along with it"
},
{ {
key: "autoHdr", type: "bool", def: true, group: "display", key: "autoHdr", type: "bool", def: true, group: "display",
label: "Game-aware HDR", label: "Game-aware HDR",
@@ -672,6 +685,24 @@ Singleton {
hypr: { path: ["input", "touchpad", "middle_button_emulation"], option: "input:touchpad:middle_button_emulation", readAs: "bool" } hypr: { path: ["input", "touchpad", "middle_button_emulation"], option: "input:touchpad:middle_button_emulation", readAs: "bool" }
}, },
// Tuning for the three-finger gestures registered in hypr/input.lua.
// The gestures themselves are not settings: Hyprland reads a gesture
// registration at config time, so switching one on would need a reload,
// and these two are the parts it will accept at runtime.
{
key: "swipeDistance", type: "int", def: 300, min: 100, max: 800, step: 20,
unit: "px", group: "touchpad",
label: "Swipe distance",
detail: "How far a three-finger swipe must travel to change workspace",
hypr: { path: ["gestures", "workspace_swipe_distance"], option: "gestures:workspace_swipe_distance", readAs: "int" }
},
{
key: "swipeInvert", type: "bool", def: true, group: "touchpad",
label: "Natural swipe direction",
detail: "Swiping left moves to the workspace on the right, as content follows your fingers",
hypr: { path: ["gestures", "workspace_swipe_invert"], option: "gestures:workspace_swipe_invert", readAs: "bool" }
},
// ── Multitasking ──────────────────────────────────────────────────── // ── Multitasking ────────────────────────────────────────────────────
// //
// GNOME's Multitasking panel, in Hyprland's terms. The Desktop page // GNOME's Multitasking panel, in Hyprland's terms. The Desktop page
@@ -733,6 +764,12 @@ Singleton {
detail: "An application asking for attention is switched to, rather than only highlighted", 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" } hypr: { path: ["misc", "focus_on_activate"], option: "misc:focus_on_activate", readAs: "bool" }
}, },
{
key: "windowSwallow", type: "bool", def: false, group: "multitasking",
label: "Hide the terminal that launched a window",
detail: "A terminal disappears while an application started from it is open, and returns when it closes",
hypr: { path: ["misc", "enable_swallow"], option: "misc:enable_swallow", readAs: "bool" }
},
{ {
key: "mouseMoveFocusesMonitor", type: "bool", def: true, group: "multitasking", key: "mouseMoveFocusesMonitor", type: "bool", def: true, group: "multitasking",
label: "Pointer changes active display", label: "Pointer changes active display",
@@ -192,7 +192,8 @@ SettingsPage {
ToggleRow { setting: "workspaceBackAndForth" } ToggleRow { setting: "workspaceBackAndForth" }
ToggleRow { setting: "allowWorkspaceCycles" } ToggleRow { setting: "allowWorkspaceCycles" }
ToggleRow { setting: "focusOnActivate" } ToggleRow { setting: "focusOnActivate" }
ToggleRow { setting: "mouseMoveFocusesMonitor"; divider: false } ToggleRow { setting: "mouseMoveFocusesMonitor" }
ToggleRow { setting: "windowSwallow"; divider: false }
} }
SettingsCard { SettingsCard {
@@ -321,6 +321,51 @@ SettingsPage {
} }
} }
// Only with something to spread across. On one screen the choice has no
// meaning, the same way the Touchpad card stays hidden without a touchpad.
SettingsCard {
visible: Displays.monitors.length >= 2
title: "Workspaces"
subtitle: "GNOME asked this too. Off, every screen has its own workspaces and switching moves the one you are looking at; on, workspaces belong to the primary display and the others keep a screen of their own."
SegmentRow {
label: "Where workspaces live"
detail: Workspaces.applied
? (Workspaces.primaryOnly
? "Workspaces 1 to 10 are on the primary display."
: "Each display has its own workspaces.")
: "Chosen, but not in effect yet — the compositor has to reload."
options: [
{ value: false, label: "All displays" },
{ value: true, label: "Primary only" }
]
value: Workspaces.primaryOnly
enabled: !Workspaces.reloading
divider: !Workspaces.applied
onSelected: value => Workspaces.choose(value === true)
}
// Appears only when the compositor and the preference disagree, which
// is also how it disappears: applying makes its own reason to exist go
// away. A reload is a whole-session event, so it is asked for rather
// than done quietly the moment the switch moves.
ActionRow {
visible: !Workspaces.applied
label: Workspaces.reloading ? "Reloading…" : "Reload to apply"
detail: "Re-reads the compositor's configuration. Windows and workspaces stay where they are."
action: "Reload"
enabled: !Workspaces.reloading
divider: false
onTriggered: Workspaces.apply()
}
}
SettingsCard {
visible: Workspaces.lastError !== ""
title: "Workspace problem"
subtitle: Workspaces.lastError
}
SettingsCard { SettingsCard {
title: "Gaming display policy" title: "Gaming display policy"
subtitle: "Applied immediately and restored when the session starts." subtitle: "Applied immediately and restored when the session starts."
@@ -52,6 +52,15 @@ SettingsPage {
ToggleRow { setting: "touchpadMiddleButtonEmulation"; divider: false } ToggleRow { setting: "touchpadMiddleButtonEmulation"; divider: false }
} }
SettingsCard {
visible: InputDevices.hasTouchpad
title: "Gestures"
subtitle: "Three fingers sideways moves between workspaces, up opens the overview, and down closes it — the same gestures GNOME used. Which gestures exist is fixed by the compositor at startup; what they feel like is here."
SliderRow { setting: "swipeDistance" }
ToggleRow { setting: "swipeInvert"; divider: false }
}
SettingsCard { SettingsCard {
title: "Pointer" title: "Pointer"
@@ -46,6 +46,7 @@ PAGE_TITLES = {
"accessibility": "Accessibility", "power": "Power & Lock", "accessibility": "Accessibility", "power": "Power & Lock",
"datetime": "Date & Time", "applications": "Applications", "datetime": "Date & Time", "applications": "Applications",
"services": "System Health", "about": "About", "services": "System Health", "about": "About",
"gaming": "Gaming",
} }
@@ -146,6 +147,17 @@ def render(entries, routes):
if not visible: if not visible:
continue continue
page = routes.get(group) page = routes.get(group)
# A routed page with no title used to fall back to the raw page id, so
# the group "gaming" documented itself as "Found on **gaming**" while
# every other group named a real page. The staleness contract could not
# see it: regenerating reproduced the same wrong file, so the copy was
# current and wrong at once. Refusing to render is what makes the next
# page added here impossible to miss.
if page is not None and page not in PAGE_TITLES:
raise SchemaError(
f"group '{group}' routes to page '{page}', which has no entry in "
"PAGE_TITLES; add one rather than letting the id be printed as a name"
)
title = PAGE_TITLES.get(page, page or "—") title = PAGE_TITLES.get(page, page or "—")
lines += [f"## {group}", "", f"Found on **{title}**.", ""] lines += [f"## {group}", "", f"Found on **{title}**.", ""]
lines += ["| Setting | Default | What it does |", "|---|---|---|"] lines += ["| Setting | Default | What it does |", "|---|---|---|"]
@@ -0,0 +1,120 @@
pragma Singleton
// ─────────────────────────────────────────────────────────────────────────────
// Whether the other screens join in on workspaces.
//
// The preference is ordinary; applying it is not. Workspace rules are read by
// Hyprland at config time and cannot be taken back at runtime -- writing a rule
// with an empty monitor leaves the old binding in place, which was checked
// rather than assumed. Only `hyprctl reload` clears them, and it re-runs the
// whole config, so monitors.lua re-emits exactly the set the preference asks
// for.
//
// That makes this service two things: the reload, and an honest answer to "has
// it actually taken effect yet". The second matters more. A page that offers a
// switch and silently does nothing until the next login is the failure this
// repository keeps refusing to ship, so `applied` is read back from the
// compositor rather than inferred from the preference having been written.
// ─────────────────────────────────────────────────────────────────────────────
import Quickshell
import Quickshell.Io
import QtQuick
import qs.config
Singleton {
id: root
// The stored intent.
readonly property bool primaryOnly: DesktopPreferences.get("workspacesOnPrimaryOnly") === true
// What the compositor is actually running, as reported by hyprctl.
property var rules: []
property bool reloading: false
property string lastError: ""
// The keybinds reach ALT+1..ALT+0, and monitors.lua pins that many.
readonly property int boundWorkspaces: 10
// Every monitor named by a rule. When workspaces are pinned there is
// exactly one, and it is the primary.
readonly property var pinnedMonitors: {
const names = [];
for (const rule of root.rules) {
const monitor = rule?.monitor;
if (typeof monitor === "string" && monitor !== "" && !names.includes(monitor))
names.push(monitor);
}
return names;
}
// Has the compositor caught up with the preference?
//
// Pinned means every bound workspace carries a rule naming one monitor;
// unpinned means no rules at all. Anything in between is a config that was
// changed without a reload, which is precisely the state worth reporting.
readonly property bool applied: root.primaryOnly
? (root.rules.length >= root.boundWorkspaces && root.pinnedMonitors.length === 1)
: root.rules.length === 0
Process {
id: query
command: ["hyprctl", "-j", "workspacerules"]
stdout: StdioCollector {
onStreamFinished: {
try {
const parsed = JSON.parse(this.text);
root.rules = Array.isArray(parsed) ? parsed : [];
} catch (error) {
// An unparseable answer is not an empty rule set. Claiming
// it was would report "not pinned" for a desktop that is.
root.lastError = "Could not read the compositor's workspace rules.";
}
}
}
}
Process {
id: reloadRun
command: ["hyprctl", "reload"]
onExited: (exitCode, exitStatus) => {
root.reloading = false;
if (exitCode !== 0) {
root.lastError = "The compositor did not reload.";
return;
}
root.lastError = "";
// The preference file is written on a timer and the reload has to
// re-read it, so the rules are only worth re-reading once both have
// had a moment.
settle.restart();
}
}
Timer {
id: settle
interval: 350
onTriggered: root.refresh()
}
function refresh(): void {
if (!query.running)
query.running = true;
}
// Write the choice, then make it true. Split from the write on purpose: the
// page confirms between the two, because a reload is felt across the whole
// session rather than in one card.
function choose(primaryOnly: bool): void {
DesktopPreferences.set("workspacesOnPrimaryOnly", primaryOnly === true);
}
function apply(): void {
if (reloadRun.running)
return;
root.reloading = true;
reloadRun.running = true;
}
Component.onCompleted: root.refresh()
}
@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Panama Settings.
A gear, because a settings icon has to be recognizable at 48px in a dock
before it is anything else - but rendered in the Prism identity rather than
the flat monochrome symbolic icon this replaces. Blue leads into orchid, the
same pair that marks the focused window, the active workspace, and the
hairline along every glass surface.
The gear is a real toothed outline. An earlier version drew a ring with radial
strokes; at dock size the strokes merged into the ring and it read as an X.
-->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128">
<defs>
<linearGradient id="prism" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#82aaff"/>
<stop offset="55%" stop-color="#9b8ed8"/>
<stop offset="100%" stop-color="#b172b0"/>
</linearGradient>
<linearGradient id="tile" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#2b2e45"/>
<stop offset="100%" stop-color="#1e2030"/>
</linearGradient>
<linearGradient id="edge" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#82aaff" stop-opacity="0"/>
<stop offset="22%" stop-color="#82aaff" stop-opacity="0.9"/>
<stop offset="78%" stop-color="#b172b0" stop-opacity="0.9"/>
<stop offset="100%" stop-color="#b172b0" stop-opacity="0"/>
</linearGradient>
</defs>
<rect x="4" y="4" width="120" height="120" rx="28" fill="url(#tile)"/>
<rect x="4.5" y="4.5" width="119" height="119" rx="27.5" fill="none"
stroke="#c8d3f5" stroke-opacity="0.10" stroke-width="1"/>
<path d="M34 6.5 H94" stroke="url(#edge)" stroke-width="1.6" stroke-linecap="round"/>
<path d="M 52.53 29.88 L 56.45 18.62 L 71.55 18.62 L 75.47 29.88 L 80.02 31.76 L 90.75 26.57 L 101.43 37.25 L 96.24 47.98 L 98.12 52.53 L 109.38 56.45 L 109.38 71.55 L 98.12 75.47 L 96.24 80.02 L 101.43 90.75 L 90.75 101.43 L 80.02 96.24 L 75.47 98.12 L 71.55 109.38 L 56.45 109.38 L 52.53 98.12 L 47.98 96.24 L 37.25 101.43 L 26.57 90.75 L 31.76 80.02 L 29.88 75.47 L 18.62 71.55 L 18.62 56.45 L 29.88 52.53 L 31.76 47.98 L 26.57 37.25 L 37.25 26.57 L 47.98 31.76 Z" fill="url(#prism)" stroke="url(#prism)" stroke-width="5"
stroke-linejoin="round"/>
<circle cx="64" cy="64" r="15" fill="#1e2030"/>
<circle cx="64" cy="64" r="15" fill="none" stroke="#c8d3f5" stroke-opacity="0.18" stroke-width="1.5"/>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

@@ -0,0 +1,30 @@
{
"name": "panama-search",
"title": "Panama Search",
"description": "Web search from the launcher, with live suggestions and bang syntax",
"categories": ["Web"],
"license": "MIT",
"author": "gib",
"icon": "extension_icon.svg",
"commands": [
{
"name": "search",
"title": "Search the web",
"subtitle": "Panama",
"description": "Search with live suggestions, opened in the default browser",
"mode": "view"
}
],
"preferences": [],
"scripts": {
"build": "vici build",
"dev": "vici develop"
},
"dependencies": {
"@vicinae/api": "^0.8.2"
},
"devDependencies": {
"@types/react": "^19.0.0",
"typescript": "^5.9.2"
}
}
@@ -0,0 +1,121 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Action, ActionPanel, Icon, List } from "@vicinae/api";
// Where a search goes. A client-side bang redirector: it resolves
// DuckDuckGo-style bangs in the browser rather than round-tripping through a
// search engine to be bounced, and falls through to an ordinary search when
// there is no bang. Bang support is a property of this URL, not of this
// extension, which is why there is no bang parsing below.
const ENGINE = "https://bang.gibbyb.com/?q=";
// The suggestion endpoint Firefox's address bar uses. Answers with
// [query, [suggestion, ...], ...] and needs no key.
const SUGGEST = "https://suggestqueries.google.com/complete/search?client=firefox&q=";
// Long enough that typing a word is one request rather than one per keystroke,
// short enough that the list still feels attached to the keyboard.
const DEBOUNCE_MS = 150;
const searchUrl = (query: string) => ENGINE + encodeURIComponent(query);
export default function SearchCommand() {
const [query, setQuery] = useState("");
const [suggestions, setSuggestions] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
// Both are refs rather than state: changing them must not re-render, and the
// cleanup below needs whatever the latest one is, not the one captured when
// an effect happened to run.
const inFlight = useRef<AbortController | null>(null);
const debounce = useRef<ReturnType<typeof setTimeout> | null>(null);
// Typing is faster than the network, so responses can arrive out of order. An
// older, slower answer landing after a newer one would leave the list showing
// suggestions for a query that is no longer on screen -- so each new keystroke
// aborts the request before it.
useEffect(() => () => {
inFlight.current?.abort();
if (debounce.current) clearTimeout(debounce.current);
}, []);
const onSearchTextChange = useCallback((text: string) => {
setQuery(text);
if (debounce.current) clearTimeout(debounce.current);
inFlight.current?.abort();
const trimmed = text.trim();
// A bang says *where* to search rather than what for, so Google's guesses
// about it are noise: "!yt" suggests nothing anybody wants.
if (trimmed === "" || trimmed.startsWith("!")) {
setSuggestions([]);
setLoading(false);
return;
}
setLoading(true);
debounce.current = setTimeout(async () => {
const controller = new AbortController();
inFlight.current = controller;
try {
const response = await fetch(SUGGEST + encodeURIComponent(trimmed), {
signal: controller.signal,
});
const body = (await response.json()) as unknown;
const returned = Array.isArray(body) ? body[1] : undefined;
setSuggestions(
Array.isArray(returned)
? returned.filter((entry): entry is string => typeof entry === "string")
: [],
);
} catch {
// Offline, rate-limited, or aborted by the next keystroke. The typed
// query is still searchable, so this costs suggestions rather than the
// command -- which is the right way round for something you reach for
// when you already know what you want.
setSuggestions([]);
} finally {
if (inFlight.current === controller) setLoading(false);
}
}, DEBOUNCE_MS);
}, []);
const trimmed = query.trim();
return (
<List
isLoading={loading}
onSearchTextChange={onSearchTextChange}
searchBarPlaceholder="Search, or !bang to jump straight there"
>
{trimmed !== "" && (
<List.Item
title={trimmed}
subtitle={trimmed.startsWith("!") ? "Bang" : "Search"}
icon={Icon.MagnifyingGlass}
actions={
<ActionPanel>
<Action.OpenInBrowser title="Search" url={searchUrl(trimmed)} />
</ActionPanel>
}
/>
)}
<List.Section title="Suggestions">
{suggestions
.filter((suggestion) => suggestion !== trimmed)
.map((suggestion) => (
<List.Item
key={suggestion}
title={suggestion}
icon={Icon.MagnifyingGlass}
actions={
<ActionPanel>
<Action.OpenInBrowser title="Search" url={searchUrl(suggestion)} />
</ActionPanel>
}
/>
))}
</List.Section>
</List>
);
}
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"lib": ["ES2023"],
"module": "ESNext",
"target": "ES2022",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"isolatedModules": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*"]
}
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# @vicinae.schemaVersion 1
# @vicinae.title Search the web
# @vicinae.mode silent
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
# @vicinae.description Search from the launcher, with bang syntax, in the default browser.
# @vicinae.keywords ["search", "web", "google", "bang", "find"]
# @vicinae.argument1 { "type": "text", "placeholder": "search, or !bang to jump", "percentEncoded": true }
# Search without leaving the launcher.
#
# Make this the fallback command -- "Manage fallback commands" in Vicinae -- and
# anything typed that matches nothing else offers this at the bottom of the
# results. That designation is per-machine state Vicinae keeps in its own
# database, so it is one click rather than something Panama ships.
#
# The engine is a client-side bang redirector: it resolves DuckDuckGo-style
# bangs in the browser rather than round-tripping through a search engine to be
# redirected, and falls through to a normal search when there is no bang. So
# `!yt tiling` reaches YouTube directly, and bang support costs nothing here --
# it is a property of where this points, not of the launcher.
#
# percentEncoded on the argument means Vicinae URL-encodes the query before it
# arrives, which is what keeps `&`, `#` and spaces from truncating the search.
#
# xdg-open rather than a named browser: the default browser is already a setting
# this desktop owns, on the Applications page, and naming one here would quietly
# outrank it.
exec xdg-open "https://bang.gibbyb.com/?q=$1"
@@ -5,6 +5,6 @@
# @vicinae.mode silent # @vicinae.mode silent
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg # @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
# @vicinae.description Open Displays in Settings. # @vicinae.description Open Displays in Settings.
# @vicinae.keywords ["settings", "game-aware hdr", "variable refresh rate", "direct scanout", "night light", "schedule automatically", "color temperature", "turns on at", "turns off at", "arrange displays", "monitor position", "primary display"] # @vicinae.keywords ["settings", "workspaces on the primary display only", "game-aware hdr", "variable refresh rate", "direct scanout", "night light", "schedule automatically", "color temperature", "turns on at", "turns off at", "arrange displays", "monitor position", "primary display"]
exec "$HOME/.config/quickshell/scripts/panama-action" settings-page displays exec "$HOME/.config/quickshell/scripts/panama-action" settings-page displays
+6 -2
View File
@@ -4,7 +4,7 @@
Do not edit this file. Run `quickshell/scripts/panama-settings-docs` Do not edit this file. Run `quickshell/scripts/panama-settings-docs`
after changing the schema; a contract fails when this copy is stale. after changing the schema; a contract fails when this copy is stale.
133 settings across 27 groups. 67 of them are applied to the compositor and confirmed by reading the value back. 137 settings across 27 groups. 70 of them are applied to the compositor and confirmed by reading the value back.
## accessibility ## accessibility
@@ -55,6 +55,7 @@ Found on **Displays**.
| Setting | Default | What it does | | Setting | Default | What it does |
|---|---|---| |---|---|---|
| **Workspaces on the primary display only**<br>`workspacesOnPrimaryOnly` | false | Other screens keep one workspace of their own rather than switching along with it |
| **Game-aware HDR**<br>`autoHdr` `render:cm_auto_hdr` | true | Hand HDR to fullscreen games while the desktop stays SDR | | **Game-aware HDR**<br>`autoHdr` `render:cm_auto_hdr` | true | Hand HDR to fullscreen games while the desktop stays SDR |
| **Variable refresh rate**<br>`vrrPolicy` `misc:vrr` | 3 | Matches the display's refresh rate to what is on screen Choices: Off, Always on, Fullscreen only, Fullscreen games. | | **Variable refresh rate**<br>`vrrPolicy` `misc:vrr` | 3 | Matches the display's refresh rate to what is on screen Choices: Off, Always on, Fullscreen only, Fullscreen games. |
| **Direct scanout**<br>`directScanoutPolicy` `render:direct_scanout` | 2 | Lets fullscreen content bypass compositing Choices: Off, Always on, Automatic. | | **Direct scanout**<br>`directScanoutPolicy` `render:direct_scanout` | 2 | Lets fullscreen content bypass compositing Choices: Off, Always on, Automatic. |
@@ -113,7 +114,7 @@ Found on **Desktop & Dock**.
## gaming ## gaming
Found on **gaming**. Found on **Gaming**.
| Setting | Default | What it does | | Setting | Default | What it does |
|---|---|---| |---|---|---|
@@ -181,6 +182,7 @@ Found on **Desktop & Dock**.
| **Switch back and forth**<br>`workspaceBackAndForth` `binds:workspace_back_and_forth` | false | Selecting the workspace you are already on returns you to the previous one | | **Switch back and forth**<br>`workspaceBackAndForth` `binds:workspace_back_and_forth` | false | Selecting the workspace you are already on returns you to the previous one |
| **Wrap around at the ends**<br>`allowWorkspaceCycles` `binds:allow_workspace_cycles` | false | Moving past the last workspace continues from the first | | **Wrap around at the ends**<br>`allowWorkspaceCycles` `binds:allow_workspace_cycles` | false | Moving past the last workspace continues from the first |
| **Let applications take focus**<br>`focusOnActivate` `misc:focus_on_activate` | false | An application asking for attention is switched to, rather than only highlighted | | **Let applications take focus**<br>`focusOnActivate` `misc:focus_on_activate` | false | An application asking for attention is switched to, rather than only highlighted |
| **Hide the terminal that launched a window**<br>`windowSwallow` `misc:enable_swallow` | false | A terminal disappears while an application started from it is open, and returns when it closes |
| **Pointer changes active display**<br>`mouseMoveFocusesMonitor` `misc:mouse_move_focuses_monitor` | true | Moving the pointer to another display makes it the active one | | **Pointer changes active display**<br>`mouseMoveFocusesMonitor` `misc:mouse_move_focuses_monitor` | true | Moving the pointer to another display makes it the active one |
## nightLight ## nightLight
@@ -263,6 +265,8 @@ Found on **Mouse & Touchpad**.
| **Scroll speed**<br>`touchpadScrollFactor` `input:touchpad:scroll_factor` | 1.0 | Multiplies how far a two-finger scroll travels. Range 0.1–4.0. | | **Scroll speed**<br>`touchpadScrollFactor` `input:touchpad:scroll_factor` | 1.0 | Multiplies how far a two-finger scroll travels. Range 0.1–4.0. |
| **Drag lock**<br>`touchpadDragLock` `input:touchpad:drag_lock` | 0 | Keeps a tap-and-drag active when you lift a finger mid-drag Choices: Off, On, On, until you tap again. | | **Drag lock**<br>`touchpadDragLock` `input:touchpad:drag_lock` | 0 | Keeps a tap-and-drag active when you lift a finger mid-drag Choices: Off, On, On, until you tap again. |
| **Middle-click by pressing both buttons**<br>`touchpadMiddleButtonEmulation` `input:touchpad:middle_button_emulation` | false | Pressing left and right together acts as a middle click | | **Middle-click by pressing both buttons**<br>`touchpadMiddleButtonEmulation` `input:touchpad:middle_button_emulation` | false | Pressing left and right together acts as a middle click |
| **Swipe distance**<br>`swipeDistance` `gestures:workspace_swipe_distance` | 300 px | How far a three-finger swipe must travel to change workspace. Range 100–800. |
| **Natural swipe direction**<br>`swipeInvert` `gestures:workspace_swipe_invert` | true | Swiping left moves to the workspace on the right, as content follows your fingers |
## typography ## typography
+19
View File
@@ -0,0 +1,19 @@
# ChatGPT Desktop.
#
# OpenAI ships macOS and Windows only. This is a community wrapper that converts
# the upstream macOS disk image into a Linux Electron app and packages it as an
# RPM, so the installed result is again something dnf owns.
#
# Same exception, same reason: there is no packaged form to prefer. Nothing is
# pinned; `bootstrap-native` fetches the current upstream image each time and
# fails loudly when it cannot.
description="ChatGPT Desktop, built into a Fedora RPM"
repo="https://github.com/ilysenko/codex-desktop-linux.git"
# bootstrap-native installs build dependencies, builds, packages, and installs
# the newest artifact -- so unlike the Claude build there is no separate install
# step to do here.
build() {
make bootstrap-native
}
+28
View File
@@ -0,0 +1,28 @@
# Claude Desktop.
#
# Anthropic ships macOS and Windows only. This is a community wrapper that
# downloads the official build, bundles Electron, and packages the result as a
# proper Fedora RPM -- so what lands on the system is still a package dnf owns
# and can remove, which is the part of the dnf/flatpak rule that matters most.
#
# The exception it needs is the build itself. There is no RPM or flatpak of
# Claude Desktop to install, so the choice is building one or not having it.
# Nothing is pinned: the default branch is built every time, and a failure is
# reported rather than worked around. sunhat pinned versions and every pin was
# a 404 within a release cycle.
description="Claude Desktop, built into a Fedora RPM"
repo="https://github.com/dewzor/claude-desktop-fedora.git"
# The upstream script installs its own build dependencies and prints the RPM it
# produced. It needs root because of that dependency install.
build() {
sudo ./build-fedora.sh
local rpm
rpm="$(find build -name 'claude-desktop-*.rpm' -newermt '-1 hour' 2>/dev/null | head -1)"
[[ -n "$rpm" ]] || rpm="$(find . -name 'claude-desktop-*.rpm' 2>/dev/null | head -1)"
[[ -n "$rpm" ]] || { printf 'the build produced no RPM\n' >&2; return 1; }
sudo dnf install -y "$rpm"
}
+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env bash
# Reading the optional-application catalog.
#
# Sourced by both front doors -- the interview's checklist during ./install, and
# `panama apps` afterwards -- so the two can never disagree about what exists.
# One catalog, parsed once, here.
#
# The format is one file per category under setup/packages/extras/, and a line
# is one of:
#
# gimp a dnf package
# flatpak:org.gimp.GIMP a Flathub id
# flatpak:com.slack.Slack | Slack either, with a name for the menu
# flatpak:com.obsproject.Studio.Plugin.SceneSwitcher
#
# An indented line belongs to the entry above it and is installed with it, which
# is how OBS carries its sixteen plugin extensions as a single thing to tick
# rather than seventeen. Comments and blank lines are ignored.
#
# Without an explicit name, the menu derives one from the last dotted component
# of a Flathub id, or uses the package name as it stands.
# Every category name, one per line.
catalog_categories() {
local dir="$1" file
for file in "$dir"/*; do
[[ -f "$file" ]] && basename "$file"
done
}
# The selectable entries in one category, as `target<TAB>label`. Indented
# continuation lines are folded into the entry above and never listed.
catalog_entries() {
local file="$1" line target label
while IFS= read -r line; do
line="${line%%#*}"
[[ "$line" =~ ^[[:space:]]*$ ]] && continue
[[ "$line" =~ ^[[:space:]] ]] && continue
if [[ "$line" == *"|"* ]]; then
target="${line%%|*}"
label="${line#*|}"
else
target="$line"
label=""
fi
target="${target#"${target%%[![:space:]]*}"}"; target="${target%"${target##*[![:space:]]}"}"
label="${label#"${label%%[![:space:]]*}"}"; label="${label%"${label##*[![:space:]]}"}"
if [[ -z "$label" ]]; then
label="${target#flatpak:}"
[[ "$target" == flatpak:* ]] && label="${label##*.}"
fi
printf '%s\t%s\n' "$target" "$label"
done <"$file"
}
# Every install target for one entry: the entry itself, then the indented lines
# beneath it. Called with the category file and the entry's target.
catalog_targets() {
local file="$1" want="$2" line target seen=0
while IFS= read -r line; do
line="${line%%#*}"
[[ "$line" =~ ^[[:space:]]*$ ]] && continue
if [[ "$line" =~ ^[[:space:]] ]]; then
(( seen == 1 )) || continue
line="${line#"${line%%[![:space:]]*}"}"
printf '%s\n' "${line%%|*}" | sed 's/[[:space:]]*$//'
continue
fi
target="${line%%|*}"
target="${target#"${target%%[![:space:]]*}"}"; target="${target%"${target##*[![:space:]]}"}"
if [[ "$target" == "$want" ]]; then
seen=1
printf '%s\n' "$target"
elif (( seen == 1 )); then
break
fi
done <"$file"
}
# Every install target in a whole category, in file order.
catalog_all_targets() {
local file="$1" line
while IFS= read -r line; do
line="${line%%#*}"
[[ "$line" =~ ^[[:space:]]*$ ]] && continue
line="${line#"${line%%[![:space:]]*}"}"
line="${line%%|*}"
printf '%s\n' "${line%"${line##*[![:space:]]}"}"
done <"$file"
}
# Is this target already on the machine? Used to mark the menu, never to skip
# silently -- a re-run that reinstalls what is present is harmless, but a menu
# that hides it is confusing.
catalog_installed() {
local target="$1"
if [[ "$target" == flatpak:* ]]; then
flatpak info "${target#flatpak:}" >/dev/null 2>&1
else
rpm -q "$target" >/dev/null 2>&1
fi
}
+5
View File
@@ -62,6 +62,11 @@ mscore-fonts
nautilus nautilus
nautilus-extensions nautilus-extensions
nautilus-python nautilus-python
# Ships both the Nautilus "Open in Terminal" extension and its gsettings
# schema. Panama used to copy its own fork of the extension over the same
# path, which left a fresh machine with the extension and no schema -- and
# the fork had drifted 63 lines behind the packaged one.
nautilus-open-any-terminal
nextcloud-client nextcloud-client
openssl-devel openssl-devel
opus-devel opus-devel
+4 -3
View File
@@ -13,12 +13,13 @@ ImageMagick
java-latest-openjdk-devel java-latest-openjdk-devel
luarocks luarocks
maven maven
nodejs # Node is installed through nvm rather than dnf, because config/bash/shell
nodejs-npm # switches version per project from .nvmrc and a system Node earlier on PATH
# would win every switch. install-packages does the rest.
nvm
pipx pipx
php php
php-fpm php-fpm
pnpm
# Rootless containers, and the backend for the Containers settings page. # Rootless containers, and the backend for the Containers settings page.
podman podman
python3-devel python3-devel
+5 -4
View File
@@ -1,8 +1,9 @@
# Chat and messaging. Every one of these is Flathub only -- none is packaged # Chat, mail and calls. Every one of these is Flathub only -- none is packaged
# for Fedora, and each publishes its own flatpak. # for Fedora, and each publishes its own flatpak.
# #
# Thunderbird is deliberately absent: it is already in flatpak-packages as # Thunderbird is deliberately absent: it is already in flatpak-packages as
# org.mozilla.thunderbird_esr, which every machine gets. # org.mozilla.thunderbird_esr, which every machine gets.
flatpak:com.discordapp.Discord flatpak:com.discordapp.Discord | Discord
flatpak:com.slack.Slack flatpak:com.slack.Slack | Slack
flatpak:org.signal.Signal flatpak:org.signal.Signal | Signal
flatpak:us.zoom.Zoom | Zoom
+32 -6
View File
@@ -1,7 +1,33 @@
# Images, video and audio. All from dnf or RPM Fusion. # Images, video and audio.
gimp #
# Flatpaks rather than the dnf builds these used to name. That was not a
# preference: OBS's plugins are published as Flathub extensions and attach only
# to the flatpak, so the dnf build cannot have them at all. The rest follow so
# one machine does not end up with GIMP from dnf and its neighbour from Flathub.
flatpak:org.gimp.GIMP | GIMP
flatpak:org.kde.kdenlive | Kdenlive
flatpak:fr.handbrake.ghb | HandBrake
flatpak:com.orama_interactive.Pixelorama | Pixelorama
flatpak:org.gnome.gitlab.YaLTeR.VideoTrimmer | Video Trimmer
flatpak:org.gnome.gThumb | gThumb
# Video editing, screen capture, and transcoding what comes out of them. # OBS and the plugins that make it usable for capture on Wayland. Ticking this
kdenlive # installs all of them: they are extensions of the OBS flatpak, useless on their
obs-studio # own, and choosing them one at a time is a menu nobody wants to read.
HandBrake flatpak:com.obsproject.Studio | OBS Studio
flatpak:com.obsproject.Studio.Plugin.AdvancedMasks
flatpak:com.obsproject.Studio.Plugin.BackgroundRemoval
flatpak:com.obsproject.Studio.Plugin.CompositeBlur
flatpak:com.obsproject.Studio.Plugin.DownstreamKeyer
flatpak:com.obsproject.Studio.Plugin.GStreamerVaapi
flatpak:com.obsproject.Studio.Plugin.Gstreamer
flatpak:com.obsproject.Studio.Plugin.InputOverlay
flatpak:com.obsproject.Studio.Plugin.MoveTransition
flatpak:com.obsproject.Studio.Plugin.OBSPWVideo
flatpak:com.obsproject.Studio.Plugin.OBSVkCapture
flatpak:com.obsproject.Studio.Plugin.PipeWireAudioCapture
flatpak:com.obsproject.Studio.Plugin.SceneSwitcher
flatpak:com.obsproject.Studio.Plugin.Shaderfilter
flatpak:com.obsproject.Studio.Plugin.SourceClone
flatpak:com.obsproject.Studio.Plugin.SourceRecord
flatpak:com.obsproject.Studio.Plugin.WaylandHotkeys
+13 -3
View File
@@ -1,5 +1,5 @@
# Games and the things that run them. A work laptop declines this whole # Games and the things that run them. A work laptop declines this whole
# category; a desktop wants all of it. # category; a desktop wants most of it.
# #
# steam is in RPM Fusion nonfree, which install-packages enables before it # steam is in RPM Fusion nonfree, which install-packages enables before it
# reads this file. # reads this file.
@@ -10,5 +10,15 @@ lutris
mangohud mangohud
gamescope gamescope
# Proton and Wine build management for Steam. Flathub only -- there is no RPM. # Proton and Wine build management. Flathub only -- there is no RPM.
flatpak:com.vysp3r.ProtonPlus flatpak:com.vysp3r.ProtonPlus | ProtonPlus
flatpak:com.usebottles.bottles | Bottles
flatpak:com.mojang.Minecraft | Minecraft
flatpak:com.moonlight_stream.Moonlight | Moonlight
# Flathub still carries yuzu and it still installs, but it is marked
# end-of-life upstream and receives no further work. Kept because it is on the
# machine this catalog was seeded from; said out loud so choosing it is a
# decision rather than a surprise.
flatpak:org.yuzu_emu.yuzu | yuzu (unmaintained)
+5
View File
@@ -0,0 +1,5 @@
# CAD, slicing and game engines -- the things that produce files rather than
# consume them.
flatpak:org.freecad.FreeCAD | FreeCAD
flatpak:com.bambulab.BambuStudio | Bambu Studio
flatpak:org.godotengine.Godot | Godot
+9
View File
@@ -0,0 +1,9 @@
# Documents, notes and media playback.
#
# LibreOffice is the flatpak rather than Fedora's split packages: sunhat
# installed writer, calc and impress separately and got three-quarters of a
# suite, because the one nobody listed was the one eventually needed.
flatpak:org.libreoffice.LibreOffice | LibreOffice
flatpak:md.obsidian.Obsidian | Obsidian
flatpak:com.spotify.Client | Spotify
flatpak:tv.plex.PlexDesktop | Plex
+11
View File
@@ -0,0 +1,11 @@
# Small tools for managing the machine itself.
#
# Flatseal edits flatpak permissions, Gear Lever manages AppImages, and
# Extension Manager is for the GNOME session -- which Panama no longer installs,
# so it is here to be chosen rather than in any core list.
flatpak:com.github.tchx84.Flatseal | Flatseal
flatpak:it.mijorus.gearlever | Gear Lever
flatpak:com.mattjakeman.ExtensionManager | Extension Manager
flatpak:org.localsend.localsend_app | LocalSend
flatpak:org.torproject.torbrowser-launcher | Tor Browser
flatpak:org.getmonero.Monero | Monero
+42 -2
View File
@@ -25,6 +25,11 @@ packages_in() {
# --- Defined Paths --- # --- Defined Paths ---
PANAMA_PATH="$HOME/.local/share/Panama" PANAMA_PATH="$HOME/.local/share/Panama"
# Reading the extras catalog, shared with `panama apps` so the two front doors
# cannot disagree about what a category contains.
# shellcheck source=../lib/extras-catalog
source "$PANAMA_PATH/setup/lib/extras-catalog"
echo -e "\n--- Installing Repositories ---" echo -e "\n--- Installing Repositories ---"
log "Installing RPM Fusion Free and Nonfree Repositories" log "Installing RPM Fusion Free and Nonfree Repositories"
sudo dnf install -y https://mirrors.rpmfusion.org/free/fedora/rpmfusion-free-release-$(rpm -E %fedora).noarch.rpm https://mirrors.rpmfusion.org/nonfree/fedora/rpmfusion-nonfree-release-$(rpm -E %fedora).noarch.rpm > /dev/null sudo dnf install -y https://mirrors.rpmfusion.org/free/fedora/rpmfusion-free-release-$(rpm -E %fedora).noarch.rpm https://mirrors.rpmfusion.org/nonfree/fedora/rpmfusion-nonfree-release-$(rpm -E %fedora).noarch.rpm > /dev/null
@@ -98,6 +103,36 @@ else
log "Package list was not in specified path: $DEV_FILE" log "Package list was not in specified path: $DEV_FILE"
fi fi
# --- Node and pnpm, through nvm ----------------------------------------------
#
# nvm is a shell function rather than a binary, so it has to be sourced before
# it can be used at all -- and its script reads variables that `set -u` above
# treats as fatal, so the strictness is lifted for exactly that source and put
# straight back.
#
# Deliberately not dnf's nodejs: config/bash/shell switches Node per project
# from .nvmrc, and a system Node earlier on PATH would win every switch, leaving
# `nvm use` looking like it did nothing.
#
# pnpm goes inside the nvm-managed Node rather than beside it as its own dnf
# package, so it travels with the version it belongs to instead of outliving it.
if [[ -s /etc/profile.d/nvm.sh ]]; then
log "Installing the latest Node LTS through nvm"
set +u
# shellcheck source=/dev/null
source /etc/profile.d/nvm.sh
if nvm install --lts >/dev/null 2>&1; then
nvm alias default 'lts/*' >/dev/null 2>&1 || true
npm install -g pnpm >/dev/null 2>&1 || log "pnpm did not install"
log "Node $(node --version 2>/dev/null) with pnpm $(pnpm --version 2>/dev/null)"
else
log "nvm could not install Node; skipping"
fi
set -u
else
log "nvm is not installed, so Node was not set up"
fi
# --- Install the Hyprland desktop --- # --- Install the Hyprland desktop ---
# Most of these live in the lionheartp/Hyprland COPR rather than Fedora proper. # Most of these live in the lionheartp/Hyprland COPR rather than Fedora proper.
HYPR_FILE="$PANAMA_PATH/setup/packages/hyprland-packages" HYPR_FILE="$PANAMA_PATH/setup/packages/hyprland-packages"
@@ -197,6 +232,11 @@ fi
# package and a `flatpak:` line is a Flathub ID, so one file per category holds # package and a `flatpak:` line is a Flathub ID, so one file per category holds
# the whole answer rather than splitting each category across two. # the whole answer rather than splitting each category across two.
# #
# Reading the file is setup/lib/extras-catalog's job, not this function's, because
# `panama apps` offers the same catalog from the other side. Two parsers would
# eventually disagree about what a category contains, and the one that disagreed
# quietly would be this one -- it runs unattended.
#
# Neither install is fatal. A category is a set of applications somebody wanted, # Neither install is fatal. A category is a set of applications somebody wanted,
# not a dependency of the desktop, and losing the rest of the run because one of # not a dependency of the desktop, and losing the rest of the run because one of
# them was renamed upstream would be the wrong trade. # them was renamed upstream would be the wrong trade.
@@ -205,8 +245,8 @@ install_extra_category() {
name="$(basename "$file")" name="$(basename "$file")"
local dnf_packages flatpak_ids local dnf_packages flatpak_ids
dnf_packages=$(packages_in "$file" | tr ' ' '\n' | grep -v '^flatpak:' | tr "\n" " ") dnf_packages=$(catalog_all_targets "$file" | grep -v '^flatpak:' | tr "\n" " ")
flatpak_ids=$(packages_in "$file" | tr ' ' '\n' | sed -n 's/^flatpak://p' | tr "\n" " ") flatpak_ids=$(catalog_all_targets "$file" | sed -n 's/^flatpak://p' | tr "\n" " ")
if [[ -n "${dnf_packages// /}" ]]; then if [[ -n "${dnf_packages// /}" ]]; then
log "Installing $name: $dnf_packages" log "Installing $name: $dnf_packages"
+37
View File
@@ -46,6 +46,43 @@ fi
ln -s "$source_dir" "$target_dir" ln -s "$source_dir" "$target_dir"
# ── Extensions ───────────────────────────────────────────────────────────────
#
# Script commands are a file and a shebang, so they are linked. Extensions are
# not: they are TypeScript that has to be compiled, and `vici build` writes its
# output straight into Vicinae's data directory rather than leaving a bundle to
# link. So the source lives in this repository and the build is what installs
# it.
#
# Never fatal, and never a reason to fail a stage. A build wants npm and the
# network, and neither is guaranteed at this point in an install -- npm arrives
# with nvm earlier in install-packages, which can itself be skipped. An
# extension that did not build is a launcher missing one command, not a desktop
# that failed to install.
extensions_source="$panama_path/config/local/share/vicinae/extensions"
if [[ -d "$extensions_source" ]] && command -v npm >/dev/null 2>&1; then
for extension in "$extensions_source"/*/; do
[[ -f "$extension/package.json" ]] || continue
name="$(basename "$extension")"
# Skip a build that would produce what is already there. `npm install`
# alone takes long enough to be worth not repeating on every re-run of
# a stage that is otherwise nearly instant.
built="$vicinae_data_dir/extensions/$name"
if [[ -d "$built" && "$extension/src" -ot "$built" ]]; then
printf 'Vicinae extension %s is already built\n' "$name"
continue
fi
printf 'Building Vicinae extension %s\n' "$name"
if ! (cd "$extension" && npm install --silent >/dev/null 2>&1 && npm run build >/dev/null 2>&1); then
printf 'Vicinae extension %s did not build; skipping\n' "$name" >&2
fi
done
elif [[ -d "$extensions_source" ]]; then
printf 'npm is not available, so Vicinae extensions were not built\n' >&2
fi
# The server also rescans periodically, but an explicit reload makes a setup # The server also rescans periodically, but an explicit reload makes a setup
# run deterministic. If Vicinae is not active yet, its startup scan is enough. # run deterministic. If Vicinae is not active yet, its startup scan is enough.
if command -v vicinae >/dev/null 2>&1 && vicinae ping >/dev/null 2>&1; then if command -v vicinae >/dev/null 2>&1 && vicinae ping >/dev/null 2>&1; then
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env bash
# The touchpad gestures, and the window swallowing beside them.
#
# Both are behaviour that only exists on hardware this machine may not have, so
# neither can be checked by running it. What can be pinned is the shape:
#
# * Three gestures, mirroring GNOME. Sideways moves workspaces, up opens the
# overview, down closes it.
# * Up and down do not both toggle. That is the obvious way to write it and it
# is wrong: swiping up from an open overview would close it, and swiping
# down would reopen it, which is the opposite of what the fingers mean.
# * Swallowing is off by default and driven by a preference. Turning it on for
# everybody would make terminals appear to vanish on a machine nobody asked.
# * The swallow regex names only terminals this desktop ships. Anything the
# pattern matches can swallow, so a broad pattern is windows disappearing in
# cases nobody intended.
set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
input="$repo_dir/config/dot/hypr/input.lua"
looks="$repo_dir/config/dot/hypr/looks.lua"
schema="$repo_dir/config/dot/quickshell/config/PreferenceSchema.qml"
findings=()
note() { findings+=("$1"); }
# ── Gestures ─────────────────────────────────────────────────────────────────
gestures="$(grep -c 'hl\.gesture({' "$input" || true)"
(( gestures == 3 )) || note "input.lua registers $gestures gestures, not the 3 that mirror GNOME"
grep -qE 'fingers = 3, direction = "horizontal",[[:space:]]*action = "workspace"' "$input" \
|| note 'three fingers sideways does not switch workspaces'
grep -qE 'fingers = 3, direction = "up",[[:space:]]*action = overview\("open"\)' "$input" \
|| note 'three fingers up does not open the overview'
grep -qE 'fingers = 3, direction = "down",[[:space:]]*action = overview\("close"\)' "$input" \
|| note 'three fingers down does not close the overview'
# The specific mistake worth a test of its own.
if grep -qE 'direction = "(up|down)",[[:space:]]*action = overview\("toggle"\)' "$input"; then
note 'a vertical gesture toggles the overview, so swiping the same way twice undoes itself'
fi
# The tuning that Hyprland does accept at runtime has to be reachable, or the
# gestures are unadjustable without editing this file -- which is the thing
# Settings exists to avoid.
for key in swipeDistance swipeInvert; do
grep -q "key: \"$key\"" "$schema" \
|| note "$key is not a preference, so the gestures cannot be tuned from Settings"
done
# ── Swallowing ───────────────────────────────────────────────────────────────
grep -qE 'enable_swallow = prefs\.get\("windowSwallow", false\)' "$looks" \
|| note 'window swallowing is not preference-driven and off by default'
regex_line="$(grep -E '^\s*swallow_regex' "$looks" || true)"
[[ -n "$regex_line" ]] || note 'swallowing is enabled with no regex, so nothing can ever swallow'
# Anchored at both ends: an unanchored pattern matches any class containing the
# name, which is a much larger set than the one intended.
grep -qE 'swallow_regex\s*=\s*"\^\(.*\)\$"' "$looks" \
|| note 'the swallow regex is not anchored, so it matches more window classes than it names'
for terminal in kitty ghostty; do
grep -q "$terminal" <<<"$regex_line" \
|| note "the swallow regex does not cover $terminal, which this desktop ships"
done
# Whatever the regex names has to be something the machine will actually have.
declared="$(cat "$repo_dir"/setup/packages/* 2>/dev/null | sed 's/#.*//' | tr -d ' ' | grep -v '^$')"
for terminal in kitty ghostty; do
grep -qix "$terminal" <<<"$declared" \
|| note "the swallow regex names $terminal, which no package list installs"
done
# ── Report ───────────────────────────────────────────────────────────────────
if (( ${#findings[@]} > 0 )); then
mapfile -t findings < <(printf '%s\n' "${findings[@]}" | sort -u)
printf 'gestures contract: %d finding(s)\n' "${#findings[@]}" >&2
printf ' - %s\n' "${findings[@]}" >&2
exit 1
fi
printf 'gestures contract: PASS\n'
+125
View File
@@ -0,0 +1,125 @@
#!/usr/bin/env bash
# Workspaces on the primary display only.
#
# monitors.lua turns one preference into workspace rules, and the rules are the
# part that cannot be taken back: Hyprland reads them at config time and offers
# no way to remove one afterwards -- an empty monitor leaves the old binding in
# place, which was checked rather than assumed. Only a reload clears them, so
# what this file emits IS the state of the desktop, and emitting one rule too
# many strands a workspace on a screen until the next reload.
#
# The Lua is exercised with a stubbed `hl`, so the rules can be counted without
# a compositor and without touching the running desktop.
set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
hypr_dir="$repo_dir/config/dot/hypr"
schema="$repo_dir/config/dot/quickshell/config/PreferenceSchema.qml"
page="$repo_dir/config/dot/quickshell/modules/settings/DisplaysPage.qml"
service="$repo_dir/config/dot/quickshell/services/Workspaces.qml"
keybinds="$hypr_dir/keybinds.lua"
findings=()
note() { findings+=("$1"); }
command -v lua >/dev/null 2>&1 || {
printf 'workspace rules contract: lua is not installed\n' >&2
exit 1
}
work="$(mktemp -d /tmp/panama-workspace-rules.XXXXXX)"
trap 'rm -rf "$work"' EXIT
mkdir -p "$work/config/panama"
# Every workspace rule monitors.lua emits for a given settings file, as
# "workspace monitor" lines. hl.monitor is swallowed: this is about workspaces,
# and a monitor call is not one.
emit() {
printf '%s' "$1" >"$work/config/panama/settings.json"
XDG_CONFIG_HOME="$work/config" lua -e "
package.path = '$hypr_dir/?.lua;' .. package.path
hl = {
monitor = function() end,
workspace_rule = function(rule)
print(tostring(rule.workspace) .. ' ' .. tostring(rule.monitor))
end,
}
dofile('$hypr_dir/monitors.lua')
" 2>/dev/null
}
PRIMARY='{"displays":{"DP-2":{"mode":"4500x3000@60","scale":1.5,"transform":0,"x":0,"y":0,"primary":true}}'
# ── Off: Hyprland's own behaviour, which needs no rules at all ────────────────
off="$(emit "$PRIMARY,\"workspacesOnPrimaryOnly\":false}")"
[[ -z "$off" ]] || note "with the setting off, $(wc -l <<<"$off") workspace rules are still emitted"
absent="$(emit "$PRIMARY}")"
[[ -z "$absent" ]] || note 'with the setting absent, workspace rules are emitted anyway'
# ── On: every workspace the keybinds reach, and no more ──────────────────────
on="$(emit "$PRIMARY,\"workspacesOnPrimaryOnly\":true}")"
emitted="$(grep -c . <<<"$on" || true)"
# The count is not a magic number: it is how many workspaces ALT+1..ALT+0 reach.
# If keybinds.lua ever binds a different number, pinning the old count leaves
# some workspaces pinned and others not, which is worse than either.
bound="$(sed -n 's/.*for i = 1, \([0-9]*\) do.*/\1/p' "$keybinds" | head -1)"
[[ -n "$bound" ]] || bound=10
(( emitted == bound )) \
|| note "the setting pins $emitted workspaces but the keybinds reach $bound"
while read -r workspace monitor; do
[[ -n "$workspace" ]] || continue
[[ "$monitor" == "DP-2" ]] \
|| note "workspace $workspace is pinned to '$monitor' rather than the primary display"
done <<<"$on"
# ── On, with nothing to pin to ───────────────────────────────────────────────
#
# A machine can have the preference set and no primary recorded -- it is the
# state this one is in. Guessing a primary would move every workspace onto
# whichever output happened to sort first.
no_primary="$(emit '{"workspacesOnPrimaryOnly":true}')"
[[ -z "$no_primary" ]] \
|| note 'with no primary display recorded, workspaces are pinned to a guess'
# ── The preference cannot pretend to be an option ────────────────────────────
if grep -q 'key: "workspacesOnPrimaryOnly"' "$schema"; then
block="$(sed -n '/key: "workspacesOnPrimaryOnly"/,/^ },/p' "$schema")"
grep -q 'hypr:' <<<"$block" \
&& note 'workspacesOnPrimaryOnly declares a hypr option, but workspace rules are not settable options'
else
note 'workspacesOnPrimaryOnly is not in the schema'
fi
# ── The page tells the truth ─────────────────────────────────────────────────
grep -q 'Displays.monitors.length >= 2' "$page" \
|| note 'the Workspaces card is not hidden on a single-display machine'
# Applied has to be read back from the compositor. Inferring it from the
# preference having been written is how a page comes to claim a setting is in
# effect when it is waiting on a reload.
grep -q 'hyprctl", "-j", "workspacerules' "$service" \
|| note 'the service never reads the compositor, so it cannot know whether the setting took effect'
grep -q '"hyprctl", "reload"' "$service" \
|| note 'the service has no way to apply the setting'
# ── Report ───────────────────────────────────────────────────────────────────
if (( ${#findings[@]} > 0 )); then
mapfile -t findings < <(printf '%s\n' "${findings[@]}" | sort -u)
printf 'workspace rules contract: %d finding(s)\n' "${#findings[@]}" >&2
printf ' - %s\n' "${findings[@]}" >&2
exit 1
fi
printf 'workspace rules contract: PASS (%d workspaces pinned when enabled)\n' "$emitted"
@@ -32,16 +32,20 @@ SHELL_WORDS='^(if|then|else|elif|fi|for|while|until|do|done|case|esac|in|functio
# Provided by any Fedora install: coreutils, util-linux, the shell, and the # Provided by any Fedora install: coreutils, util-linux, the shell, and the
# systemd/session tooling. Nothing here is a choice Panama makes. # systemd/session tooling. Nothing here is a choice Panama makes.
BASELINE='^(sh|bash|cat|cut|sed|awk|gawk|grep|egrep|head|tail|sort|uniq|tr|wc|find|xargs|basename|dirname|mkdir|rm|cp|mv|ln|chmod|chown|stat|df|du|date|sleep|env|id|tee|touch|mktemp|readlink|realpath|seq|comm|join|paste|od|file|nl|fold|column|tput|timeout|flock|install|sha256sum|md5sum|base64|nproc|uptime|free|uname|hostname|whoami|ps|pgrep|pkill|kill|killall|lsblk|mount|umount|sudo|su|rpm|dnf|flatpak|git|python3|ss|ip|lsof)$' #
# authselect is on the list for the same reason: it manages Fedora's PAM and
# nsswitch profiles and arrives with fprintd-pam, realmd and nss-mdns, so the
# fingerprint aliases in config/bash can rely on it without declaring it.
BASELINE='^(sh|bash|cat|cut|sed|awk|gawk|grep|egrep|head|tail|sort|uniq|tr|wc|find|xargs|basename|dirname|mkdir|rm|cp|mv|ln|chmod|chown|stat|df|du|date|sleep|env|id|tee|touch|mktemp|readlink|realpath|seq|comm|join|paste|od|file|nl|fold|column|tput|timeout|flock|install|sha256sum|md5sum|base64|nproc|uptime|free|uname|hostname|whoami|ps|pgrep|pkill|kill|killall|lsblk|mount|umount|sudo|su|rpm|dnf|flatpak|git|python3|ss|ip|lsof|authselect)$'
SESSION='^(systemctl|busctl|journalctl|loginctl|hostnamectl|localectl|systemd-inhibit|systemd-run|udevadm|gsettings|dconf|dbus-send|dbus-monitor|hyprctl|qs|quickshell|gnf|panama|wl-copy|wl-paste)$' SESSION='^(systemctl|busctl|journalctl|loginctl|hostnamectl|localectl|systemd-inhibit|systemd-run|udevadm|gsettings|dconf|dbus-send|dbus-monitor|hyprctl|qs|quickshell|gnf|panama|wl-copy|wl-paste)$'
# Installed by install-packages itself, because no repository carries them. # Installed by install-packages itself rather than by a package list. Two
# They are deliberately absent from the package lists, and install-packages # reasons, both deliberate: bun and claude have no RPM or flatpak at all, and
# probes for them with `command -v` precisely because they arrive out of band -- # node, npm and pnpm come from nvm on purpose -- a dnf nodejs earlier on PATH
# so that probe must not be read as an undeclared dependency. Anything added # would win every per-project `nvm use`, which is the whole point of having nvm.
# here needs a matching install block and a stated reason for the exception. # Anything added here needs a matching install block and a stated reason.
SELF_INSTALLED='^(bun|claude)$' SELF_INSTALLED='^(bun|claude|node|npm|pnpm)$'
# jq programs are quoted arguments, but the scanner is line-based and cannot # jq programs are quoted arguments, but the scanner is line-based and cannot
# tell a filter from a command. `not` is a jq builtin appearing inside one. # tell a filter from a command. `not` is a jq builtin appearing inside one.
@@ -77,6 +81,7 @@ package_for() {
} }
missing=() missing=()
unguarded=()
checked=0 checked=0
while read -r script; do while read -r script; do
@@ -128,8 +133,48 @@ while read -r script; do
done < <(find "$repo_dir/config/dot/quickshell/scripts" \ done < <(find "$repo_dir/config/dot/quickshell/scripts" \
"$repo_dir/config/local/share/vicinae/scripts" \ "$repo_dir/config/local/share/vicinae/scripts" \
"$repo_dir/setup/scripts" "$repo_dir/bin" \ "$repo_dir/setup/scripts" "$repo_dir/bin" \
"$repo_dir/config/bash" \
-type f 2>/dev/null) -type f 2>/dev/null)
# ── Sourced paths ────────────────────────────────────────────────────────────
#
# config/bash is scanned above because it is where dependencies hide: the shell
# configuration names nvm, oh-my-posh, zoxide, eza and fzf, and it was excluded
# for long enough that `nvm` reached this repository's own shell config without
# ever being installed by it.
#
# Sourcing is the other half. A shell configuration that sources a path nothing
# guarantees exists produces an error on every single shell start, on exactly
# the machines least able to explain it -- new ones. That happened twice here:
# `$HOME/.cargo/env`, which rustup writes only after rustup-init has run, and
# `/etc/profile.d/nvm.sh`, which belongs to a package nothing installed.
#
# So a source of anything outside this repository has to be guarded. Whether the
# owning package is declared is not enough: the file still does not exist until
# that package is installed, and a shell can be opened before then.
while read -r file; do
[[ -f "$file" ]] || continue
while IFS= read -r line; do
# Only unconditional ones. A guard anywhere on the line is the fix.
[[ "$line" =~ \[\[?[[:space:]]*-[fesr] ]] && continue
path="$(sed -E 's/^[[:space:]]*(source|\.)[[:space:]]+//; s/[[:space:]].*//' <<<"$line")"
[[ -n "$path" ]] || continue
# Only literal paths. `. "$rc"` inside a loop that already tested the
# variable is a different shape, guarded structurally rather than on the
# same line, and reporting it would bury the real findings.
[[ "$path" =~ ^[\"\']?(/|~|\$HOME|\$\{HOME) ]] || continue
# Paths inside the repository ship with it and are always present.
[[ "$path" == *PANAMA* ]] && continue
unguarded+=("$(basename "$file"): $path")
done < <(grep -nE '^[[:space:]]*(source|\.)[[:space:]]+[^[:space:]]' "$file" | sed 's/^[0-9]*://')
done < <(find "$repo_dir/config/bash" -type f 2>/dev/null)
if (( ${#unguarded[@]} > 0 )); then
printf 'declared dependencies contract: sourced without checking it exists:\n' >&2
printf ' %s\n' "${unguarded[@]}" | sort -u >&2
fail 'guard each with a -f test, or every shell on a fresh machine starts with an error'
fi
if (( ${#missing[@]} > 0 )); then if (( ${#missing[@]} > 0 )); then
printf 'declared dependencies contract: commands used but never installed:\n' >&2 printf 'declared dependencies contract: commands used but never installed:\n' >&2
printf ' %s\n' "${missing[@]}" | sort -u >&2 printf ' %s\n' "${missing[@]}" | sort -u >&2
+27 -4
View File
@@ -51,6 +51,19 @@ done < <(grep -oE '\{ page: "[a-z-]+"' "$sidebar" | sed 's/.*"\([a-z-]*\)"/\1/')
(( ${#expected[@]} > 18 )) || fail 'no generated per-page commands were found; run scripts/panama-settings-commands' (( ${#expected[@]} > 18 )) || fail 'no generated per-page commands were found; run scripts/panama-settings-commands'
# Commands that do not go through panama-action, and should not.
#
# Every command above asks the shell to do something, so routing them through
# one dispatcher is what keeps that surface small. search-web is a different
# animal: it takes a query and opens a browser, and neither half needs the
# shell. Sending it through panama-action would mean a web search stops working
# when Quickshell is down -- which is exactly when somebody is reaching for the
# launcher to look up what went wrong.
#
# They are still commands, so everything else below applies to them: a title,
# a description, search vocabulary, the Panama icon, and closing quietly.
declare -a standalone=(search-web)
# Generated commands must match their source. A stale command dispatches to a # Generated commands must match their source. A stale command dispatches to a
# page that has been renamed or removed, and the launcher reports nothing wrong. # page that has been renamed or removed, and the launcher reports nothing wrong.
"$repo_dir/config/dot/quickshell/scripts/panama-settings-commands" --check >/dev/null \ "$repo_dir/config/dot/quickshell/scripts/panama-settings-commands" --check >/dev/null \
@@ -69,11 +82,12 @@ chmod +x "$work/home/.config/quickshell/scripts/panama-action"
# none: a shebang and the executable bit already select the interpreter, and an # none: a shebang and the executable bit already select the interpreter, and an
# extension is one more thing that has to stay in sync -- which it did not. # extension is one more thing that has to stay in sync -- which it did not.
mapfile -t actual_files < <(find "$commands_dir" -maxdepth 1 -type f -printf '%f\n' | sort) mapfile -t actual_files < <(find "$commands_dir" -maxdepth 1 -type f -printf '%f\n' | sort)
[[ ${#actual_files[@]} -eq ${#expected[@]} ]] \ declared=$(( ${#expected[@]} + ${#standalone[@]} ))
|| fail "expected ${#expected[@]} commands, found ${#actual_files[@]}" [[ ${#actual_files[@]} -eq $declared ]] \
|| fail "expected $declared commands, found ${#actual_files[@]}"
declare -A seen_titles=() declare -A seen_titles=()
for script_name in "${!expected[@]}"; do for script_name in "${!expected[@]}" "${standalone[@]}"; do
script="$commands_dir/$script_name" script="$commands_dir/$script_name"
[[ -x "$script" ]] || fail "$script_name is missing or not executable" [[ -x "$script" ]] || fail "$script_name is missing or not executable"
@@ -109,6 +123,15 @@ for script_name in "${!expected[@]}"; do
|| fail 'health command bypasses the stable dispatcher path' || fail 'health command bypasses the stable dispatcher path'
fi fi
# A standalone command has nothing to dispatch, and running it would open a
# browser at whoever is running the tests.
if [[ -z ${expected[$script_name]+x} ]]; then
if grep -Fq 'panama-action' "$script"; then
fail "$script_name is listed as standalone but goes through panama-action"
fi
continue
fi
: >"$dispatch_log" : >"$dispatch_log"
HOME="$work/home" PANAMA_COMMAND_TEST_LOG="$dispatch_log" "$script" HOME="$work/home" PANAMA_COMMAND_TEST_LOG="$dispatch_log" "$script"
dispatched="$(cat "$dispatch_log")" dispatched="$(cat "$dispatch_log")"
@@ -116,4 +139,4 @@ for script_name in "${!expected[@]}"; do
|| fail "$script_name dispatched [$dispatched], expected [${expected[$script_name]}]" || fail "$script_name dispatched [$dispatched], expected [${expected[$script_name]}]"
done done
printf 'Panama commands contract: PASS (%d commands)\n' "${#expected[@]}" printf 'Panama commands contract: PASS (%d commands)\n' "$declared"
+117
View File
@@ -0,0 +1,117 @@
#!/usr/bin/env bash
# The applications built from source, and the standard they have to meet.
#
# Every other application Panama installs comes from dnf or Flathub. These do
# not, and the rule for admitting one is not "it was convenient": there has to
# be no packaged form, the reason has to be written down, and what lands on the
# system still has to be a package the system owns.
#
# The rules:
#
# 1. Every definition declares a repository, a description, and a build.
# A file missing any of them is an entry that fails only when somebody
# asks for it, which is the worst moment to find out.
# 2. Every definition states why the exception exists. This is the whole
# guard against the list growing by habit -- sunhat had seventy-odd
# installers and no reason recorded for any of them.
# 3. Nothing is pinned. A recorded version is a 404 waiting to happen: every
# pinned URL sunhat carried had rotted within a release cycle, which is the
# argument this repository's package rule is built on.
# 4. `panama app` lists what the directory holds and refuses what it does not.
#
# Definitions are read, not run. Building one downloads an upstream release and
# installs a package, which is not something a test suite does.
set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
apps_dir="$repo_dir/setup/apps"
panama="$repo_dir/bin/panama"
findings=()
note() { findings+=("$1"); }
[[ -d "$apps_dir" ]] || { printf 'apps contract: no %s\n' "$apps_dir" >&2; exit 1; }
shopt -s nullglob
definitions=("$apps_dir"/*)
# ── 1 & 2. Each definition is complete, and says why it exists ───────────────
for definition in "${definitions[@]}"; do
name="$(basename "$definition")"
[[ -f "$definition" ]] || { note "$name is not a file"; continue; }
# Sourced in a subshell so one definition cannot leak into the next, and so
# a definition that runs something at source time is contained.
problems="$(
description=""
repo=""
unset -f build 2>/dev/null || true
# shellcheck source=/dev/null
source "$definition" >/dev/null 2>&1
[[ -n "$description" ]] || { printf 'no-description\n'; exit 0; }
[[ -n "$repo" ]] || { printf 'no-repo\n'; exit 0; }
declare -F build >/dev/null || { printf 'no-build\n'; exit 0; }
[[ "$repo" == https://* ]] || { printf 'insecure-repo\n'; exit 0; }
)"
# Read back through a here-string rather than a pipe: a `while read` on the
# right of a pipe runs in a subshell, and every finding it recorded was
# being discarded at the end of the loop. Caught by standing in a broken
# definition and watching two of the three checks stay silent.
while read -r problem; do
[[ -n "$problem" ]] || continue
case "$problem" in
no-description) note "$name has no description, so it cannot be listed" ;;
no-repo) note "$name declares no repository" ;;
no-build) note "$name declares no build function" ;;
insecure-repo) note "$name is cloned over something other than https" ;;
esac
done <<<"$problems"
# The comment block is the reason. A definition without one is an entry
# somebody added because it was easy.
reason="$(grep -c '^#' "$definition")"
(( reason >= 3 )) \
|| note "$name records no reason for being a source build rather than a package"
# ── 3. Nothing pinned ───────────────────────────────────────────────────
if grep -qE 'git (checkout|clone).*(-b|--branch|--tag)|checkout [0-9a-f]{7,40}|v[0-9]+\.[0-9]+\.[0-9]+' "$definition"; then
note "$name looks like it pins a version or tag, which is what goes stale"
fi
done
# ── 4. The command agrees with the directory ────────────────────────────────
listing="$("$panama" app 2>&1)"
for definition in "${definitions[@]}"; do
[[ -f "$definition" ]] || continue
grep -q "$(basename "$definition")" <<<"$listing" \
|| note "$(basename "$definition") is not listed by 'panama app'"
done
"$panama" app definitely-not-an-app >/dev/null 2>&1 \
&& note "'panama app' accepts a name that has no definition"
# The build tree belongs in the cache: it is entirely rebuildable, and a
# checkout kept beside the repository would eventually be mistaken for one.
grep -q 'XDG_CACHE_HOME' "$panama" \
|| note 'application checkouts are not placed under the cache directory'
# Not part of the unattended run, for the reason the interview exists.
grep -q 'app)' "$repo_dir/install" \
&& note 'the installer runs a source build, which cannot be walked away from'
# ── Report ───────────────────────────────────────────────────────────────────
if (( ${#findings[@]} > 0 )); then
mapfile -t findings < <(printf '%s\n' "${findings[@]}" | sort -u)
printf 'apps contract: %d finding(s)\n' "${#findings[@]}" >&2
printf ' - %s\n' "${findings[@]}" >&2
exit 1
fi
printf 'apps contract: PASS (%d applications)\n' "${#definitions[@]}"
+109 -20
View File
@@ -24,6 +24,14 @@ repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
installer="$repo_dir/setup/scripts/install-packages" installer="$repo_dir/setup/scripts/install-packages"
interview="$repo_dir/setup/scripts/interview" interview="$repo_dir/setup/scripts/interview"
extras_dir="$repo_dir/setup/packages/extras" extras_dir="$repo_dir/setup/packages/extras"
catalog="$repo_dir/setup/lib/extras-catalog"
# The same parser both front doors use. A contract that re-implemented the
# format would eventually be testing its own idea of it rather than the one that
# runs -- which is exactly how the package lists came to be annotated with
# comments that every contract stripped and dnf did not.
# shellcheck source=../../setup/lib/extras-catalog
source "$catalog"
findings=() findings=()
note() { findings+=("$1"); } note() { findings+=("$1"); }
@@ -39,7 +47,7 @@ categories=("$extras_dir"/*)
for category in "${categories[@]}"; do for category in "${categories[@]}"; do
name="$(basename "$category")" name="$(basename "$category")"
[[ -f "$category" ]] || { note "$name is not a file"; continue; } [[ -f "$category" ]] || { note "$name is not a file"; continue; }
entries="$(sed 's/#.*//' "$category" | tr -d ' \t' | grep -cv '^$')" entries="$(catalog_entries "$category" | grep -c . || true)"
(( entries > 0 )) || note "the $name category installs nothing, so choosing it does nothing" (( entries > 0 )) || note "the $name category installs nothing, so choosing it does nothing"
done done
@@ -88,6 +96,7 @@ LIST
( (
PATH="$stub_dir:$PATH" PATH="$stub_dir:$PATH"
log() { :; } log() { :; }
source "$catalog"
eval "$filter" eval "$filter"
eval "$loop" eval "$loop"
install_extra_category "$fixture" install_extra_category "$fixture"
@@ -112,6 +121,7 @@ fi
PATH="$stub_dir:$PATH" PATH="$stub_dir:$PATH"
PANAMA_PATH="$repo_dir" PANAMA_PATH="$repo_dir"
log() { :; } log() { :; }
source "$catalog"
eval "$filter" eval "$filter"
eval "$loop" eval "$loop"
EXTRAS_DIR="$extras_dir" EXTRAS_DIR="$extras_dir"
@@ -121,34 +131,113 @@ fi
) )
[[ -s "$calls" ]] && note 'with no categories chosen the installer still installed something' [[ -s "$calls" ]] && note 'with no categories chosen the installer still installed something'
# ── 3b. Labels and bundles ───────────────────────────────────────────────────
#
# Two pieces of syntax carry real weight, and both fail quietly when wrong: a
# label that leaked into an install command would be handed to dnf as a package
# name, and a bundle that did not resolve would install OBS without the plugins
# that are the reason to pick it.
bundle_fixture="$work/bundle"
cat >"$bundle_fixture" <<'LIST'
# A named entry, a bundle, and a plain one.
flatpak:com.example.Named | A Friendly Name
flatpak:com.example.Host | Host
flatpak:com.example.Host.Plugin.One
flatpak:com.example.Host.Plugin.Two
plain-package
LIST
# An indented line belongs to the entry above it and must never be offered on
# its own, or the menu lists plugins as though they were applications.
selectable="$(catalog_entries "$bundle_fixture" | wc -l)"
(( selectable == 3 )) \
|| note "a category with 3 entries and 2 attached lines offers $selectable choices, not 3"
catalog_entries "$bundle_fixture" | grep -q 'Plugin.One' \
&& note 'an indented line is offered as a selectable application'
# The label is for the menu and must not survive into an install target.
catalog_all_targets "$bundle_fixture" | grep -q '|' \
&& note 'a label reaches the install targets, where it would be treated as a package name'
catalog_entries "$bundle_fixture" | grep -q "$(printf 'flatpak:com.example.Named\tA Friendly Name')" \
|| note 'an explicit label is not carried through to the menu'
# Ticking a bundle installs the entry and everything attached to it.
host_targets="$(catalog_targets "$bundle_fixture" "flatpak:com.example.Host" | wc -l)"
(( host_targets == 3 )) \
|| note "selecting a bundle resolves to $host_targets targets, not the entry plus its 2 attached lines"
catalog_targets "$bundle_fixture" "flatpak:com.example.Host" | grep -q '^flatpak:com.example.Host$' \
|| note 'selecting a bundle does not install the entry itself'
# A plain entry stays plain: it must not absorb whatever follows it.
plain_targets="$(catalog_targets "$bundle_fixture" "plain-package" | wc -l)"
(( plain_targets == 1 )) \
|| note "a plain entry resolves to $plain_targets targets rather than just itself"
# `panama apps` maps a selection back to an entry by its menu label, because
# that is all gum returns. Two entries sharing a label would therefore install
# whichever came first, silently and with no way to pick the other.
for category in "${categories[@]}"; do
[[ -f "$category" ]] || continue
duplicate="$(catalog_entries "$category" | cut -f2 | sort | uniq -d)"
[[ -z "$duplicate" ]] \
|| note "$(basename "$category") has more than one entry labelled '$duplicate', which makes the menu ambiguous"
done
# ── 3c. Both front doors, one catalog ────────────────────────────────────────
#
# `panama apps` and the interview offer the same applications. They diverge the
# moment either grows its own parser, and the divergence would be invisible.
panama="$repo_dir/bin/panama"
grep -q 'source "$PANAMA_DIR/setup/lib/extras-catalog"' "$panama" \
|| note 'panama apps does not read the shared catalog'
grep -q 'source "$PANAMA_PATH/setup/lib/extras-catalog"' "$installer" \
|| note 'install-packages does not read the shared catalog'
grep -qE '^\s*apps\)' "$panama" \
|| note 'panama does not dispatch an apps subcommand'
# ── 4. Every name resolves ─────────────────────────────────────────────────── # ── 4. Every name resolves ───────────────────────────────────────────────────
# #
# Skipped rather than failed when the repositories cannot be reached, so this # Skipped rather than failed when the repositories cannot be reached, so this
# contract stays runnable on a train. # contract stays runnable on a train.
#
# One bulk query per manager, not one per name. Asking Flathub about forty-five
# ids individually took minutes and got slower every time an application was
# added -- a check nobody will wait for is a check that gets commented out.
if timeout 60 dnf list --available --quiet bash >/dev/null 2>&1; then dnf_wanted="$(for category in "${categories[@]}"; do
for category in "${categories[@]}"; do [[ -f "$category" ]] && catalog_all_targets "$category" | grep -v '^flatpak:'
[[ -f "$category" ]] || continue done | sort -u)"
while read -r package; do
[[ -n "$package" ]] || continue flatpak_wanted="$(for category in "${categories[@]}"; do
[[ "$package" == flatpak:* ]] && continue [[ -f "$category" ]] && catalog_all_targets "$category" | sed -n 's/^flatpak://p'
timeout 90 dnf list --quiet "$package" >/dev/null 2>&1 \ done | sort -u)"
|| note "$(basename "$category") names $package, which dnf cannot resolve"
done < <(sed 's/#.*//' "$category" | tr -d ' \t' | grep -v '^$') if [[ -n "$dnf_wanted" ]] && timeout 60 dnf list --available --quiet bash >/dev/null 2>&1; then
done # repoquery answers for every name at once and simply omits the ones it
# cannot resolve, so the difference is the finding.
resolved="$(timeout 180 dnf repoquery --qf '%{name}\n' $dnf_wanted 2>/dev/null | sort -u)"
while read -r package; do
[[ -n "$package" ]] || continue
grep -qx "$package" <<<"$resolved" \
|| note "$package is named by a category but dnf cannot resolve it"
done <<<"$dnf_wanted"
else else
printf 'extras contract: dnf is unreachable, so package names were not resolved\n' >&2 printf 'extras contract: dnf is unreachable, so package names were not resolved\n' >&2
fi fi
if timeout 60 flatpak remote-info flathub org.mozilla.firefox >/dev/null 2>&1; then # --all matters: without it, remote-ls hides end-of-life applications, which
for category in "${categories[@]}"; do # still resolve and still install. Leaving it off reported yuzu as missing from
[[ -f "$category" ]] || continue # Flathub when it is merely unmaintained -- a false negative that would have
while read -r id; do # quietly deleted a working entry.
[[ -n "$id" ]] || continue if [[ -n "$flatpak_wanted" ]] && timeout 120 flatpak remote-ls flathub --columns=application --all >"$work/flathub" 2>/dev/null \
timeout 90 flatpak remote-info flathub "$id" >/dev/null 2>&1 \ && [[ -s "$work/flathub" ]]; then
|| note "$(basename "$category") names $id, which is not on Flathub" while read -r id; do
done < <(sed 's/#.*//' "$category" | tr -d ' \t' | sed -n 's/^flatpak://p') [[ -n "$id" ]] || continue
done grep -qx "$id" "$work/flathub" \
|| note "$id is named by a category but is not on Flathub"
done <<<"$flatpak_wanted"
else else
printf 'extras contract: Flathub is unreachable, so flatpak IDs were not resolved\n' >&2 printf 'extras contract: Flathub is unreachable, so flatpak IDs were not resolved\n' >&2
fi fi
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env bash
# Searching the web from the launcher, in both the forms Panama ships it.
#
# There are two, deliberately: a script command that needs nothing but bash, and
# an extension that adds live suggestions but has to be compiled. That is also
# the risk this pins -- the search engine is written down twice, and two copies
# of a URL drift.
#
# `vicinae script check` is the validator for the first, and it exits 0 even when
# it rejects a file. Checking its exit status would pass on a script Vicinae
# refuses to load, so its output is what counts.
set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
script="$repo_dir/config/local/share/vicinae/scripts/search-web"
extension="$repo_dir/config/local/share/vicinae/extensions/panama-search"
stage="$repo_dir/setup/scripts/link-vicinae-scripts"
findings=()
note() { findings+=("$1"); }
# ── The script command ───────────────────────────────────────────────────────
if [[ ! -x "$script" ]]; then
note 'search-web is missing or not executable, so Vicinae will not load it'
else
if command -v vicinae >/dev/null 2>&1; then
output="$(vicinae script check "$script" 2>&1)"
[[ "$output" == *Error* ]] && note "Vicinae rejects search-web: $output"
fi
# Exactly one argument. Vicinae only offers a no-view command as a fallback
# -- type anything, get "Search" at the bottom -- when it takes a single
# text argument, which is the whole point of shipping this one.
arguments="$(grep -c '@vicinae.argument' "$script" || true)"
(( arguments == 1 )) \
|| note "search-web declares $arguments arguments; a fallback command takes exactly 1"
grep -q '"percentEncoded": true' "$script" \
|| note 'the argument is not percent-encoded, so a query containing & or # is truncated'
grep -qE '@vicinae\.mode silent' "$script" \
|| note 'search-web is not a silent command, so it would render a view it has nothing to put in'
# The default browser is a setting this desktop already owns. Naming a
# browser here would quietly outrank the Applications page.
grep -q 'xdg-open' "$script" \
|| note 'search-web does not open through xdg-open, so it ignores the default browser'
grep -qE '\b(helium|firefox|chromium|google-chrome)\b' "$script" \
&& note 'search-web names a specific browser instead of following the default'
fi
# ── The extension ────────────────────────────────────────────────────────────
manifest="$extension/package.json"
if [[ ! -f "$manifest" ]]; then
note 'the panama-search extension has no manifest'
else
if command -v jq >/dev/null 2>&1; then
jq -e . "$manifest" >/dev/null 2>&1 || note 'the extension manifest is not valid JSON'
for field in name title description commands; do
jq -e "has(\"$field\")" "$manifest" >/dev/null 2>&1 \
|| note "the extension manifest has no \"$field\", which Vicinae requires"
done
# A command's name is its source file. Getting this wrong builds an
# extension with a command that cannot be opened.
while read -r command_name; do
[[ -n "$command_name" ]] || continue
[[ -f "$extension/src/$command_name.tsx" || -f "$extension/src/$command_name.ts" ]] \
|| note "the manifest declares command \"$command_name\" with no matching file in src/"
done < <(jq -r '.commands[]?.name // empty' "$manifest" 2>/dev/null)
fi
fi
# ── One engine, written twice ────────────────────────────────────────────────
#
# The script command and the extension both have to know where a search goes.
# Nothing makes them agree, so this does: searching from the fallback and
# searching from the suggestions list must not reach different places.
engine_in_script="$(grep -oE 'https://[^"]+\?q=' "$script" 2>/dev/null | head -1)"
engine_in_extension="$(grep -oE 'https://[^"]+\?q=' "$extension/src/search.tsx" 2>/dev/null | head -1)"
if [[ -z "$engine_in_script" ]]; then
note 'no search engine URL found in search-web'
elif [[ -z "$engine_in_extension" ]]; then
note 'no search engine URL found in the extension'
elif [[ "$engine_in_script" != "$engine_in_extension" ]]; then
note "the script searches $engine_in_script but the extension searches $engine_in_extension"
fi
# ── Provisioning ─────────────────────────────────────────────────────────────
#
# An extension is compiled, so unlike a script command it cannot simply be
# linked. If nothing builds it, it ships as source nobody can run.
grep -q 'npm run build' "$stage" \
|| note 'no stage builds the Vicinae extensions, so they never reach the launcher'
grep -q 'command -v npm' "$stage" \
|| note 'the extension build does not check for npm, so a machine without it fails the stage'
# node_modules is a dependency tree, not configuration.
git -C "$repo_dir" check-ignore -q "$extension/node_modules" 2>/dev/null \
|| note 'the extension node_modules is not gitignored'
# ── Report ───────────────────────────────────────────────────────────────────
if (( ${#findings[@]} > 0 )); then
mapfile -t findings < <(printf '%s\n' "${findings[@]}" | sort -u)
printf 'launcher search contract: %d finding(s)\n' "${#findings[@]}" >&2
printf ' - %s\n' "${findings[@]}" >&2
exit 1
fi
printf 'launcher search contract: PASS\n'
+58
View File
@@ -0,0 +1,58 @@
#!/usr/bin/env bash
# The README describes this repository accurately.
#
# It carries two facts that are cheap to state and easy to leave behind: how
# many contracts there are, and which `panama` subcommands exist. Both had
# already drifted -- the count said 121 when the suite had grown to 125, one day
# after it was written.
#
# A number in prose is not worth much on its own. It is worth something as a
# claim somebody might rely on, and worth nothing once it is wrong, so it is
# either checked or it should not be there.
set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
readme="$repo_dir/README.md"
panama="$repo_dir/bin/panama"
findings=()
note() { findings+=("$1"); }
# ── The contract count ───────────────────────────────────────────────────────
#
# Counted the way `panama test` collects the suite, so the README agrees with
# what the runner actually reports rather than with a second idea of it.
actual="$(find "$repo_dir/tests" -type f -not -path '*/fixtures/*' -not -path '*__pycache__*' \
\( -executable -o -name '*_test.py' \) | wc -l)"
claimed="$(grep -oE '^[0-9]+ of them, under' "$readme" | grep -oE '^[0-9]+')"
if [[ -z "$claimed" ]]; then
note 'the README no longer states a contract count in the form this checks'
elif (( claimed != actual )); then
note "the README says $claimed contracts; there are $actual"
fi
# ── Documented subcommands exist ─────────────────────────────────────────────
#
# A README listing a command the dispatcher does not have sends somebody to a
# 'Unknown command' error, which reads as a broken install rather than a stale
# document.
while read -r subcommand; do
[[ -n "$subcommand" ]] || continue
grep -qE "^\s+$subcommand\)" "$panama" \
|| note "the README documents 'panama $subcommand', which the dispatcher does not handle"
done < <(sed -n '/^panama [a-z]/s/^panama \([a-z-]*\).*/\1/p' "$readme" | sort -u)
if (( ${#findings[@]} > 0 )); then
mapfile -t findings < <(printf '%s\n' "${findings[@]}" | sort -u)
printf 'readme contract: %d finding(s)\n' "${#findings[@]}" >&2
printf ' - %s\n' "${findings[@]}" >&2
exit 1
fi
printf 'readme contract: PASS (%d contracts, as documented)\n' "$actual"