Compare commits
8
Commits
d61792433c
...
4c77bc2f61
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c77bc2f61 | ||
|
|
9092a80f66 | ||
|
|
62dce86b4e | ||
|
|
725e274ef4 | ||
|
|
f09763ef5d | ||
|
|
215da285f3 | ||
|
|
48f7c1e962 | ||
|
|
f457c1eb9f |
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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()
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
Executable
+107
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Executable
+88
@@ -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'
|
||||||
Executable
+125
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Executable
+117
@@ -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
@@ -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
|
||||||
|
|||||||
Executable
+119
@@ -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'
|
||||||
Executable
+58
@@ -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"
|
||||||
Reference in New Issue
Block a user