Compare commits
261
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a69cacc006 | ||
|
|
28e387868f | ||
|
|
1f65e09865 | ||
|
|
dce00e45d1 | ||
|
|
0d1841cf86 | ||
|
|
ba4e5e6677 | ||
|
|
1ffd05f0e1 | ||
|
|
92c60c3ecd | ||
|
|
31fc1d3eaa | ||
|
|
8264ca6320 | ||
|
|
eab4280969 | ||
|
|
1c3cd7ac72 | ||
|
|
8652f92aae | ||
|
|
4abf3f8acd | ||
|
|
3f8539176a | ||
|
|
18ecc2244f | ||
|
|
94a4314730 | ||
|
|
27acbe7a95 | ||
|
|
fc8f226747 | ||
|
|
98e29eb8f3 | ||
|
|
00e11e0b8d | ||
|
|
ac25fd776c | ||
|
|
51e2c8418a | ||
|
|
d267da58ad | ||
|
|
20b41c3976 | ||
|
|
bfb37afd69 | ||
|
|
b361db8486 | ||
|
|
12b858371e | ||
|
|
77f625d7bb | ||
|
|
34e295d184 | ||
|
|
e19fb00276 | ||
|
|
37688f702a | ||
|
|
71f4f0d24b | ||
|
|
3839183401 | ||
|
|
68b6664111 | ||
|
|
803362ebad | ||
|
|
6a7bdcb835 | ||
|
|
37fd5e890e | ||
|
|
a19c6dc3ef | ||
|
|
6d71a77798 | ||
|
|
d8144a66c1 | ||
|
|
43d1e15858 | ||
|
|
1192ad64dc | ||
|
|
044139ed63 | ||
|
|
0d894afe3e | ||
|
|
54dfa977ee | ||
|
|
5f09c5ee38 | ||
|
|
f33da41cc6 | ||
|
|
9b338608ef | ||
|
|
cc7d91d09c | ||
|
|
ada0faf1d1 | ||
|
|
045a774847 | ||
|
|
beed44dd87 | ||
|
|
8105849151 | ||
|
|
bd2fd12de2 | ||
|
|
06c53d6c21 | ||
|
|
f9e5d3f470 | ||
|
|
88371d19f0 | ||
|
|
8b1205e4b8 | ||
|
|
be0e55214b | ||
|
|
9ffaf45a4d | ||
|
|
e1ff25fc66 | ||
|
|
6f0ce639d9 | ||
|
|
4ec8bd94d9 | ||
|
|
5a0643357f | ||
|
|
b30bf40407 | ||
|
|
aba2d16ffa | ||
|
|
b8f88a91f3 | ||
|
|
d5b6e62515 | ||
|
|
07db1068f1 | ||
|
|
ffce48964e | ||
|
|
b58371bb35 | ||
|
|
9bc68ba358 | ||
|
|
1f694b00b6 | ||
|
|
f8f5b25510 | ||
|
|
4d7a194300 | ||
|
|
cb7c09d208 | ||
|
|
7578348db1 | ||
|
|
5490fd285d | ||
|
|
50077a0c31 | ||
|
|
14fc4fc8a3 | ||
|
|
e723fabed3 | ||
|
|
c0eee076b4 | ||
|
|
31668619a4 | ||
|
|
9530e32326 | ||
|
|
153554b5df | ||
|
|
44124d72fa | ||
|
|
3d21e20041 | ||
|
|
dfc0c49877 | ||
|
|
c2eddc2c5b | ||
|
|
6ad2e017e8 | ||
|
|
12f6b2a310 | ||
|
|
6510fdda0f | ||
|
|
36fdd4e076 | ||
|
|
d865cb74a1 | ||
|
|
a95c480c6a | ||
|
|
78db2d5689 | ||
|
|
669a228286 | ||
|
|
89761a7da3 | ||
|
|
8b96d907a1 | ||
|
|
7a5e990439 | ||
|
|
41dd91eb75 | ||
|
|
4910bd659c | ||
|
|
8d66247b7c | ||
|
|
68cbf892e9 | ||
|
|
05fd5346db | ||
|
|
333ab9f6b7 | ||
|
|
e1a04d2d70 | ||
|
|
202b5b89ac | ||
|
|
9202697734 | ||
|
|
9fbbdd902b | ||
|
|
6ae8265730 | ||
|
|
317b7a0962 | ||
|
|
50a99a5ad0 | ||
|
|
bc6d63b70f | ||
|
|
3c359f3f7e | ||
|
|
e446a1072c | ||
|
|
3c45d63085 | ||
|
|
32bebc2b07 | ||
|
|
12d538c4af | ||
|
|
c02329ac3c | ||
|
|
4e978bf3b7 | ||
|
|
1e430881e5 | ||
|
|
f42b3cfe0e | ||
|
|
51ceb19480 | ||
|
|
25e2328658 | ||
|
|
9f563c8f94 | ||
|
|
7d633eb06e | ||
|
|
6d1f3f3763 | ||
|
|
de923cb4d5 | ||
|
|
bd9a55c8eb | ||
|
|
cab7699711 | ||
|
|
86825e7327 | ||
|
|
89417cd6d4 | ||
|
|
41b86c7a9e | ||
|
|
185d7edaa5 | ||
|
|
538c0a887c | ||
|
|
033d5b21f8 | ||
|
|
7cd4131327 | ||
|
|
b280bd02d7 | ||
|
|
4c77bc2f61 | ||
|
|
9092a80f66 | ||
|
|
62dce86b4e | ||
|
|
725e274ef4 | ||
|
|
f09763ef5d | ||
|
|
215da285f3 | ||
|
|
48f7c1e962 | ||
|
|
f457c1eb9f | ||
|
|
d61792433c | ||
|
|
6016efa436 | ||
|
|
ed428e87c4 | ||
|
|
69ecec7ecf | ||
|
|
a90f6eb357 | ||
|
|
c4642919f7 | ||
|
|
c4733e0624 | ||
|
|
e1faaf7a76 | ||
|
|
47f29f9fa9 | ||
|
|
88497826ec | ||
|
|
b319d1a5e1 | ||
|
|
13f3648e4d | ||
|
|
359fb922aa | ||
|
|
b6448c9876 | ||
|
|
15d54b16f6 | ||
|
|
96e4085919 | ||
|
|
6bcc351e07 | ||
|
|
3b01f1e020 | ||
|
|
23141673a2 | ||
|
|
495fb9b41b | ||
|
|
3b657e0a56 | ||
|
|
c148bae4ac | ||
|
|
4cbab01ae2 | ||
|
|
de45f205ad | ||
|
|
52e2a83a78 | ||
|
|
79b3d5cb85 | ||
|
|
1b845c0126 | ||
|
|
f53ca16392 | ||
|
|
6dc606b872 | ||
|
|
32fab59d24 | ||
|
|
f6b970da21 | ||
|
|
7e1c85b094 | ||
|
|
0c364f38e6 | ||
|
|
1aa1324083 | ||
|
|
ac5e6e2130 | ||
|
|
536958430f | ||
|
|
fd99569666 | ||
|
|
a412e3d894 | ||
|
|
4cbe3b882a | ||
|
|
edc504af2e | ||
|
|
a15f019c17 | ||
|
|
116510caa8 | ||
|
|
b10f8e2593 | ||
|
|
8f0fe23377 | ||
|
|
89cf0f8c29 | ||
|
|
e290a262f3 | ||
|
|
6997dd535f | ||
|
|
1f40f8e136 | ||
|
|
e9d567aa72 | ||
|
|
914d58f52b | ||
|
|
a23b42841a | ||
|
|
e0c0e53ae0 | ||
|
|
180308135a | ||
|
|
b743f44c5b | ||
|
|
2528edfddc | ||
|
|
dbd1472e6b | ||
|
|
eeb49c5aff | ||
|
|
8e93f08977 | ||
|
|
99433c0e8e | ||
|
|
a68e4f6dcd | ||
|
|
2b3b762793 | ||
|
|
91306ce810 | ||
|
|
1b94af1163 | ||
|
|
b4ce148caf | ||
|
|
2fcaada7e8 | ||
|
|
588dec4adc | ||
|
|
d96863b687 | ||
|
|
8156fc47ca | ||
|
|
9bc771bcca | ||
|
|
fe74a82a46 | ||
|
|
ac231eeb54 | ||
|
|
8cf03d4529 | ||
|
|
2a716dac9e | ||
|
|
719ef2f38e | ||
|
|
9ba224d776 | ||
|
|
8b59b78d9f | ||
|
|
e6b4d3c1a1 | ||
|
|
70d8d32ee2 | ||
|
|
c37ca3baee | ||
|
|
bd5d030d91 | ||
|
|
63f4bdb547 | ||
|
|
1a93a788fa | ||
|
|
1debd37d01 | ||
|
|
4341628b2f | ||
|
|
7777f5c1aa | ||
|
|
bc7bf3185c | ||
|
|
f3d91c14a1 | ||
|
|
21beb066e1 | ||
|
|
d2898d8806 | ||
|
|
0c3935f818 | ||
|
|
70a9d27c98 | ||
|
|
5cf2943ccf | ||
|
|
d0d9e196b0 | ||
|
|
2613768efd | ||
|
|
101797f8f0 | ||
|
|
b9336d5930 | ||
|
|
c36e423890 | ||
|
|
1c12892252 | ||
|
|
52818b7290 | ||
|
|
c8004e400a | ||
|
|
3e98cc2216 | ||
|
|
a80a6f4dda | ||
|
|
3520700983 | ||
|
|
a845534110 | ||
|
|
db34b1e6ed | ||
|
|
0f3bddc452 | ||
|
|
9aea519e54 | ||
|
|
e5a2a430e0 | ||
|
|
bfa9c58b09 | ||
|
|
1360a80f07 | ||
|
|
8be3fc2fdd | ||
|
|
2d69ce7648 | ||
|
|
d87f4b6d6a |
@@ -0,0 +1,236 @@
|
||||
# Panama server role: headless installs and versioned containers
|
||||
|
||||
2026-08-25. Designed with Gib across two interview rounds; every decision below
|
||||
was made explicitly, none inferred.
|
||||
|
||||
## Goal
|
||||
|
||||
Panama becomes the one repo for every Fedora machine Gib runs. A desktop gets
|
||||
what it gets today. A server — a Hetzner VPS, a future home server — gets the
|
||||
same shell environment (bash profile, prompt, tmux-on-ssh, agents) minus
|
||||
everything that needs a screen, plus rootless podman, systemd user units, and a
|
||||
versioned catalog of compose services that `panama server enable` brings up.
|
||||
The repo stays public; secrets never enter it.
|
||||
|
||||
## Decisions of record
|
||||
|
||||
| Decision | Choice |
|
||||
|---|---|
|
||||
| Repo | One public monorepo. No companion repo. `.gitignore` + a secrets contract keep it safe |
|
||||
| Role | Asked first in the interview (`desktop` / `server`), persisted to `~/.local/state/panama/role`; `--server` on `boot`/`install` presets it |
|
||||
| Layout | Top-level `server/`, services under `server/containers/<Name>/` (TitleCase, matching junior) |
|
||||
| Tracked per service | `compose.yml`, `podman-<name>.service`, `.env.example`. Nothing else |
|
||||
| `~/Server` | Real directories. Only `compose.yml` is symlinked into them; units symlink into `~/.config/systemd/user/`. Live `.env` and data stay outside the repo tree entirely |
|
||||
| Data dirs | Standardized to `./data/`, applied per service at cutover (a stop/rename/start), never at import |
|
||||
| Enablement | `panama server` subcommand family, not the interview |
|
||||
| Units | Checked in per service (they legitimately differ), installed as symlinks so edits flow with `panama update` |
|
||||
| Updates | `panama update` relinks and `daemon-reload`s but never restarts a running service; it names services whose definitions changed |
|
||||
| Nightly image updates | junior's `update-containers.sh` + `podman-update.timer` adopted into the repo (it replaced watchtower for cause) |
|
||||
| Codex | Installed everywhere, same pattern as Claude Code |
|
||||
| Ports | 80/443/81 only. Anything else is opened by hand and documented in the service's folder |
|
||||
| VPS bootstrap | `boot` run as root walks user-creation/keys/sshd-hardening, each step skipped when already true, then re-runs as the user |
|
||||
| Out of scope | server.gib (docker; translate later), backup and verify-rootless (own design later), cutover of junior's live services (later, per service) |
|
||||
|
||||
## 1. Machine role
|
||||
|
||||
- New interview question, asked first: "What is this machine?" — `desktop` or
|
||||
`server` via `gum choose`. `./install --server` (and `boot --server`) records
|
||||
the answer without asking.
|
||||
- Persisted to `~/.local/state/panama/role`, plain text, one word. This is the
|
||||
first durable answer Panama keeps, and deliberately so: the transient-answers
|
||||
principle exists to keep *personal* data out of durable paths, and a machine
|
||||
role is a fact about the machine. `install`, `panama update`, `panama doctor`
|
||||
and future migrations read it. A machine with no role file is a desktop —
|
||||
every machine that predates this work is one.
|
||||
- On role=server the interview skips: hardware probes (NVIDIA, Secure Boot,
|
||||
firmware — and their bootstrap tools; only `gum` is bootstrapped), the
|
||||
debloat question, and the extras checklist. It keeps: hostname, git
|
||||
identity, gh sign-in, SSH key, user-content.
|
||||
|
||||
## 2. Stages by role
|
||||
|
||||
Role=desktop is exactly today's behavior. Role=server runs:
|
||||
|
||||
| Stage | Server behavior |
|
||||
|---|---|
|
||||
| `install-packages` | Core + server lists (§3). No flatpaks, no COPRs, no desktop repos. Claude Code and Codex install for both roles |
|
||||
| `link-dotfiles` | Universal dot dirs only: `btop`, `nvim`, `ohmyposh`, `tmux`, `vim`, `panama`. Desktop-only (everything else: `hypr`, `quickshell`, `kitty`, `ghostty`, `espanso`, `gtk-*`, `uwsm`, `vicinae`, `wofi`, `xdg-desktop-portal`) is skipped via an explicit classification list in the stage — a new dot dir must be classified or the contract fails. Bash profile links as today; Firefox/wallpaper/cursor seeding skipped |
|
||||
| `link-user` | Unchanged (SSH aliases and agent rules are exactly what a server wants) |
|
||||
| `setup-identity` | Unchanged |
|
||||
| `setup-server` (new) | §4 |
|
||||
| `link-server` (new) | Creates `~/Server`, then delegates to `panama-server relink` so link repair has one owner |
|
||||
| Skipped | `link-skills` (all three shipped skills are desktop-specific today; revisit when a server skill exists), `change-settings`, `link-vicinae-scripts`, `install-hardware` |
|
||||
|
||||
Migrations run on servers too — they are self-guarding no-ops where they do
|
||||
not apply. `panama doctor` learns the role: on a server it reports service
|
||||
health via `panama-server status` instead of probing desktop units.
|
||||
|
||||
## 3. Package restructure
|
||||
|
||||
`setup/packages/` gains `core-packages` (both roles) and `server-packages`;
|
||||
`initial-packages` keeps what is desktop-only. Nothing is added or dropped for
|
||||
desktops — the union of core + desktop lists equals today's set.
|
||||
|
||||
- **core-packages**: awk, bat, btop, curl, dnf5-plugins, eza, fontconfig, fzf,
|
||||
gh, git-all, gum, jq, neovim, oh-my-posh, openssh, openssl, podman,
|
||||
podman-compose, python3-dnf, ripgrep, tmux, unzip, wget, zoxide.
|
||||
(`podman` moves here from development-packages; `podman-compose` is what
|
||||
provides `podman compose` on junior — verified, 1.6.0.)
|
||||
- **server-packages**: nvm (Node via nvm, as on desktop — agents and MCP
|
||||
servers want it, and Codex installs through npm).
|
||||
- **initial-packages** (desktop-only remainder): cargo, rustup, flatpak, fwupd,
|
||||
kitty, ksshaskpass, libselinux-utils, pciutils, python3-neovim,
|
||||
wireguard-tools, vim-X11 — plus everything else currently there that the
|
||||
core list did not claim.
|
||||
- Codex CLI: installed in `install-packages` beside the Claude Code step —
|
||||
skip when `codex` is on PATH, `npm install -g @openai/codex` (after nvm/node
|
||||
is available), soft-fail like Claude Code does.
|
||||
- The package-hash mechanic in `install` is untouched; new files under
|
||||
`-maxdepth 1` join the hash automatically.
|
||||
|
||||
## 4. `setup-server` stage
|
||||
|
||||
Idempotent, like every stage; each item checks before acting:
|
||||
|
||||
1. `loginctl enable-linger $USER`.
|
||||
2. `/etc/sysctl.d/99-rootless-ports.conf` → `net.ipv4.ip_unprivileged_port_start=80`,
|
||||
applied with `sysctl --system` (junior already has exactly this file).
|
||||
3. firewalld: open 80, 443, 81 (NPM admin portal), permanent + runtime.
|
||||
Nothing else — reverse-proxy philosophy, ports open only when unavoidable.
|
||||
4. `podman network create nginx-bridge` if absent (every compose expects it,
|
||||
`external: true`).
|
||||
5. Nightly image updates: `update-containers.sh` is adopted into the repo as
|
||||
`server/scripts/update-containers` (no `.sh`, per repo rule — junior's copy
|
||||
is imported and its `ExecStart`/log paths updated), with
|
||||
`server/systemd/podman-update.service` and `.timer` symlinked into
|
||||
`~/.config/systemd/user/` and the timer enabled. Logs go to
|
||||
`~/Server/logs/` as today.
|
||||
|
||||
## 5. The `panama server` command
|
||||
|
||||
`bin/panama-server`, surfaced as `panama server <verb>` (README's subcommand
|
||||
table and its contract update with it):
|
||||
|
||||
- `list` — every service in `server/containers/`, with enabled/active state.
|
||||
- `enable <Name>` — create `~/Server/<Name>/`; symlink `compose.yml`; seed
|
||||
`~/Server/<Name>/.env` from `.env.example` when the example exists and the
|
||||
live file does not; if the seeded `.env` still contains placeholder values,
|
||||
stop there and say what to fill in — do **not** start; otherwise symlink the
|
||||
unit, `daemon-reload`, `enable --now`.
|
||||
- `disable <Name>` — `disable --now`, remove the unit symlink,
|
||||
`daemon-reload`. `~/Server/<Name>` and its data are never touched.
|
||||
- `status [<Name>]` — unit state plus `podman compose ps` per service.
|
||||
- `relink` — for every service whose unit symlink exists: refresh both
|
||||
symlinks, `daemon-reload`, and report services whose definitions changed
|
||||
since the last run (so the operator restarts deliberately). Called by the
|
||||
`link-server` stage and by `panama update` on role=server.
|
||||
|
||||
Live `.env` files live in `~/Server/<Name>/`, not in the repo tree — the repo
|
||||
carries only `.env.example`. `.gitignore` still covers `.env` everywhere under
|
||||
`server/` as belt and braces.
|
||||
|
||||
## 6. VPS bootstrap (`boot` as root)
|
||||
|
||||
`boot` gains a root path. When `EUID == 0`, before anything else, walk these
|
||||
steps, each checked and skipped when already true:
|
||||
|
||||
1. Ask for a username (default `gib`). Create it with `wheel` if absent; if it
|
||||
exists, use it.
|
||||
2. Ensure the user has a password (needed for sudo) — `passwd` interactively
|
||||
if none is set.
|
||||
3. Copy root's `authorized_keys` only after OpenSSH parses every key line. The
|
||||
destination directory and file are created as the target UID at `0700/0600`
|
||||
and revalidated before hardening is available.
|
||||
4. Offer sshd hardening (yes/no, default yes): atomically install
|
||||
`/etc/ssh/sshd_config.d/00-panama.conf` with `PermitRootLogin no`,
|
||||
`PasswordAuthentication no`, and `KbdInteractiveAuthentication no`. Run
|
||||
`sshd -t` plus effective root/target `sshd -T -C` checks before reloading the
|
||||
detected unit. Restore a prior regular file with metadata on failure. A
|
||||
missing unit or unsupported existing drop-in leaves SSH unchanged and
|
||||
bootstrap continues. The later binding transaction design owns the details.
|
||||
5. Move/clone the checkout under the user's home, chown it, and re-exec
|
||||
`install --server` as that user.
|
||||
|
||||
Run as a normal user, `boot` behaves exactly as today (plus passing `--server`
|
||||
through).
|
||||
|
||||
## 7. Importing junior's services (the first population)
|
||||
|
||||
A one-time operation, performed against junior over SSH, that **never stops,
|
||||
starts, or restarts anything**. For each dedicated compose dir:
|
||||
|
||||
1. Copy `compose.yml` into `server/containers/<Name>/`.
|
||||
2. Take the **installed** unit (`~/.config/systemd/user/podman-<name>.service`)
|
||||
as truth; diff against the copy in the service dir and flag any divergence.
|
||||
3. Move inline secrets out: secret-bearing values in the repo copy of
|
||||
`compose.yml` become `${VAR}` interpolations; extracted values are written
|
||||
to `~/Server/<Name>/.env` **on junior** (inert until cutover — the live
|
||||
compose still carries them inline); `.env.example` with placeholder values
|
||||
is committed. Services already using `.env`/`env_file` get an
|
||||
`.env.example` generated from the live `.env` with values blanked.
|
||||
4. Verify without touching the stack: `podman compose config` rendered from
|
||||
the repo copy (with the live `.env`) must match the render of the live
|
||||
compose. Volume paths are kept **verbatim** at import (`volumes/`,
|
||||
`volume/`, `pg_data/` and all) so this diff can be exact; the `data/`
|
||||
standardization happens per service at cutover.
|
||||
5. Commit. The secrets contract (§8) gates every one of these commits.
|
||||
|
||||
**Import list (23):** Adminer, Agentchat, Authentik, Beszel, CalCom,
|
||||
ConvexMonorepo, Documenso, Gitea, Infisical, ISponsorBlockTV, LashAddict, N8n,
|
||||
Nginx_Proxy_Manager, Penpot, Portainer, PostgreSQL, Rustdesk, Spoon, StPeteIT,
|
||||
Tools, Uptime, UseSend, Vaultwarden. CalCom, Documenso, Penpot and N8n are
|
||||
currently parked (dir present, unit not active) — imported anyway, noted as
|
||||
parked in the catalog.
|
||||
|
||||
**Excluded:** Watchtower (retired — replaced by the update timer after it took
|
||||
gitea down; its dir stays on junior until cleanup), Completeuphoria,
|
||||
sierraandtyler, Bang (source checkouts deployed by CI from their own repos —
|
||||
Panama owning a second copy of their compose would drift), `ci/`, `logs/`,
|
||||
and junior's loose scripts other than `update-containers.sh` (§4).
|
||||
|
||||
**Cutover** (per service, later, not this work): stop → rename the volume dir
|
||||
to `data/` and update the repo compose to match → replace live compose with
|
||||
the symlink, installed unit with the symlink → `daemon-reload` → start →
|
||||
verify. Ten minutes a service, done when Gib chooses, favoring quiet hours.
|
||||
|
||||
## 8. Contracts and docs
|
||||
|
||||
New `tests/server/`, same discipline as the rest:
|
||||
|
||||
- `compose-secrets-contract` — fails when any tracked file under `server/`
|
||||
contains an inline secret-looking value (the `user-content-contract`
|
||||
pattern), when `.gitignore` stops covering `.env` under `server/`, or when
|
||||
`git ls-files` shows a tracked `.env`.
|
||||
- `containers-shape-contract` — every `server/containers/<Name>/` carries
|
||||
`compose.yml` + `podman-<name>.service`; every unit's `WorkingDirectory` is
|
||||
`%h/Server/<Name>`; every `${VAR}` used in a compose file appears in that
|
||||
service's `.env.example`.
|
||||
- `panama-server-contract` — throwaway `HOME`, stubbed `systemctl`/`podman` on
|
||||
PATH: `enable` creates the right symlinks and seeds `.env`; placeholder
|
||||
values block start; `disable` leaves data alone; `relink` reports changed
|
||||
definitions.
|
||||
- `role-contract` — the interview asks role first, `--server` presets it, the
|
||||
role file is written, stages filter correctly per role (via the stage
|
||||
scripts' static/stubbed seams).
|
||||
- `dotfile-classification-contract` — every dir in `config/dot/` appears in
|
||||
exactly one of link-dotfiles' universal/desktop lists.
|
||||
- README: layout section gains `server/`, the subcommand table gains
|
||||
`panama server`, the pinned contract count moves — `readme-contract` and the
|
||||
interview/link-dotfiles contracts that assert today's behavior update in the
|
||||
same change.
|
||||
|
||||
## Phasing
|
||||
|
||||
1. **Role plumbing** — interview question, role file, `--server`, package
|
||||
split, stage filters, `setup-server`, boot's root path.
|
||||
2. **Server skeleton** — `server/` tree, `panama-server`, `link-server`,
|
||||
update-timer adoption, contracts, README/docs.
|
||||
3. **Import** — junior's 23 services into `server/containers/` per §7.
|
||||
4. **Later, separately** — per-service cutover on junior; backup and
|
||||
verify-rootless design; server.gib docker→podman translation; a real
|
||||
end-to-end test on a fresh VPS.
|
||||
|
||||
Testing note: contracts for phases 1–2 run with stubs and throwaway HOMEs, but
|
||||
per standing practice all suite runs batch at the end of the session with
|
||||
Gib's go-ahead. Phase 3 touches junior only with read commands plus the inert
|
||||
`.env` writes described in §7.
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/claude-code-settings.json",
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(coredumpctl list:*)",
|
||||
"Bash(coredumpctl info:*)",
|
||||
"Bash(journalctl:*)",
|
||||
"Bash(rpm -q:*)",
|
||||
"Bash(panama doctor:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
---
|
||||
name: panama
|
||||
description: Use when working on the Panama repository itself — editing anything under config/, setup/, bin/, tests/, or the Quickshell shell and Hyprland Lua it ships. Covers the live-desktop hot-reload rules, the contract-test discipline, the fixture seams, and where the real documentation lives.
|
||||
---
|
||||
|
||||
# panama
|
||||
|
||||
You are editing a running desktop, not a codebase that gets deployed later. Every dotfile in this
|
||||
repository is symlinked into `~/.config`, so a save is live the moment it lands. That single fact
|
||||
drives every rule below.
|
||||
|
||||
Read before large work, in this order:
|
||||
|
||||
- `README.md` — layout, the `panama` command, how installing and updating work
|
||||
- `config/dot/hypr/README.md` — the compositor config is **Lua, not hyprlang**; read "The one
|
||||
thing to know first" and "Never use `hyprctl keyword`" before touching it
|
||||
- `config/dot/quickshell/modules/settings/README.md` — adding a setting, setting ownership,
|
||||
"Things that will bite you", and where state lives; the most load-bearing document here
|
||||
- `docs/settings.md` — generated reference for every settings key (never edit by hand)
|
||||
|
||||
## The live-desktop covenant
|
||||
|
||||
Editing `config/dot/quickshell/**` hot-reloads the live shell on every save.
|
||||
|
||||
1. Every save must leave valid QML. An intermediate broken save is a broken desktop, not a broken
|
||||
build. Convert a component and its body in ONE edit, never two.
|
||||
2. After each batch of saves, check the journal for errors AND "Unable to assign" warnings:
|
||||
`journalctl --user -u panama-quickshell.service --since '-2 minutes' --no-pager`
|
||||
3. Every `.qml` file in `config/dot/quickshell/modules/*` must be registered in that directory's
|
||||
`qmldir` in the same save batch. An unregistered component fails the whole configuration and
|
||||
takes down the bar, dock, and settings together. `tests/quickshell/qmldir-registration-contract`
|
||||
is pure file inspection — run it before the change lands.
|
||||
4. Never `qs kill` from a copied configuration: Quickshell derives the shell ID from config
|
||||
content, so a content-identical harness can share the live shell's ID and kill the desktop.
|
||||
Harnesses use a distinct entry file, `qs -p`, the PID from `qs list --all`, and `kill` that PID.
|
||||
5. Never `hyprctl keyword` — Lua-configured Hyprland refuses it, prints to stdout, and exits 0.
|
||||
`hyprctl eval` also exits 0 on errors. The only proof a write landed is `hyprctl getoption`
|
||||
reading it back. Batch `hyprctl reload` to one per verified change-set, and say when you do it.
|
||||
6. IPC: annotate every parameter and return type or Quickshell silently skips registration; never
|
||||
duplicate an `IpcHandler` target — one silently shadows the other.
|
||||
7. In `config/dot/quickshell/config/PreferenceSchema.qml`, `readAs` describes the compositor's
|
||||
ANSWER shape, not the setting's; a wrong one makes every successful write look rejected.
|
||||
8. No continuously repainting animations (pulse, shimmer, spinners) — they peg the GPU on
|
||||
high-refresh displays.
|
||||
9. `hyprlock.conf`, `hypridle.conf`, `hyprpaper.conf`, `hyprtoolkit.conf` never moved to Lua. Do
|
||||
not "fix" them. Do not install a notification daemon — the shell is one.
|
||||
10. Scripts carry no `.sh` extension (shebang + `chmod +x`), with one deliberate exception:
|
||||
`migrations/*.sh`, which `bin/panama-migrate` globs. No secrets under `user/` — it is
|
||||
world-readable and `tests/setup/user-content-contract` greps for key material.
|
||||
|
||||
## The contract discipline
|
||||
|
||||
Every executable contract under `tests/` is classified in `tests/contracts.manifest`. The rules:
|
||||
|
||||
- `panama contracts <file>` names the contracts that mention a file; run those after touching it.
|
||||
- During a desktop session, run `panama test --safe [pattern]`. `--safe` selects only contracts
|
||||
classified as `hermetic`.
|
||||
- A plain full run prompts in a terminal before any selected non-hermetic contract starts. In
|
||||
automation, grant every required capability with repeatable flags, for example
|
||||
`panama test --allow live-compositor --allow live-desktop keybinds`.
|
||||
- Each contract has a 180-second outer timeout by default. Override it with a positive
|
||||
`PANAMA_TEST_TIMEOUT_SECONDS` value. Failures print the contract's captured stdout and stderr.
|
||||
Successful stdout stays quiet. Successful stderr is surfaced as a warning.
|
||||
- Contracts run directly too: `tests/setup/interview-contract`.
|
||||
- After changing `PreferenceSchema.qml` or `services/SettingsRoutes.qml`, regenerate:
|
||||
`config/dot/quickshell/scripts/panama-settings-docs` (writes `docs/settings.md`) and
|
||||
`config/dot/quickshell/scripts/panama-settings-commands` (writes the launcher deep links).
|
||||
Both take `--check`; `tests/quickshell/settings-docs-contract` fails when stale.
|
||||
- `README.md` pins the contract count and the `panama` subcommand list
|
||||
(`tests/setup/readme-contract`); adding a contract or subcommand means updating it.
|
||||
|
||||
## Seams for testing
|
||||
|
||||
Helpers are built hermetic: validated inputs, JSON out, secrets on stdin only (never argv).
|
||||
Contracts reach them through env seams — `PANAMA_NETWORK_HELPER`, `PANAMA_FINGERPRINT_FIXTURE`,
|
||||
`PANAMA_EXTRAS_DIR`, and the `PANAMA_*_STATIC_ONLY` family that cuts a contract to its
|
||||
file-inspection half. Prefer exercising a helper through its contract's stubbed PATH over running
|
||||
it against the real system.
|
||||
|
||||
## Safety idioms (contract-enforced)
|
||||
|
||||
- Destructive actions are two-stage: `ConfirmAction` with a unique `actionId`, arbitrated through
|
||||
`ShellState.armedConfirm` — one armed confirm app-wide. Danger tone marks only the confirming
|
||||
press.
|
||||
- Failures render through `ErrorRow`; honest empty states through `NotMeasuredRow` with a
|
||||
`because:`; paragraph-length notes through `SettingsNote`.
|
||||
- Stored user actions (custom shortcuts, gestures, window rules) are DATA — an enum kind and a
|
||||
validated target resolved through whitelist tables in `config/dot/hypr/actions.lua`. Nothing in
|
||||
`~/.config/panama/settings.json` may ever be an executable command; the contracts
|
||||
(`tests/quickshell/settings-idiom-contract`, `tests/quickshell/keybind-rebind-contract`) pin all
|
||||
of this.
|
||||
|
||||
## Root work
|
||||
|
||||
Never bare `sudo`. Load the `panama-sudo` skill first. Migrations already follow the rule.
|
||||
|
||||
## Initial bootstrap trust
|
||||
|
||||
Use the complete command in `README.md` for a new machine. It downloads `boot`
|
||||
from the documented commit URL into a private temporary directory, enforces the
|
||||
documented curl time and byte limits, verifies the committed blob with
|
||||
`sha256sum -c`, and passes both pins to the verified script. Desktop and server
|
||||
bootstrap use the same commit and digest. Never substitute a branch URL, pipe a
|
||||
response into Bash, or invent a newer pin from an uncommitted file.
|
||||
|
||||
`boot` validates the full lowercase commit and SHA-256 before Git or install. A
|
||||
fresh destination fetches only that revision and creates a tracked local `main`
|
||||
after HEAD matches. An existing checkout must be clean and an ancestor of the
|
||||
requested revision; it advances with fast-forward only. Dirty, divergent,
|
||||
fetch-failed, or mismatched checkouts stop without reset or install. Once Panama
|
||||
exists, use `panama update` for normal updates.
|
||||
|
||||
`boot --server` is the one exception where a new Fedora VPS may begin as root.
|
||||
Before it offers SSH hardening, it copies a safe root key when possible or
|
||||
verifies the target key. The target user's `.ssh` must be owned by that user at
|
||||
`0700`, and `authorized_keys` must be owned by that user at `0600`. Without a
|
||||
verified target key, SSH hardening is unavailable and the bootstrap continues.
|
||||
Every non-comment key line must parse with `ssh-keygen`. Root-key destination
|
||||
creation and writing run as the target UID, followed by the same owner, mode,
|
||||
and key checks. Do not replace that with root writes or assume the user's
|
||||
primary group matches the username.
|
||||
|
||||
Accepted hardening uses atomic same-directory `00-panama.conf` with exactly
|
||||
`PermitRootLogin no`, `PasswordAuthentication no`, and
|
||||
`KbdInteractiveAuthentication no`. A pre-existing symlink or non-regular
|
||||
object makes hardening unavailable, as does a missing SSH unit. Panama runs
|
||||
`sshd -t`, then checks effective root and target-user policy with `sshd -T -C`
|
||||
before reloading the detected unit. Validation or reload failure restores a
|
||||
prior regular file with its metadata before it retries validation and reload.
|
||||
Failed recovery stops the handoff with instructions that distinguish a prior
|
||||
file from no prior file. The fixture contracts also cover declined hardening
|
||||
and interrupted preparation. `panama test --safe` never reloads a live daemon,
|
||||
so it is not live-host proof.
|
||||
+28
-3
@@ -1,5 +1,15 @@
|
||||
# Ignore bash environment variables.
|
||||
# Ignore bash environment variables, and the lock its writer takes.
|
||||
/config/bash/env
|
||||
/config/bash/.env.lock
|
||||
# Server secrets and state never enter the repo: live .env files belong in
|
||||
# ~/Server/<Name>/ (only compose.yml is symlinked back here), and data/ is
|
||||
# the bind-mount convention. Ignored anyway as a seatbelt -- the repo is
|
||||
# public, and tests/server/compose-secrets-contract is the check that this
|
||||
# line still exists.
|
||||
/server/containers/**/.env
|
||||
/server/containers/**/data/
|
||||
# Personal espanso triggers (name, email), seeded per-machine by setup-identity.
|
||||
/config/dot/espanso/match/identity.yml
|
||||
# Ignore backups of old config files
|
||||
/config/old
|
||||
# Ignore Wireguard config of course!
|
||||
@@ -14,10 +24,25 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
# Generated from the colour scheme; machine state, not configuration.
|
||||
# Generated from the color scheme; machine state, not configuration.
|
||||
/config/dot/kitty/current-theme.conf
|
||||
|
||||
# Generated from the colour scheme; machine state, not configuration.
|
||||
# Generated from the color scheme; machine state, not configuration.
|
||||
/config/dot/gtk-3.0/settings.ini
|
||||
/config/dot/gtk-3.0/bookmarks
|
||||
/config/dot/gtk-4.0/settings.ini
|
||||
/config/dot/tmux/current-theme.conf
|
||||
/config/dot/hypr/hyprlock.conf
|
||||
# The Firefox chrome override sheet, rendered from the active theme. It sits
|
||||
# beside custom.css because chrome/ is symlinked into the profile as one
|
||||
# directory; custom.css imports it and stays the file a person edits.
|
||||
/config/firefox/chrome/panama-theme.css
|
||||
/config/firefox/chrome/panama-theme.css.tmp
|
||||
|
||||
# Build products of the Vicinae extension. The source and reviewed lockfile are
|
||||
# in the repository; the dependency tree and bundle are machine state.
|
||||
/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
|
||||
!/config/local/share/vicinae/extensions/panama-search/package-lock.json
|
||||
|
||||
@@ -1,46 +1,214 @@
|
||||
# Panama
|
||||
|
||||
Formerly Sunhat. A personal config for Fedora, with the intention of helping a
|
||||
user set up their Fedora system with one command.
|
||||
user set up their Fedora system with one checked command.
|
||||
|
||||
```sh
|
||||
git clone https://git.gbrown.org/gib/Panama.git ~/.local/share/Panama
|
||||
~/.local/share/Panama/install
|
||||
(
|
||||
set -euo pipefail
|
||||
bootstrap_commit=0d1841cf86975d52485c94c0caa5d1b67e5c54a1
|
||||
bootstrap_sha=fd8dcd70d1266da109972df346e3d85d1037f6f6e1942b0157064412ab892372
|
||||
bootstrap_dir="$(umask 077; mktemp -d)"
|
||||
trap 'rm -rf -- "$bootstrap_dir"' EXIT
|
||||
bootstrap="$bootstrap_dir/boot"
|
||||
curl --fail --location --connect-timeout 10 --max-time 30 --max-filesize 262144 \
|
||||
--output "$bootstrap" \
|
||||
"https://git.gbrown.org/gib/Panama/raw/commit/$bootstrap_commit/boot"
|
||||
printf '%s %s\n' "$bootstrap_sha" "$bootstrap" >"$bootstrap_dir/boot.sha256"
|
||||
sha256sum -c "$bootstrap_dir/boot.sha256"
|
||||
PANAMA_BOOT_REVISION="$bootstrap_commit" PANAMA_BOOT_SHA256="$bootstrap_sha" \
|
||||
bash "$bootstrap"
|
||||
)
|
||||
```
|
||||
|
||||
`install` runs everything in `setup/scripts/` in order:
|
||||
`boot` installs git if the machine lacks it, creates a verified checkout at
|
||||
`~/.local/share/Panama` (or `$PANAMA_PATH`), and hands off to `install`. It is
|
||||
deliberately small enough to read first. The command downloads one immutable
|
||||
`boot` blob into a private temporary directory, checks its SHA-256, then asks
|
||||
that verified script to fetch the same commit. A clean existing checkout may
|
||||
fast-forward to that exact revision. A dirty or divergent checkout, a failed
|
||||
fetch, or a revision mismatch stops before `install`; Panama never resets local
|
||||
work or falls back to whatever checkout happens to be present.
|
||||
|
||||
Once a machine exists, the command that keeps it current is `panama update` —
|
||||
one command, and it never asks you anything:
|
||||
|
||||
```sh
|
||||
panama update
|
||||
```
|
||||
|
||||
It pulls, applies any repairs this machine has not had, and runs the stages
|
||||
below that need no answers. `./install` remains what it is: how a machine is
|
||||
built, and how you change an answer you gave.
|
||||
|
||||
The first question the interview asks is what the machine is: a **desktop**
|
||||
gets everything below; a **server** — a VPS, a headless box — gets the same
|
||||
shell environment minus everything that needs a screen, plus rootless podman
|
||||
and the compose services in [`server/`](server/README.md). The answer is the
|
||||
one thing Panama records durably (`~/.local/state/panama/role`), because
|
||||
`panama update` asks nothing and still has to know. A fresh server skips the
|
||||
question entirely. It uses the same immutable boot blob and pins as the desktop
|
||||
command; only the final `--server` argument differs:
|
||||
|
||||
```sh
|
||||
(
|
||||
set -euo pipefail
|
||||
bootstrap_commit=0d1841cf86975d52485c94c0caa5d1b67e5c54a1
|
||||
bootstrap_sha=fd8dcd70d1266da109972df346e3d85d1037f6f6e1942b0157064412ab892372
|
||||
bootstrap_dir="$(umask 077; mktemp -d)"
|
||||
trap 'rm -rf -- "$bootstrap_dir"' EXIT
|
||||
bootstrap="$bootstrap_dir/boot"
|
||||
curl --fail --location --connect-timeout 10 --max-time 30 --max-filesize 262144 \
|
||||
--output "$bootstrap" \
|
||||
"https://git.gbrown.org/gib/Panama/raw/commit/$bootstrap_commit/boot"
|
||||
printf '%s %s\n' "$bootstrap_sha" "$bootstrap" >"$bootstrap_dir/boot.sha256"
|
||||
sha256sum -c "$bootstrap_dir/boot.sha256"
|
||||
PANAMA_BOOT_REVISION="$bootstrap_commit" PANAMA_BOOT_SHA256="$bootstrap_sha" \
|
||||
bash "$bootstrap" --server
|
||||
)
|
||||
```
|
||||
|
||||
That command also works from a brand-new VPS's **root** login. It creates or
|
||||
reuses your sudo-enabled user, then copies a safe root key when it can or
|
||||
verifies the target key before offering SSH hardening. A verified target key
|
||||
means the target user owns `.ssh` with mode `0700` and `authorized_keys` with
|
||||
mode `0600`. Every non-comment line in `authorized_keys` must be valid OpenSSH
|
||||
key material that `ssh-keygen` can parse. Root-key destination writes run as
|
||||
the target user, and Panama rechecks the resulting owner, modes, and keys. SSH
|
||||
hardening is unavailable without a verified target key or installed SSH unit,
|
||||
and the install continues without it. Declining hardening also leaves SSH
|
||||
unchanged.
|
||||
|
||||
When you accept hardening, Panama uses an atomic same-directory drop-in named
|
||||
`00-panama.conf`, whose early filename gives it safer precedence. A pre-existing
|
||||
symlink or non-regular object makes hardening unavailable. The effective policy
|
||||
is exactly `PermitRootLogin no`, `PasswordAuthentication no`, and
|
||||
`KbdInteractiveAuthentication no`. Panama validates syntax with `sshd -t` and
|
||||
checks `sshd -T -C` for both root and target-user contexts before it reloads
|
||||
the detected SSH unit. If validation or reload fails, it restores the previous
|
||||
drop-in regular file with its metadata and validates and reloads that restored
|
||||
configuration. Recovery that cannot complete stops the handoff and prints the
|
||||
right manual command for either a prior file or no prior file. The fixture
|
||||
contracts test these branches. No real daemon reload runs under
|
||||
`panama test --safe`, so that suite is not live-host proof.
|
||||
|
||||
After that, it hands off to a normal install as the new user.
|
||||
|
||||
`install` asks its questions first and then runs the stages in `setup/scripts/`
|
||||
in order, without stopping again:
|
||||
|
||||
| Script | Does |
|
||||
|---|---|
|
||||
| `install-packages` | Repos (RPM Fusion, Terra, Hyprland COPR), then the four package lists in `setup/packages/` |
|
||||
| `link-dotfiles` | Symlinks `config/dot/<name>` → `~/.config/<name>` |
|
||||
| `interview` | Every prompt, before anything is installed. Answers last one run and are never written to a durable path |
|
||||
| `install-packages` | Repos (RPM Fusion, Terra, Hyprland COPR), the package lists in `setup/packages/`, then whichever optional categories were chosen |
|
||||
| `link-dotfiles` | Symlinks `config/dot/<name>` → `~/.config/<name>`, and seeds the wallpaper, cursor theme and Firefox chrome |
|
||||
| `link-skills` | Links the agent skills in `skills/` into `~/.claude/skills`, one per skill. Every machine gets these; personal ones link after and win a name clash |
|
||||
| `link-user` | Links the personal content in `user/` — agent instructions, SSH host aliases — but only on a machine that answered yes. See [user/README.md](user/README.md) |
|
||||
| `change-settings` | Copies `config/copy/` over `/`, applies gsettings, enables user services |
|
||||
| `link-vicinae-scripts` | Publishes the Vicinae script commands |
|
||||
| `setup-identity` | git config, `gh auth login`, an SSH key — whichever were asked for |
|
||||
| `install-hardware` | NVIDIA, Secure Boot enrollment, Fedora's extras, firmware — each only if it was asked for. Last, because enrollment and firmware are consumed at the next boot |
|
||||
| `setup-server` | Server role only: linger, unprivileged ports from 80, firewalld (80/443/81), the `nginx-bridge` network, and the nightly image-update timer |
|
||||
| `link-server` | Server role only: creates `~/Server` and refreshes the links of whatever services this machine has enabled |
|
||||
|
||||
A server runs the shared stages plus its own two, and skips the rest —
|
||||
`link-skills`, `change-settings`, `link-vicinae-scripts` and
|
||||
`install-hardware` are desktop concerns. `link-dotfiles` links only the
|
||||
universal dot dirs there (bash, nvim, tmux, btop and tmux theming, the hook
|
||||
samples), and `install-packages` takes a short path: the core tools, nvm,
|
||||
Bun, Claude Code and Codex — no third-party repos, no desktop, no flatpaks.
|
||||
|
||||
Then `panama migrate` applies any repairs this machine has not had yet. That is
|
||||
the half of an upgrade installing cannot do: the stages above only ever add, so
|
||||
nothing there can remove a file this repository stopped shipping or repair a
|
||||
symlink that now points nowhere. A fresh machine is marked as already caught up
|
||||
rather than having those repairs run against it, and a machine with work waiting
|
||||
is told at the next login rather than left to find out.
|
||||
|
||||
The run ends with a health summary from `panama-doctor`, which reports what is
|
||||
actually running rather than what was attempted. It never fails the install: on a
|
||||
fresh machine it legitimately reports things as not yet configured.
|
||||
|
||||
### Optional applications
|
||||
|
||||
Every machine gets the lists in `setup/packages/`. The interview also offers the
|
||||
categories in `setup/packages/extras/` as a checklist, so a work laptop need not
|
||||
acquire emulators and a desktop need not skip Steam. Nothing is preselected.
|
||||
|
||||
A category is one file. A bare line is a dnf package and a `flatpak:` line is a
|
||||
Flathub ID, because the applications in a category do not all come from one
|
||||
place. Adding a category is adding a file — the menu is read from the directory,
|
||||
not written down anywhere.
|
||||
|
||||
Existing configs are moved to `config/old/` rather than overwritten.
|
||||
|
||||
## Desktops
|
||||
## The desktop
|
||||
|
||||
Panama configures **two** desktops that coexist. Both sessions stay available in
|
||||
GDM, so you can switch back and forth while you settle in.
|
||||
Hyprland, with a shell written from scratch. It began as a replacement for a
|
||||
GNOME session — Forge for tiling, Dash-to-Dock, Openbar, Vitals — and was built
|
||||
to reproduce it closely enough that muscle memory transferred: same keybinds,
|
||||
same panel contents, same dock, same Tokyo Night Moon palette.
|
||||
|
||||
### GNOME
|
||||
The original setup: Forge for tiling, Dash-to-Dock, Openbar, Vitals,
|
||||
AppIndicator support. Configured through `config/dot/forge/` and gsettings.
|
||||
That is history now rather than a second option. Panama installs and configures
|
||||
one desktop, and the GNOME session it grew out of is neither installed nor
|
||||
configured here. What each piece replaced is recorded in
|
||||
[`config/dot/hypr/DESKTOP-PARITY.md`](config/dot/hypr/DESKTOP-PARITY.md) and in
|
||||
the comments of the components themselves, because knowing what a thing was
|
||||
modelled on explains why it behaves the way it does.
|
||||
|
||||
### Hyprland
|
||||
A from-scratch replacement built to reproduce the GNOME setup closely enough
|
||||
that muscle memory transfers — same keybinds, same panel contents, same dock,
|
||||
same Tokyo Night Moon palette.
|
||||
GNOME is not gone from the machine: `gnome-control-center` is a declared
|
||||
dependency, and two rows in Panama's own Settings still open it. Adding an
|
||||
online account goes through the provider's dialog, because the OAuth sign-in
|
||||
runs inside a library Fedora ships without a binding anything else can call.
|
||||
And Digital wellbeing — screen time and break reminders — is the one panel of
|
||||
GNOME's that still does something Panama does not. Everything else it used to
|
||||
hand over is a page here now, and
|
||||
[`tests/quickshell/gnome-handoff-contract`](tests/quickshell/gnome-handoff-contract)
|
||||
fails the build if a door back opens onto a panel Panama owns, because those
|
||||
rows kept working perfectly long after they stopped being true.
|
||||
|
||||
Displays is one of the panels it owns, and the only one where a wrong answer
|
||||
can leave you unable to see well enough to undo it. So every change there is
|
||||
applied as one complete layout, read back from the compositor, and reverted
|
||||
after fifteen seconds unless you keep it — resolution, scale, rotation,
|
||||
position and primary display, and now color profile, bit depth, SDR
|
||||
brightness and saturation, and mirroring with them. Two of those opt out of
|
||||
part of that, for reasons rather than convenience. A per-display variable
|
||||
refresh rate override is applied but never verified, because `hyprctl` reports
|
||||
whether adaptive sync is live this instant rather than what was asked for. And
|
||||
a mirrored display's position is not asserted at all: the compositor stacks it
|
||||
on the display it mirrors and ignores the coordinates the rule carried, so
|
||||
holding it to them would make Keep permanently unavailable. Monitor brightness
|
||||
sits outside the transaction entirely — it is the panel's own backlight over
|
||||
DDC, and the buttons on the bezel change it behind our back.
|
||||
|
||||
| Piece | What it is |
|
||||
|---|---|
|
||||
| `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/vicinae/` | Raycast-style launcher, themed |
|
||||
| `config/containers/` | Container definitions systemd runs as units — currently the speech-to-text server behind dictation |
|
||||
| `config/dot/vicinae/` | Raycast-style launcher, themed. Its commands live in `config/local/share/vicinae/` — script commands (settings deep links, power actions, projects, reminders, window switcher, kill process, SSH hosts, recent files, color picker), and one compiled extension that adds web search with live suggestions. File search, calculator, clipboard, and emoji are Vicinae's own |
|
||||
| `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/xdg-desktop-portal/` | Portal backend routing |
|
||||
|
||||
New machines get a welcome card on first start, teaching the handful of keys
|
||||
that matter and reachable afterwards from the launcher. `SUPER + /` shows every
|
||||
shortcut the machine has, read from the live keymap so a rebind appears there
|
||||
without anything being kept in sync. Rebinding is Settings' job, and so is
|
||||
adding a shortcut of your own or assigning the four-finger touchpad swipes: an
|
||||
entry names an application, a shell action or a window move rather than
|
||||
carrying a command, so a hand-edited settings file can move a key but cannot
|
||||
make one run something arbitrary. Settings carries a
|
||||
[manual](config/dot/quickshell/manual/) written for the person using the
|
||||
desktop rather than the person building it, opening with a chapter for people
|
||||
arriving from GNOME, macOS or Windows.
|
||||
|
||||
Laptops work: battery, charge limits, idle timings that differ on battery, and
|
||||
a closed lid that suspends when you are leaving and keeps working when you are
|
||||
docked. A desktop sees none of it, because every one of those surfaces hides
|
||||
when the hardware is absent.
|
||||
|
||||
**Start here: [`config/dot/hypr/README.md`](config/dot/hypr/README.md)** — it
|
||||
covers the Lua migration, the uwsm environment gotcha, the HDR decision, the
|
||||
full keymap, and troubleshooting.
|
||||
@@ -50,13 +218,159 @@ Log in as **"Hyprland (uwsm-managed)"**, not plain "Hyprland".
|
||||
## Layout
|
||||
|
||||
```
|
||||
bin/ Small user-facing commands on PATH
|
||||
bin/ Small user-facing commands on PATH; `panama` is the entry point
|
||||
migrations/ One repair per file, for machines installed before a change
|
||||
config/
|
||||
bash/ .bashrc, aliases, env (env is gitignored)
|
||||
copy/ Files copied verbatim over / (needs sudo)
|
||||
dot/ Symlinked into ~/.config
|
||||
dot/ Symlinked into ~/.config; quickshell/manual/ is the manual
|
||||
Settings renders
|
||||
firefox/ Vendored Firefox chrome, linked into the browser profile
|
||||
containers/ Quadlets, linked into ~/.config/containers/systemd
|
||||
local/ Icons, the cursor theme, and the launcher's commands and
|
||||
extensions, linked into ~/.local/share
|
||||
old/ Backups of whatever was replaced (gitignored)
|
||||
wallpapers/ Copied into ~/Pictures/Wallpapers when absent
|
||||
server/ The server role: compose services (one directory per
|
||||
service), the nightly image updater, and its units. See
|
||||
server/README.md
|
||||
skills/ Agent skills for operating this desktop, linked into
|
||||
~/.claude/skills
|
||||
setup/
|
||||
packages/ One package per line
|
||||
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
|
||||
scripts/ Run in order by ./install
|
||||
tests/ Contracts. See below
|
||||
docs/ Settings reference, and the design specs behind the work
|
||||
```
|
||||
|
||||
## Tests
|
||||
|
||||
188 of them, under `tests/`. `tests/contracts.manifest` classifies every
|
||||
contract by the capabilities it needs. Run the hermetic set, or grant a
|
||||
specific external capability when automation needs it:
|
||||
|
||||
```sh
|
||||
panama test --safe
|
||||
panama test --allow live-host updates
|
||||
panama test --allow live-compositor keybinds
|
||||
PANAMA_TEST_TIMEOUT_SECONDS=300 panama test --safe
|
||||
tests/setup/interview-contract # or one directly; they are plain executables
|
||||
```
|
||||
|
||||
`--safe` means hermetic contracts only. A plain full run prompts in a terminal
|
||||
before it starts any non-hermetic contract. Automation cannot answer that
|
||||
prompt, so it must grant every required capability with a repeatable `--allow`.
|
||||
Each contract has a 180-second outer timeout by default. Set
|
||||
`PANAMA_TEST_TIMEOUT_SECONDS` to a positive integer to override it. When a
|
||||
contract fails, the runner prints its captured stdout and stderr. Successful
|
||||
stdout stays quiet. Successful stderr is surfaced as a warning.
|
||||
|
||||
They are called contracts rather than unit tests because that is what they are:
|
||||
each one pins a decision that was expensive to get right and is cheap to undo by
|
||||
accident. Most read or measure the real thing — launching a shell to measure a
|
||||
surface's geometry, standing stub commands on `PATH` to see what a stage would
|
||||
have installed, running a script against a throwaway `HOME` — rather than
|
||||
asserting things about source text, because the bugs worth catching here have all
|
||||
been ones that source text looked fine for.
|
||||
|
||||
```
|
||||
tests/setup/ The installer: the interview, package lists, hardware, extras
|
||||
tests/quickshell/ The shell and its settings pages
|
||||
tests/hypr/ The compositor config
|
||||
tests/server/ The server role: the service catalog's shape, the secrets
|
||||
rule that keeps a public repo safe, and panama-server
|
||||
```
|
||||
|
||||
## Projects
|
||||
|
||||
A project is the set of windows you open together — which applications, which
|
||||
workspace each was on, and for a terminal, which directory it was sitting in.
|
||||
Arrange the desktop, then run **Save Layout as Project** from the launcher and
|
||||
name it; **Open Project** lays it out again.
|
||||
|
||||
Workspaces are recorded as positions rather than numbers, and opening a project
|
||||
claims free ones, so it never lands on top of what you are already doing. An
|
||||
application that refuses to open twice — Slack, Thunderbird, the browser — is
|
||||
moved into place rather than launched again. Saved layouts are listed on
|
||||
Shell › Workspaces in Settings, which is also where they are removed.
|
||||
|
||||
## The `panama` command
|
||||
|
||||
```sh
|
||||
panama update # bring this machine up to date; asks nothing
|
||||
panama sync # review, commit and push your changes to this repo
|
||||
panama edit # open it in Neovim
|
||||
panama doctor # what is actually running, not what was installed
|
||||
panama diagnose # hand the health summary and recent errors to your agent
|
||||
panama test # every contract, prompting before non-hermetic work
|
||||
panama test --safe # hermetic contracts only
|
||||
panama test --allow live-host updates # grant one capability to automation
|
||||
panama contracts <file> # which contracts mention a file, and can they be run
|
||||
panama migrate # apply repairs this machine has not had yet
|
||||
panama upgrade # re-run ./install from anywhere, interview and all
|
||||
panama apps # choose applications to install, by category
|
||||
panama app # applications no repository carries; build one by name
|
||||
panama server # the services a server runs: list, enable, disable, status
|
||||
```
|
||||
|
||||
`panama update` and `panama sync` are separate verbs on purpose. One acts on
|
||||
the machine, the other on the repository. A single command that chose between
|
||||
them by checking whether the working tree happened to be dirty would do a
|
||||
different job depending on state nobody can see — and, worse, would never
|
||||
update a machine belonging to somebody who had left a file edited.
|
||||
|
||||
`panama update` stashes uncommitted work across the pull and restores it
|
||||
afterwards. If restoring conflicts it resets the checkout and leaves the work
|
||||
in the stash, saying so at the end of the run: every dotfile here is a symlink
|
||||
into this repository, so a conflict marker is not a thing to fix at leisure. It
|
||||
is live in `~/.config` the moment it is written.
|
||||
|
||||
Only two things still need `./install`: a machine that does not exist yet, and
|
||||
an answer you want to change. Adding a package to a list you already have is
|
||||
`panama update`; adding an optional category is `panama apps`.
|
||||
|
||||
`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.
|
||||
|
||||
Hooks are the extension point: drop a script at `~/.config/panama/hooks/theme-set`
|
||||
and it runs whenever the color scheme changes, with the scheme and accent as
|
||||
arguments. Same for `post-upgrade` and `post-migrate`, and a `<name>.d/`
|
||||
directory beside each so several things can react without fighting over one
|
||||
file. A broken hook is reported and stepped over, never fatal. Samples are
|
||||
copied into place on install.
|
||||
|
||||
`panama migrate` applies repairs an installed machine has not had yet. Safe to
|
||||
re-run: nothing is applied twice, and a machine with nothing waiting says so.
|
||||
|
||||
`panama-sudo` is pkexec with a stated reason: `panama-sudo --reason "why" --
|
||||
command` shows the reason on Panama's password prompt, clearly labeled as an
|
||||
unverified claim beside polkitd's own action text — meant for agents and
|
||||
scripts, so the person typing the password learns why before they do. Without
|
||||
a reason, a running shell, or `qs` it behaves exactly like pkexec.
|
||||
|
||||
`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.
|
||||
|
||||
No script in this repository carries a `.sh` extension, with one deliberate
|
||||
exception: each repair in `migrations/` is named `<timestamp>.sh`, because
|
||||
`panama-migrate` finds them by globbing exactly that. Everywhere else a shebang
|
||||
and the executable bit already select the interpreter, and the extension only
|
||||
becomes something to keep in sync — which it did not stay.
|
||||
|
||||
@@ -152,11 +152,14 @@ case "$cmd" in
|
||||
|
||||
# 2.4) Run flatpak updates (user then system)
|
||||
flatpak update -y
|
||||
sudo flatpak update
|
||||
sudo flatpak update -y
|
||||
|
||||
# 2.5) Optional firmware via fwupd
|
||||
if $firmware; then
|
||||
sudo fwupdmgr refresh
|
||||
# fwupdmgr exits non-zero when metadata is already current -- that is
|
||||
# not an error, but under 'set -e' it would abort the script before
|
||||
# 'fwupdmgr update' ever runs.
|
||||
sudo fwupdmgr refresh || true
|
||||
sudo fwupdmgr update
|
||||
fi
|
||||
|
||||
|
||||
+990
-47
File diff suppressed because it is too large
Load Diff
Executable
+195
@@ -0,0 +1,195 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Hand a prompt to whichever coding agent this machine has chosen.
|
||||
#
|
||||
# Every rung of the escalation ladder ends here: a crash toast, a failed shell
|
||||
# reload, a red health check, `panama diagnose`. They gather facts; this decides
|
||||
# which binary runs them and puts it in a terminal you can watch and interrupt.
|
||||
#
|
||||
# Two settings decide everything, and both are read at press time rather than at
|
||||
# start time, so choosing an agent in Settings takes effect on the next crash
|
||||
# without restarting anything:
|
||||
#
|
||||
# preferredAgent none | claude | codex ("none" is the default: silence)
|
||||
# agentAutoApprove true -> the agent starts in its own "don't stop to ask"
|
||||
# mode; false -> its normal prompting mode, untouched.
|
||||
#
|
||||
# "none" exits 0 without a word. It is not an error to have no agent; it is the
|
||||
# shipped state, and a rung that shouted about it would be a rung that gets
|
||||
# turned off.
|
||||
#
|
||||
# panama-agent open the agent on the repo
|
||||
# panama-agent --prompt "text" open it with something to work on
|
||||
#
|
||||
# Environment seams, for the contract and for a second checkout:
|
||||
#
|
||||
# PANAMA_PATH the repository; also the agent's working directory
|
||||
# PANAMA_AGENT_SETTINGS the settings file to read (default: the real one)
|
||||
#
|
||||
# ---------------------------------------------------------------------------
|
||||
# Adapted from Omarchy's bin/omarchy-agent (https://github.com/basecamp/omarchy)
|
||||
#
|
||||
# Copyright (c) David Heinemeier Hansson
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to deal
|
||||
# in the Software without restriction, including without limitation the rights
|
||||
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
# copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be included in all
|
||||
# copies or substantial portions of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
# SOFTWARE.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
PANAMA_PATH="${PANAMA_PATH:-$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)}"
|
||||
SETTINGS="${PANAMA_AGENT_SETTINGS:-${XDG_CONFIG_HOME:-$HOME/.config}/panama/settings.json}"
|
||||
|
||||
# A fixed window class rather than the per-binary default, so one Hyprland rule
|
||||
# can catch every agent window regardless of which agent is chosen.
|
||||
readonly WINDOW_CLASS="panama-agent"
|
||||
|
||||
# THE INHERITED PATH IS NOT THE USER'S PATH. Every rung except `panama diagnose`
|
||||
# reaches this script from the Quickshell shell, which systemd starts with
|
||||
# neither PANAMA_PATH nor ~/.local/bin -- and ~/.local/bin is where both agents
|
||||
# install themselves. Trusting PATH here meant a perfectly well installed agent
|
||||
# reporting itself as missing, into the stderr of a detached process nobody will
|
||||
# ever read: the whole ladder failing silently, which is the exact failure it
|
||||
# exists to prevent.
|
||||
#
|
||||
# So the binary is resolved rather than named. PATH first, because a user who
|
||||
# put an agent somewhere else meant it; then the XDG user bin directory, which
|
||||
# is where the installers actually put them.
|
||||
resolve_agent() {
|
||||
local name="$1" found
|
||||
found="$(command -v "$name" 2>/dev/null)" && { printf '%s' "$found"; return 0; }
|
||||
[[ -x "$HOME/.local/bin/$name" ]] && { printf '%s' "$HOME/.local/bin/$name"; return 0; }
|
||||
return 1
|
||||
}
|
||||
|
||||
# The same repair, for the agent's own sake rather than this script's: an agent
|
||||
# launched from a notification click would otherwise run every shell command it
|
||||
# is asked to with a PATH unlike the one the user gets in a terminal. Applied
|
||||
# just before the spawn rather than here, so resolve_agent above is answering
|
||||
# the question the caller actually asked -- "can this be found from where I was
|
||||
# started" -- instead of one this script has already fixed for itself.
|
||||
repair_path() {
|
||||
case ":$PATH:" in
|
||||
*":$HOME/.local/bin:"*) ;;
|
||||
*) PATH="$PATH:$HOME/.local/bin" ;;
|
||||
esac
|
||||
export PATH
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: panama-agent [--prompt "text"]
|
||||
|
||||
Opens the agent named by preferredAgent in a terminal, in the Panama checkout.
|
||||
With no agent chosen, exits silently: choose one on Settings > System > Agents.
|
||||
EOF
|
||||
}
|
||||
|
||||
prompt=""
|
||||
while (($#)); do
|
||||
case "$1" in
|
||||
--prompt)
|
||||
prompt="${2:?--prompt needs a value}"
|
||||
shift 2
|
||||
;;
|
||||
-h | --help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
printf 'panama-agent: unexpected argument: %s\n' "$1" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Same shape as panama-idle's reader: a missing file, a missing key and an
|
||||
# explicit null all mean "the default", because all three describe a machine
|
||||
# that has never been asked the question.
|
||||
read_setting() {
|
||||
local key="$1" fallback="$2"
|
||||
[[ -r "$SETTINGS" ]] || { printf '%s' "$fallback"; return; }
|
||||
command -v jq >/dev/null 2>&1 || { printf '%s' "$fallback"; return; }
|
||||
jq -r --arg k "$key" --arg d "$fallback" \
|
||||
'if has($k) and (.[$k] != null) then (.[$k] | tostring) else $d end' \
|
||||
"$SETTINGS" 2>/dev/null || printf '%s' "$fallback"
|
||||
}
|
||||
|
||||
agent="$(read_setting preferredAgent none)"
|
||||
|
||||
# The shipped state. Nothing to launch, nothing to say.
|
||||
[[ -n "$agent" && "$agent" != "none" ]] || exit 0
|
||||
|
||||
auto_approve="$(read_setting agentAutoApprove true)"
|
||||
|
||||
case "$agent" in
|
||||
claude | codex) ;;
|
||||
*)
|
||||
printf 'panama-agent: unsupported preferredAgent: %s\n' "$agent" >&2
|
||||
printf 'Choose one on Settings > System > Agents.\n' >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# Resolved to a path before argv is built, so kitty is never asked to repeat a
|
||||
# PATH lookup this script has already done more carefully than kitty could.
|
||||
if ! agent_bin="$(resolve_agent "$agent")"; then
|
||||
printf 'panama-agent: %s is not installed.\n' "$agent" >&2
|
||||
printf 'Looked on PATH and in %s.\n' "$HOME/.local/bin" >&2
|
||||
printf 'Install it, or choose another agent on Settings > System > Agents.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The per-agent launch table. VERIFIED against the installed binaries' --help on
|
||||
# 2026-08-25 (claude 2.1.245, codex-cli 0.149.1) -- these flags are not the same
|
||||
# from release to release, so re-run --help before changing them.
|
||||
#
|
||||
# claude --permission-mode auto "auto" is one of acceptEdits/auto/
|
||||
# bypassPermissions/manual/dontAsk/plan
|
||||
# codex --approve-for-me routes approvals through automatic review
|
||||
# inside the workspace-write sandbox
|
||||
#
|
||||
# With agentAutoApprove off, no mode flag is passed at all: the agent's own
|
||||
# configured default is a choice the user already made, and overriding it with
|
||||
# an explicit "prompt me" would be this script having an opinion it was told not
|
||||
# to have.
|
||||
declare -a argv=("$agent_bin")
|
||||
case "$agent" in
|
||||
claude) [[ "$auto_approve" == "true" ]] && argv+=(--permission-mode auto) ;;
|
||||
codex) [[ "$auto_approve" == "true" ]] && argv+=(--approve-for-me) ;;
|
||||
esac
|
||||
|
||||
# One argv element, after the option terminator. Both CLIs take the prompt as a
|
||||
# trailing positional, and `--` is what stops a prompt beginning with a dash --
|
||||
# or one that happens to read like a subcommand -- from being parsed as flags.
|
||||
[[ -n "$prompt" ]] && argv+=(-- "$prompt")
|
||||
|
||||
# The checkout, not $HOME: the skills the prompts point at, the repository the
|
||||
# agent is being asked about, and .claude/settings.json's pre-approved read-only
|
||||
# diagnostics all live here. An agent started anywhere else finds none of them.
|
||||
cd "$PANAMA_PATH"
|
||||
|
||||
repair_path
|
||||
|
||||
# setsid so the agent outlives whatever spawned it -- a notification handler, a
|
||||
# crash watcher, a terminal that is about to close.
|
||||
exec setsid kitty \
|
||||
--directory "$PANAMA_PATH" \
|
||||
--class "$WINDOW_CLASS" \
|
||||
-e "${argv[@]}"
|
||||
Executable
+85
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# "Something crashed" -> an agent already reading the core dump.
|
||||
#
|
||||
# Reached by clicking the crash notification panama-crash-watch sends, or run by
|
||||
# hand against any PID in `coredumpctl list`. It gathers the four facts
|
||||
# systemd-coredump recorded and points at the skill that says what to do with
|
||||
# them; the method lives in the skill so it is edited in one place and works
|
||||
# whichever agent is configured.
|
||||
#
|
||||
# panama-agent-crash <pid> [comm] [exe] [signal]
|
||||
#
|
||||
# The skill is named AND given as an absolute path. A harness with a skill
|
||||
# mechanism follows the name; one without still has a file to read. That is the
|
||||
# whole reason this ladder works for more than one agent.
|
||||
#
|
||||
# ---------------------------------------------------------------------------
|
||||
# Adapted from Omarchy's bin/omarchy-agent-crash
|
||||
# (https://github.com/basecamp/omarchy)
|
||||
#
|
||||
# Copyright (c) David Heinemeier Hansson
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to deal
|
||||
# in the Software without restriction, including without limitation the rights
|
||||
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
# copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be included in all
|
||||
# copies or substantial portions of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
# SOFTWARE.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
PANAMA_PATH="${PANAMA_PATH:-$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)}"
|
||||
|
||||
pid="${1:-}"
|
||||
if [[ ! "$pid" =~ ^[0-9]+$ ]]; then
|
||||
printf 'Not a PID: %s\n' "${pid:-<missing>}" >&2
|
||||
printf 'Usage: panama-agent-crash <pid> [comm] [exe] [signal] (see: coredumpctl list)\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
comm="${2:-unknown}"
|
||||
exe="${3:-unknown}"
|
||||
signal="${4:-unknown}"
|
||||
|
||||
skill="$PANAMA_PATH/skills/diagnose-crash/SKILL.md"
|
||||
|
||||
# Looked up live so a PID typed by hand still gets a timestamp. A core that has
|
||||
# already been rotated away costs only the timestamp, so this is allowed to
|
||||
# fail: the other four facts are enough to start on.
|
||||
when="$(coredumpctl list "$pid" --no-pager --no-legend 2>/dev/null | tail -1 | cut -d' ' -f1-4)" || true
|
||||
when="${when:-unknown}"
|
||||
[[ -n "${when// }" ]] || when="unknown"
|
||||
|
||||
prompt="$(
|
||||
cat <<PROMPT
|
||||
A process crashed on this Panama machine and I want to know why.
|
||||
|
||||
What systemd-coredump recorded:
|
||||
process: $comm
|
||||
PID: $pid
|
||||
binary: $exe
|
||||
signal: $signal
|
||||
time: $when
|
||||
|
||||
Use the diagnose-crash skill. It covers how to investigate, what to rule out
|
||||
first, and what to report. If your harness has no skill mechanism, read the
|
||||
skill file directly and follow it instead:
|
||||
|
||||
$skill
|
||||
PROMPT
|
||||
)"
|
||||
|
||||
exec "$PANAMA_PATH/bin/panama-agent" --prompt "$prompt"
|
||||
Executable
+67
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# "The shell would not reload" -> an agent already holding the error.
|
||||
#
|
||||
# Quickshell keeps the old shell running when a reload fails, which is what
|
||||
# makes this rung possible at all: the desktop that just refused the new code is
|
||||
# still there to notify you about it, and still there to click. shell.qml's
|
||||
# onReloadFailed sends that notification; this builds the prompt behind it.
|
||||
#
|
||||
# panama-agent-reload "<what Quickshell said>"
|
||||
#
|
||||
# The failure string on its own is usually one line naming one file. The journal
|
||||
# around it is where the rest is -- the QML warnings that preceded the fatal
|
||||
# one, the property that was already undefined two saves ago -- so both go in.
|
||||
#
|
||||
# Environment seams, for the contract:
|
||||
#
|
||||
# PANAMA_PATH the repository
|
||||
# PANAMA_RELOAD_UNIT the unit to read (default panama-quickshell.service)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
PANAMA_PATH="${PANAMA_PATH:-$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)}"
|
||||
UNIT="${PANAMA_RELOAD_UNIT:-panama-quickshell.service}"
|
||||
|
||||
summary="${1:-}"
|
||||
if [[ -z "${summary// }" ]]; then
|
||||
printf 'Usage: panama-agent-reload "<the reload failure>"\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Read a generous window and filter it down, rather than asking journalctl for
|
||||
# forty lines and hoping they were the relevant ones. A failed reload usually
|
||||
# arrives after a burst of unrelated shell chatter.
|
||||
#
|
||||
# Each line is truncated because the prompt leaves as one argv element and the
|
||||
# kernel caps that at 128KB; a single Quickshell backtrace can be most of it.
|
||||
context=""
|
||||
if command -v journalctl >/dev/null 2>&1; then
|
||||
context="$(journalctl --user -u "$UNIT" -n 400 --no-pager --output=cat 2>/dev/null \
|
||||
| grep -iE 'quickshell|\.qml|qml:|panama' \
|
||||
| tail -40 \
|
||||
| cut -c 1-300)" || true
|
||||
fi
|
||||
[[ -n "${context// }" ]] || context="(nothing in the journal for $UNIT)"
|
||||
|
||||
prompt="$(
|
||||
cat <<PROMPT
|
||||
The Panama shell refused to reload on this machine. The old shell is still
|
||||
running, so the desktop is up, but the change that was just saved is not live.
|
||||
|
||||
What Quickshell reported:
|
||||
|
||||
$summary
|
||||
|
||||
The last relevant lines from $UNIT:
|
||||
|
||||
$context
|
||||
|
||||
The shell lives in config/dot/quickshell in this repository, symlinked into
|
||||
~/.config/quickshell -- so the file that failed to parse is a tracked file here,
|
||||
not a copy. Find what broke the reload and say what it is. Read before you
|
||||
write: a bad guess saved into this tree is live in the desktop immediately.
|
||||
PROMPT
|
||||
)"
|
||||
|
||||
exec "$PANAMA_PATH/bin/panama-agent" --prompt "$prompt"
|
||||
Executable
+143
@@ -0,0 +1,143 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Tell somebody when a program crashes.
|
||||
#
|
||||
# On GNOME, ABRT says so. Under a hand-assembled Hyprland desktop nothing does,
|
||||
# and applications die silently -- which is most of how "Linux is flaky" gets
|
||||
# earned. Fedora ships systemd-coredump by default, so the information is
|
||||
# already there; nobody is reading it.
|
||||
#
|
||||
# Follows the journal for systemd-coredump's own message id and reports each
|
||||
# program once per session.
|
||||
#
|
||||
# ONCE PER SESSION IS THE WHOLE DESIGN. This machine's portal backend crashes
|
||||
# between eleven and sixty times a day -- see the portal-stability check in
|
||||
# panama-doctor -- and a notification per crash would be a notification every
|
||||
# few minutes for something the user can do nothing about. The first one is
|
||||
# news; the fortieth is why people turn notifications off. The health page
|
||||
# carries the running count for anyone who wants it.
|
||||
#
|
||||
# When an agent has been chosen, the notification stops being a dead end. It
|
||||
# carries the diagnosis command as data in a `panama-exec` hint, which the shell
|
||||
# runs on click. Command-as-data rather than a libnotify action, because an
|
||||
# action would tie the click to this process still being alive to hear it, and
|
||||
# this process is a `journalctl -f` that outlives nothing in particular. The
|
||||
# hint survives a shell restart and never blocks the watcher.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}"
|
||||
SETTINGS="${PANAMA_AGENT_SETTINGS:-${XDG_CONFIG_HOME:-$HOME/.config}/panama/settings.json}"
|
||||
|
||||
# systemd-coredump's MESSAGE_ID. Matching on this rather than on text keeps
|
||||
# working when the wording changes and never matches a program that merely
|
||||
# mentions the word "crash" in its own logs.
|
||||
readonly COREDUMP_MESSAGE_ID='fc2e22bc6ee647b6b90729ab34a250b1'
|
||||
|
||||
command -v journalctl >/dev/null 2>&1 || exit 0
|
||||
command -v notify-send >/dev/null 2>&1 || exit 0
|
||||
|
||||
# The shell owns org.freedesktop.Notifications, and the crash most worth
|
||||
# reporting is the one that took the shell with it. Waiting means that report
|
||||
# arrives rather than vanishing into a bus nobody is serving.
|
||||
for _ in $(seq 1 60); do
|
||||
busctl --user status org.freedesktop.Notifications >/dev/null 2>&1 && break
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# Read per crash rather than once at startup, so choosing an agent in Settings
|
||||
# takes effect on the next crash instead of on the next login. This service runs
|
||||
# for the life of the session; nothing restarts it when a preference changes.
|
||||
read_setting() {
|
||||
local key="$1" fallback="$2"
|
||||
[[ -r "$SETTINGS" ]] || { printf '%s' "$fallback"; return; }
|
||||
command -v jq >/dev/null 2>&1 || { printf '%s' "$fallback"; return; }
|
||||
jq -r --arg k "$key" --arg d "$fallback" \
|
||||
'if has($k) and (.[$k] != null) then (.[$k] | tostring) else $d end' \
|
||||
"$SETTINGS" 2>/dev/null || printf '%s' "$fallback"
|
||||
}
|
||||
|
||||
# What to call the agent in a sentence aimed at a person.
|
||||
agent_label() {
|
||||
case "$1" in
|
||||
claude) printf 'Claude Code' ;;
|
||||
codex) printf 'Codex' ;;
|
||||
*) printf '%s' "$1" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
declare -A reported=()
|
||||
|
||||
# -f from now, not from the boot: a session that starts after a crash should
|
||||
# not open with a notification about something the user has already lived
|
||||
# through and cannot act on.
|
||||
journalctl --user -f -n 0 --output=json MESSAGE_ID="$COREDUMP_MESSAGE_ID" 2>/dev/null \
|
||||
| while IFS= read -r line; do
|
||||
[[ -n "$line" ]] || continue
|
||||
|
||||
# One jq per entry rather than one per field: the fields are read
|
||||
# together, and the click payload needs all of them.
|
||||
IFS=$'\t' read -r uid exe comm pid signal < <(
|
||||
jq -r '[(.COREDUMP_UID // ""),
|
||||
(.COREDUMP_EXE // ""),
|
||||
(.COREDUMP_COMM // ""),
|
||||
(.COREDUMP_PID // ""),
|
||||
(.COREDUMP_SIGNAL_NAME // "")] | @tsv' <<<"$line" 2>/dev/null
|
||||
)
|
||||
|
||||
# Another user's crash is not this session's business, and reporting it
|
||||
# would leak what they are running.
|
||||
[[ "$uid" == "$(id -u)" ]] || continue
|
||||
[[ -n "$exe" || -n "$comm" ]] || continue
|
||||
|
||||
# The executable name first: COREDUMP_COMM is the kernel's comm field
|
||||
# and is truncated to fifteen characters, so it reports
|
||||
# "panama-test-cra" for a program called panama-test-crasher.
|
||||
if [[ -n "$exe" ]]; then
|
||||
program="$(basename "$exe")"
|
||||
else
|
||||
program="$comm"
|
||||
fi
|
||||
|
||||
# Never announce our own machinery. A crash watcher that notifies about
|
||||
# the crash watcher, or about the agent it just launched to investigate
|
||||
# the last crash, is a loop with a toast in it.
|
||||
[[ "$program" == panama-crash-* || "$program" == panama-agent* ]] && continue
|
||||
|
||||
[[ -z "${reported[$program]:-}" ]] || continue
|
||||
reported[$program]=1
|
||||
|
||||
# The toast can only offer a diagnosis if there is something to diagnose
|
||||
# with. No agent, or the offer switched off, and it stays exactly the
|
||||
# actionless notification it has always been.
|
||||
agent="$(read_setting preferredAgent none)"
|
||||
offer="$(read_setting crashDiagnoseOffer true)"
|
||||
|
||||
if [[ -n "$agent" && "$agent" != "none" && "$offer" != "false" && "$pid" =~ ^[0-9]+$ ]]; then
|
||||
# By absolute path, not by name. The shell runs this hint, and the
|
||||
# shell is started by systemd -- whose environment does not carry
|
||||
# the repository's bin directory on PATH, so a bare name would
|
||||
# click into "command not found".
|
||||
exec_command="$(printf '%q %q %q %q %q' \
|
||||
"$PANAMA_PATH/bin/panama-agent-crash" \
|
||||
"$pid" "${comm:-$program}" "${exe:-unknown}" "${signal:-unknown}")"
|
||||
|
||||
# The hint is data, not privilege. Any process on this session bus
|
||||
# could send one, and running it grants nothing a local process
|
||||
# could not already do for itself.
|
||||
# Same urgency as the plain report, deliberately. Making the
|
||||
# clickable one critical would let a crash break through Do Not
|
||||
# Disturb, which is a louder desktop than anybody asked for in
|
||||
# exchange for an offer that keeps until it is read anyway.
|
||||
notify-send --icon=dialog-error-symbolic --app-name=Panama \
|
||||
--hint="string:panama-exec:$exec_command" \
|
||||
"$program stopped unexpectedly" \
|
||||
"Click to diagnose with $(agent_label "$agent")." \
|
||||
2>/dev/null || true
|
||||
else
|
||||
notify-send --icon=dialog-error-symbolic --app-name=Panama \
|
||||
"$program stopped unexpectedly" \
|
||||
"It crashed and was not able to recover. System Health has the details." \
|
||||
2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
Executable
+56
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Stamps a new migration, so the name is never chosen by hand.
|
||||
#
|
||||
# panama-dev-migration "remove the stale settings-ssh-keys.sh launcher command"
|
||||
#
|
||||
# The filename is the commit timestamp of HEAD, which makes glob order
|
||||
# chronological without a sequence number that two branches could pick at the
|
||||
# same time. Two migrations authored against the same commit would collide, so
|
||||
# a taken name gets the next free second rather than silently overwriting.
|
||||
#
|
||||
# Developer tool, not part of any install path. See bin/panama-migrate for what
|
||||
# runs these and what rules they have to follow.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
PANAMA_PATH="${PANAMA_PATH:-$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)}"
|
||||
MIGRATIONS_DIR="$PANAMA_PATH/migrations"
|
||||
|
||||
describe="${1:-}"
|
||||
if [[ -z "$describe" ]]; then
|
||||
echo 'usage: panama-dev-migration "what this repairs"' >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
stamp="$(git -C "$PANAMA_PATH" log -1 --format=%cd --date=unix 2>/dev/null || date +%s)"
|
||||
mkdir -p "$MIGRATIONS_DIR"
|
||||
while [[ -e "$MIGRATIONS_DIR/$stamp.sh" ]]; do
|
||||
stamp=$(( stamp + 1 ))
|
||||
done
|
||||
file="$MIGRATIONS_DIR/$stamp.sh"
|
||||
|
||||
cat >"$file" <<EOF
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# $describe
|
||||
#
|
||||
# Rules, because the runner cannot enforce them:
|
||||
#
|
||||
# * Safe to run twice. The marker records success, not intent.
|
||||
# * Tolerant of the repair already being correct -- the user may have fixed
|
||||
# it by hand, or a later ./install may have put it back.
|
||||
# * Root work goes through \`panama-sudo --reason "..."\`, never bare sudo,
|
||||
# so the password prompt names the repair.
|
||||
# * Exit non-zero to be retried at the next login. Exit zero only when the
|
||||
# machine is genuinely in the state this describes.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
PANAMA_PATH="\${PANAMA_PATH:-\$HOME/.local/share/Panama}"
|
||||
|
||||
# ... the repair goes here.
|
||||
EOF
|
||||
|
||||
chmod +x "$file"
|
||||
printf 'Created %s\n' "$file"
|
||||
Executable
+68
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# The pressure valve.
|
||||
#
|
||||
# panama-hook theme-set dark orchid
|
||||
#
|
||||
# Runs ~/.config/panama/hooks/<name> and everything executable in
|
||||
# ~/.config/panama/hooks/<name>.d/, in sorted order, with the hook's arguments.
|
||||
#
|
||||
# This exists so "can Panama also do X when the theme changes" is a five-line
|
||||
# file somebody drops in a directory rather than a fork, a feature request, or
|
||||
# a patch that has to be rebased forever. docs/UPSTREAM-INSPIRATION.md defers a
|
||||
# plugin host as premature and still should: this is the thirty-line version
|
||||
# that covers most of what people actually want from one, and it has no API to
|
||||
# keep stable beyond "we will run your script and tell you what happened".
|
||||
#
|
||||
# A failing hook is reported and stepped over. Somebody's broken script must
|
||||
# never break a theme change, an upgrade, or a login -- which is exactly what
|
||||
# would happen if this used `set -e` and the caller did too.
|
||||
#
|
||||
# Hooks run synchronously, so a slow one delays whatever called it. That is
|
||||
# deliberate: the alternative is a hook whose output arrives after the thing it
|
||||
# was reacting to has already finished, which is harder to reason about than a
|
||||
# pause.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
HOOK_DIR="${PANAMA_HOOK_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/panama/hooks}"
|
||||
|
||||
name="${1:-}"
|
||||
if [[ -z "$name" ]]; then
|
||||
echo 'usage: panama-hook <name> [args...]' >&2
|
||||
exit 2
|
||||
fi
|
||||
shift
|
||||
|
||||
# A hook name reaches the filesystem, so it cannot be allowed to leave the
|
||||
# directory. Callers are all in-repo today, which is exactly when this is
|
||||
# cheap to add and easy to forget.
|
||||
if [[ ! "$name" =~ ^[a-z][a-z0-9-]*$ ]]; then
|
||||
echo "panama-hook: refusing hook name: $name" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
run_one() {
|
||||
local script="$1"
|
||||
# Shifted off before the arguments are forwarded, or every hook receives
|
||||
# its own path as $1 and the real arguments arrive one place late.
|
||||
shift
|
||||
[[ -f "$script" && -x "$script" ]] || return 0
|
||||
if ! "$script" "$@"; then
|
||||
printf 'panama-hook: %s failed (%s); continuing\n' \
|
||||
"$(basename "$script")" "$name" >&2
|
||||
fi
|
||||
}
|
||||
|
||||
# The single file first, then the .d directory in sorted order. Both are
|
||||
# optional and having neither is the normal case.
|
||||
run_one "$HOOK_DIR/$name" "$@"
|
||||
|
||||
if [[ -d "$HOOK_DIR/$name.d" ]]; then
|
||||
while IFS= read -r script; do
|
||||
[[ -n "$script" ]] || continue
|
||||
run_one "$script" "$@"
|
||||
done < <(find "$HOOK_DIR/$name.d" -maxdepth 1 -type f | sort)
|
||||
fi
|
||||
|
||||
exit 0
|
||||
Executable
+190
@@ -0,0 +1,190 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# What this machine is, asked one yes-or-no question at a time.
|
||||
#
|
||||
# panama-hw laptop && echo "portable"
|
||||
# panama-hw clamshell && panama-lid close
|
||||
#
|
||||
# Every subcommand exits 0 for yes and 1 for no, prints nothing, and answers
|
||||
# correctly on a machine that has none of the hardware in question. That last
|
||||
# part is the whole point: a desktop must be able to ask "am I in clamshell
|
||||
# mode" and get a calm no rather than an error, because the scripts and
|
||||
# services that ask are shared between machines.
|
||||
#
|
||||
# `--json` answers everything at once, for the health page and for contracts.
|
||||
#
|
||||
# Detection reads sysfs directly rather than shelling out to lspci or upower:
|
||||
# lspci touches PCI config space and wakes a runtime-suspended GPU, which is a
|
||||
# real cost to pay for a question asked at every login.
|
||||
#
|
||||
# Paths are overridable (PANAMA_HW_SYS, PANAMA_HW_ACPI) so the contract can
|
||||
# drive fixture trees. Nothing else should set them.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
SYS="${PANAMA_HW_SYS:-/sys}"
|
||||
ACPI="${PANAMA_HW_ACPI:-/proc/acpi}"
|
||||
|
||||
# SMBIOS chassis types that mean "carried around": Portable, Laptop, Notebook,
|
||||
# Hand Held, Sub Notebook, Tablet, Convertible, Detachable. A machine that
|
||||
# reports something else, or reports nothing, is treated as stationary --
|
||||
# guessing "laptop" on an unknown chassis would put battery chrome on a desktop.
|
||||
readonly PORTABLE_CHASSIS=" 8 9 10 11 14 30 31 32 "
|
||||
|
||||
is_laptop() {
|
||||
local type_file="$SYS/class/dmi/id/chassis_type" chassis
|
||||
[[ -r "$type_file" ]] || return 1
|
||||
chassis="$(cat "$type_file" 2>/dev/null)" || return 1
|
||||
[[ "$PORTABLE_CHASSIS" == *" $chassis "* ]]
|
||||
}
|
||||
|
||||
# The first SYSTEM battery, or nothing. Named rather than assumed to be BAT0:
|
||||
# the second battery in a ThinkPad is BAT1, and a machine with only BAT1
|
||||
# exists. The scope check is what keeps a desktop a desktop: a wireless mouse
|
||||
# or a game controller publishes type=Battery with scope=Device, and counting
|
||||
# one turned a tower into a "laptop" whose battery readout was the mouse's.
|
||||
battery_path() {
|
||||
local supply type scope
|
||||
for supply in "$SYS"/class/power_supply/*; do
|
||||
[[ -r "$supply/type" ]] || continue
|
||||
type="$(cat "$supply/type" 2>/dev/null)"
|
||||
[[ "$type" == "Battery" ]] || continue
|
||||
scope="$(cat "$supply/scope" 2>/dev/null || echo System)"
|
||||
[[ "$scope" == "Device" ]] && continue
|
||||
printf '%s\n' "$supply"
|
||||
return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
has_battery() { battery_path >/dev/null; }
|
||||
|
||||
# On wall power. A machine with no mains supply at all and no system battery
|
||||
# is a desktop, and a desktop is always on wall power -- answering "no" there
|
||||
# would make every battery-aware timing apply to a machine that cannot run
|
||||
# out of power. But "no Mains" alone is not "desktop": hardware charged only
|
||||
# over USB-PD exposes type=USB supplies and no Mains at all, and reading that
|
||||
# as permanently-on-AC meant its battery timings never engaged while it ran
|
||||
# down. When no Mains exists but a system battery does, the battery's own
|
||||
# status is the answer: Discharging means battery, everything else means fed.
|
||||
on_ac() {
|
||||
local supply type online found=1 battery status
|
||||
for supply in "$SYS"/class/power_supply/*; do
|
||||
[[ -r "$supply/type" ]] || continue
|
||||
type="$(cat "$supply/type" 2>/dev/null)"
|
||||
[[ "$type" == "Mains" ]] || continue
|
||||
found=0
|
||||
online="$(cat "$supply/online" 2>/dev/null || echo 0)"
|
||||
[[ "$online" == "1" ]] && return 0
|
||||
done
|
||||
# Mains exists and none of it is online: genuinely on battery.
|
||||
(( found == 0 )) && return 1
|
||||
if battery="$(battery_path)"; then
|
||||
status="$(cat "$battery/status" 2>/dev/null || echo Unknown)"
|
||||
[[ "$status" == "Discharging" ]] && return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# ACPI first, logind second. Some platforms expose the lid only as an evdev
|
||||
# switch with no /proc/acpi/button entry; logind watches the switch either
|
||||
# way, so its LidClosed property is the fallback that keeps clamshell
|
||||
# detection honest there. No logind (a container, a test tree) means the
|
||||
# fallback quietly answers open, which is the safe direction.
|
||||
lid_closed() {
|
||||
local state
|
||||
for state in "$ACPI"/button/lid/*/state; do
|
||||
[[ -r "$state" ]] || continue
|
||||
grep -qi closed "$state" && return 0
|
||||
return 1
|
||||
done
|
||||
[[ -d "$ACPI/button/lid" ]] && return 1
|
||||
busctl get-property org.freedesktop.login1 /org/freedesktop/login1 \
|
||||
org.freedesktop.login1.Manager LidClosed 2>/dev/null | grep -q 'b true'
|
||||
}
|
||||
|
||||
# A connected output that is not the built-in panel. eDP, LVDS and DSI are the
|
||||
# internal ones; everything else arrived through a cable.
|
||||
has_external_monitor() {
|
||||
local status connector
|
||||
for status in "$SYS"/class/drm/card*-*/status; do
|
||||
[[ -r "$status" ]] || continue
|
||||
[[ "$(cat "$status" 2>/dev/null)" == "connected" ]] || continue
|
||||
connector="$(basename "$(dirname "$status")")"
|
||||
case "$connector" in
|
||||
*eDP*|*LVDS*|*DSI*) continue ;;
|
||||
*) return 0 ;;
|
||||
esac
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# The one definition the rest of the laptop work hangs on: the lid is shut and
|
||||
# there is still a screen to use. Closing the lid on a dock must not suspend;
|
||||
# closing it on a train must.
|
||||
is_clamshell() { lid_closed && has_external_monitor; }
|
||||
|
||||
has_touchpad() {
|
||||
local name
|
||||
for name in "$SYS"/class/input/*/name; do
|
||||
[[ -r "$name" ]] || continue
|
||||
grep -qi touchpad "$name" && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# Vendor 0x10de on a display-class device. Read from sysfs rather than lspci
|
||||
# so an idle discrete GPU is not woken to answer.
|
||||
has_nvidia() {
|
||||
local device vendor class
|
||||
for device in "$SYS"/bus/pci/devices/*; do
|
||||
[[ -r "$device/vendor" && -r "$device/class" ]] || continue
|
||||
vendor="$(cat "$device/vendor" 2>/dev/null)"
|
||||
[[ "$vendor" == "0x10de" ]] || continue
|
||||
class="$(cat "$device/class" 2>/dev/null)"
|
||||
[[ "$class" == 0x03* ]] && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
answer() { "$1" && printf 'true' || printf 'false'; }
|
||||
|
||||
cmd_json() {
|
||||
printf '{"laptop":%s,"battery":%s,"ac":%s,"lidClosed":%s,"externalMonitor":%s,"clamshell":%s,"touchpad":%s,"nvidia":%s}\n' \
|
||||
"$(answer is_laptop)" "$(answer has_battery)" "$(answer on_ac)" \
|
||||
"$(answer lid_closed)" "$(answer has_external_monitor)" \
|
||||
"$(answer is_clamshell)" "$(answer has_touchpad)" "$(answer has_nvidia)"
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
laptop) is_laptop ;;
|
||||
battery) has_battery ;;
|
||||
battery-path) battery_path ;;
|
||||
ac) on_ac ;;
|
||||
lid-closed) lid_closed ;;
|
||||
external-monitor) has_external_monitor ;;
|
||||
clamshell) is_clamshell ;;
|
||||
touchpad) has_touchpad ;;
|
||||
nvidia) has_nvidia ;;
|
||||
--json) cmd_json ;;
|
||||
-h|--help|"")
|
||||
cat <<'USAGE'
|
||||
usage: panama-hw <predicate>
|
||||
|
||||
Exits 0 for yes, 1 for no, and prints nothing.
|
||||
|
||||
laptop a portable chassis
|
||||
battery a battery is present
|
||||
battery-path print the first battery's sysfs path (0 if found)
|
||||
ac on wall power (a machine with no mains is always yes)
|
||||
lid-closed the lid is shut
|
||||
external-monitor a connected output that is not the built-in panel
|
||||
clamshell lid shut AND an external monitor: docked, keep working
|
||||
touchpad a touchpad is present
|
||||
nvidia an NVIDIA display device is present
|
||||
|
||||
--json every answer at once
|
||||
USAGE
|
||||
;;
|
||||
*) printf 'panama-hw: unknown predicate: %s\n' "$1" >&2; exit 2 ;;
|
||||
esac
|
||||
Executable
+69
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Focus the window if it is already open; start it if it is not.
|
||||
#
|
||||
# panama-launch --class '^helium$' -- helium-browser-bin
|
||||
# panama-launch --class '^kitty$' --title 'nvim' -- kitty nvim .
|
||||
#
|
||||
# This is what the application keys do on every other desktop. Pressing the
|
||||
# browser key twice on macOS or Windows raises the browser; here it used to
|
||||
# open a second one, which is the single most common "Linux feels wrong"
|
||||
# moment and a twenty-line fix.
|
||||
#
|
||||
# Matching is a regular expression against the window class, optionally
|
||||
# narrowed by title. Both halves matter: the terminal and the editor are both
|
||||
# kitty on this desktop, and only the title tells them apart -- so a class-only
|
||||
# match would make the editor key raise whatever terminal happened to be open.
|
||||
#
|
||||
# Anchor your patterns. `--class mail` would match `gmail-notifier`, and the
|
||||
# key that should open Thunderbird would raise somebody's notifier instead.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
class_pattern=""
|
||||
title_pattern=""
|
||||
|
||||
while (( $# > 0 )); do
|
||||
case "$1" in
|
||||
--class) class_pattern="${2:-}"; shift 2 ;;
|
||||
--title) title_pattern="${2:-}"; shift 2 ;;
|
||||
--) shift; break ;;
|
||||
*) break ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$class_pattern" || $# -eq 0 ]]; then
|
||||
echo 'usage: panama-launch --class <regex> [--title <regex>] -- command [args...]' >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
launch() {
|
||||
# setsid so the application outlives this script and is not a child of the
|
||||
# compositor's exec, which would tie its lifetime to a shell that exits.
|
||||
setsid "$@" >/dev/null 2>&1 &
|
||||
exit 0
|
||||
}
|
||||
|
||||
# No compositor, no window list: just start the thing.
|
||||
command -v hyprctl >/dev/null 2>&1 || launch "$@"
|
||||
|
||||
address="$(hyprctl clients -j 2>/dev/null | jq -r --arg class "$class_pattern" --arg title "$title_pattern" '
|
||||
[ .[]
|
||||
| select(.mapped)
|
||||
| select(.class | test($class))
|
||||
| select($title == "" or (.title | test($title)))
|
||||
]
|
||||
# Most recently focused first: with several matches, raise the one the
|
||||
# user was last in rather than whichever the compositor lists first.
|
||||
| sort_by(-.focusHistoryID)
|
||||
| .[0].address // empty
|
||||
' 2>/dev/null)"
|
||||
|
||||
if [[ -n "$address" ]]; then
|
||||
# Hyprland 0.56 dispatches through Lua: `hyprctl dispatch focuswindow
|
||||
# address:0x...` is parsed as Lua source and fails. The selector string is
|
||||
# what hl.focus accepts; a table of the same fields is refused.
|
||||
exec hyprctl dispatch "hl.dsp.focus({ window = \"address:$address\" })"
|
||||
fi
|
||||
|
||||
launch "$@"
|
||||
Executable
+189
@@ -0,0 +1,189 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Bringing an already-installed machine up to what this checkout expects.
|
||||
#
|
||||
# `./install` is additive: it copies files over `/`, links dotfiles, and
|
||||
# installs packages. It has no way to say "remove that file", "disable that
|
||||
# unit", "that symlink now points at the wrong place". So a machine installed
|
||||
# in March keeps March's mistakes forever, and the only thing that ever fixes
|
||||
# them is the person who happens to read a commit message.
|
||||
#
|
||||
# A migration is one shell script that performs one such repair, exactly once,
|
||||
# on machines that need it.
|
||||
#
|
||||
# migrations/<unix-timestamp>.sh
|
||||
#
|
||||
# The name is the commit timestamp of HEAD when it was authored, so glob order
|
||||
# over fixed-width epoch seconds IS chronological order -- no sequence numbers
|
||||
# to collide on across branches. `panama-dev-migration` stamps them.
|
||||
#
|
||||
# State is one empty marker file per migration under
|
||||
# $XDG_STATE_HOME/panama/migrations. Present means applied. There is no
|
||||
# database and no version integer, because the failure mode of a version
|
||||
# integer is that one bad migration strands every later one behind it.
|
||||
#
|
||||
# The rules a migration must follow are in the template that
|
||||
# `panama-dev-migration` writes, and they are worth repeating here because
|
||||
# this runner cannot enforce them:
|
||||
#
|
||||
# * Safe to run twice. The marker only records that it succeeded once.
|
||||
# * Tolerant of the repair already being correct -- a user may have fixed it
|
||||
# by hand, or a later `./install` may have overwritten it back.
|
||||
# * Root work goes through `panama-sudo --reason "..."`, never bare sudo,
|
||||
# so the prompt names the repair. See bin/panama-sudo.
|
||||
#
|
||||
# The marker is written ONLY on success, so a migration that fails stays
|
||||
# pending and is retried at the next login. That is deliberate: a repair that
|
||||
# could not complete has not happened, and recording it as done would hide it
|
||||
# forever.
|
||||
#
|
||||
# This mirrors config/dot/quickshell/config/Migrations.qml, which does the same
|
||||
# job for the settings JSON and documents the same reasoning. That one handles
|
||||
# renamed preference keys; this one handles everything else.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
PANAMA_PATH="${PANAMA_PATH:-$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)}"
|
||||
MIGRATIONS_DIR="$PANAMA_PATH/migrations"
|
||||
STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/panama/migrations"
|
||||
|
||||
export PANAMA_PATH
|
||||
|
||||
info() { printf '\033[1;34m==>\033[0m %s\n' "$*"; }
|
||||
ok() { printf '\033[1;32m✓\033[0m %s\n' "$*"; }
|
||||
warn() { printf '\033[1;33m!\033[0m %s\n' "$*" >&2; }
|
||||
err() { printf '\033[1;31m✗\033[0m %s\n' "$*" >&2; }
|
||||
|
||||
# Every migration this checkout ships, oldest first. Empty is a valid state.
|
||||
all_migrations() {
|
||||
[[ -d "$MIGRATIONS_DIR" ]] || return 0
|
||||
local file
|
||||
for file in "$MIGRATIONS_DIR"/*.sh; do
|
||||
[[ -e "$file" ]] || continue
|
||||
basename "$file"
|
||||
done | sort
|
||||
}
|
||||
|
||||
pending_migrations() {
|
||||
local name
|
||||
while read -r name; do
|
||||
[[ -n "$name" ]] || continue
|
||||
[[ -e "$STATE_DIR/$name" ]] || printf '%s\n' "$name"
|
||||
done < <(all_migrations)
|
||||
}
|
||||
|
||||
run_one() {
|
||||
local name="$1" file="$MIGRATIONS_DIR/$1"
|
||||
info "$name"
|
||||
# A subshell with its own strictness: a migration that forgets `set -e` is
|
||||
# still stopped by its first failing command, and one that sets shell
|
||||
# options cannot leak them into the next migration.
|
||||
if bash -euo pipefail "$file"; then
|
||||
mkdir -p "$STATE_DIR"
|
||||
: >"$STATE_DIR/$name"
|
||||
ok "$name applied"
|
||||
return 0
|
||||
fi
|
||||
err "$name failed and will be retried at the next login"
|
||||
return 1
|
||||
}
|
||||
|
||||
cmd_run() {
|
||||
local pending
|
||||
pending="$(pending_migrations)"
|
||||
if [[ -z "$pending" ]]; then
|
||||
ok "Nothing to migrate; this machine matches the checkout."
|
||||
return 0
|
||||
fi
|
||||
|
||||
local count failed=0 name
|
||||
count="$(grep -c . <<<"$pending")"
|
||||
info "$count migration(s) to apply"
|
||||
while read -r name; do
|
||||
[[ -n "$name" ]] || continue
|
||||
# Stop at the first failure rather than continuing. Migrations are
|
||||
# ordered, and a later one may assume an earlier one landed; running
|
||||
# it anyway turns one stuck repair into an unpredictable machine.
|
||||
if ! run_one "$name"; then
|
||||
failed=1
|
||||
break
|
||||
fi
|
||||
done <<<"$pending"
|
||||
|
||||
if (( failed )); then
|
||||
warn "Re-running 'panama migrate' is safe and will retry from the failure."
|
||||
warn "If it keeps failing, hand it to an agent: panama diagnose"
|
||||
return 1
|
||||
fi
|
||||
ok "This machine now matches the checkout."
|
||||
# Only after repairs actually ran: a hook that fires on every login when
|
||||
# there was nothing to do is a hook people disable.
|
||||
"$PANAMA_PATH/bin/panama-hook" post-migrate || true
|
||||
}
|
||||
|
||||
# The check the login notifier runs. Exit 0 means work is waiting, so it reads
|
||||
# as `if panama-migrate --pending; then notify; fi`.
|
||||
cmd_pending() {
|
||||
local pending
|
||||
pending="$(pending_migrations)"
|
||||
[[ -n "$pending" ]] || return 1
|
||||
grep -c . <<<"$pending"
|
||||
}
|
||||
|
||||
cmd_list() {
|
||||
local name
|
||||
while read -r name; do
|
||||
[[ -n "$name" ]] || continue
|
||||
if [[ -e "$STATE_DIR/$name" ]]; then
|
||||
printf 'applied %s\n' "$name"
|
||||
else
|
||||
printf 'pending %s\n' "$name"
|
||||
fi
|
||||
done < <(all_migrations)
|
||||
}
|
||||
|
||||
# Re-run one that already succeeded. For developing a migration, and for the
|
||||
# rare case where a repair was undone by something else.
|
||||
cmd_force() {
|
||||
local name="${1:-}"
|
||||
[[ -n "$name" ]] || { err "force needs a migration name"; return 2; }
|
||||
[[ -e "$MIGRATIONS_DIR/$name" ]] || { err "no such migration: $name"; return 2; }
|
||||
rm -f "$STATE_DIR/$name"
|
||||
run_one "$name"
|
||||
}
|
||||
|
||||
# Mark everything applied without running it. This is what a fresh install
|
||||
# does: the machine was just built from this checkout, so every repair those
|
||||
# migrations describe is already true of it, and running them would apply
|
||||
# fixes for versions it never had.
|
||||
cmd_baseline() {
|
||||
mkdir -p "$STATE_DIR"
|
||||
local name count=0
|
||||
while read -r name; do
|
||||
[[ -n "$name" ]] || continue
|
||||
[[ -e "$STATE_DIR/$name" ]] && continue
|
||||
: >"$STATE_DIR/$name"
|
||||
count=$(( count + 1 ))
|
||||
done < <(all_migrations)
|
||||
ok "Marked $count migration(s) as already applied."
|
||||
}
|
||||
|
||||
case "${1:-run}" in
|
||||
run) shift || true; cmd_run "$@" ;;
|
||||
--pending) shift || true; cmd_pending "$@" ;;
|
||||
--list|list) shift || true; cmd_list "$@" ;;
|
||||
--force) shift || true; cmd_force "$@" ;;
|
||||
--baseline) shift || true; cmd_baseline "$@" ;;
|
||||
-h|--help)
|
||||
cat <<'USAGE'
|
||||
usage: panama-migrate [run|--pending|--list|--force <name>|--baseline]
|
||||
|
||||
run Apply every pending migration, oldest first (default)
|
||||
--pending Exit 0 and print the count when work is waiting, else exit 1
|
||||
--list Show every migration and whether it has been applied
|
||||
--force Re-run one migration that already succeeded
|
||||
--baseline Mark everything applied without running it (fresh installs)
|
||||
USAGE
|
||||
;;
|
||||
*) err "unknown argument: $1"; exit 2 ;;
|
||||
esac
|
||||
Executable
+50
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# "Panama has repairs waiting for this machine."
|
||||
#
|
||||
# A migration that nobody knows about is a migration that never runs. This
|
||||
# checks once per session and, when work is pending, sends one notification
|
||||
# whose action opens a terminal running `panama migrate` -- so the repair is
|
||||
# always something the user chose, never something that happened to them.
|
||||
#
|
||||
# Waiting for the notification server first is not politeness. Quickshell owns
|
||||
# org.freedesktop.Notifications, and a shell that has not started yet would
|
||||
# swallow this silently -- which is precisely the session where a pending
|
||||
# migration is most likely to matter.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
PANAMA_PATH="${PANAMA_PATH:-$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)}"
|
||||
TERMINAL="${PANAMA_TERMINAL:-kitty}"
|
||||
|
||||
command -v notify-send >/dev/null 2>&1 || exit 1
|
||||
|
||||
count="$("$PANAMA_PATH/bin/panama-migrate" --pending)" || exit 1
|
||||
[[ -n "$count" ]] || exit 1
|
||||
|
||||
# Up to ~15s for the shell to claim the bus name. Longer than a healthy start
|
||||
# needs, short enough that a session without a shell gives up rather than
|
||||
# lingering.
|
||||
for _ in $(seq 1 30); do
|
||||
if busctl --user status org.freedesktop.Notifications >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
sleep 0.5
|
||||
done
|
||||
|
||||
if (( count == 1 )); then
|
||||
body="One repair is waiting for this machine."
|
||||
else
|
||||
body="$count repairs are waiting for this machine."
|
||||
fi
|
||||
|
||||
# Critical so it waits to be read: a notification that expires while the user
|
||||
# is elsewhere has told nobody anything. The action is the whole point -- there
|
||||
# is no instruction to remember and nothing to type.
|
||||
action="$(notify-send --urgency=critical --icon=system-software-update \
|
||||
--app-name=Panama \
|
||||
--action=migrate="Apply now" --action=later="Later" \
|
||||
"Panama updates" "$body" 2>/dev/null)" || exit 0
|
||||
|
||||
[[ "$action" == "migrate" ]] || exit 0
|
||||
exec "$TERMINAL" --hold "$PANAMA_PATH/bin/panama-migrate" run
|
||||
Executable
+323
@@ -0,0 +1,323 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# panama-server – the compose services a server machine runs.
|
||||
#
|
||||
# The repository carries the catalog: server/containers/<Name>/ holds a
|
||||
# compose.yml, a podman-<name>.service user unit, and an .env.example naming
|
||||
# what the service needs told. The machine carries the rest: ~/Server/<Name>/
|
||||
# is a real directory owning the .env (secrets never sit inside the checkout,
|
||||
# gitignored or not) and the bind-mounted data. Only the tracked files are
|
||||
# symlinked, so a `git clean` in the repo can never reach a database.
|
||||
#
|
||||
# list every service in the catalog, with its state here
|
||||
# enable <Name> link it into ~/Server, seed .env, enable the unit
|
||||
# disable <Name> stop it and remove the unit; data and .env stay put
|
||||
# status [<Name>] what is actually running
|
||||
# relink refresh the symlinks for everything enabled here
|
||||
#
|
||||
# Verbs act on the unit and the links, never on data: there is deliberately no
|
||||
# verb here that deletes ~/Server/<Name> or anything in it.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
PROGRAM="panama server"
|
||||
|
||||
SCRIPT_PATH=$(readlink -f "${BASH_SOURCE[0]}")
|
||||
PANAMA_DIR=$(cd "$(dirname "$SCRIPT_PATH")/.." && pwd)
|
||||
|
||||
# Overridable so the contract can point this at a fixture catalog and a
|
||||
# throwaway HOME rather than the machine's real services.
|
||||
SERVER_DIR="${PANAMA_SERVER_DIR:-$PANAMA_DIR/server/containers}"
|
||||
TARGET_DIR="${PANAMA_SERVER_TARGET:-$HOME/Server}"
|
||||
UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
|
||||
STATE_FILE="${XDG_STATE_HOME:-$HOME/.local/state}/panama/server-definitions"
|
||||
|
||||
if [[ -t 1 ]] && command -v tput >/dev/null 2>&1 && [[ $(tput colors 2>/dev/null || echo 0) -ge 8 ]]; then
|
||||
BOLD=$(tput bold); RESET=$(tput sgr0)
|
||||
RED=$(tput setaf 1); GREEN=$(tput setaf 2); YELLOW=$(tput setaf 3); BLUE=$(tput setaf 4)
|
||||
else
|
||||
BOLD=""; RESET=""; RED=""; GREEN=""; YELLOW=""; BLUE=""
|
||||
fi
|
||||
|
||||
info() { printf '%s==>%s %s\n' "${BLUE}${BOLD}" "$RESET" "$*"; }
|
||||
ok() { printf '%s✓%s %s\n' "${GREEN}${BOLD}" "$RESET" "$*"; }
|
||||
warn() { printf '%s!%s %s\n' "${YELLOW}${BOLD}" "$RESET" "$*"; }
|
||||
err() { printf '%s✗%s %s\n' "${RED}${BOLD}" "$RESET" "$*" >&2; }
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
${BOLD}$PROGRAM${RESET} – manage the compose services in server/containers/
|
||||
|
||||
${BOLD}Usage:${RESET}
|
||||
$PROGRAM list Every service in the catalog, and its state here
|
||||
$PROGRAM enable <Name> Link a service into ~/Server, seed its .env from
|
||||
.env.example, and enable its unit. Stops short of
|
||||
starting when the .env still holds CHANGE_ME
|
||||
placeholders, and says which.
|
||||
$PROGRAM disable <Name> Stop the unit and unlink it. ~/Server/<Name>,
|
||||
its .env and its data are never touched.
|
||||
$PROGRAM status [<Name>] What is actually running, per service
|
||||
$PROGRAM relink Refresh every enabled service's symlinks after a
|
||||
pull, and name the ones whose definitions changed
|
||||
(nothing is restarted for you)
|
||||
EOF
|
||||
}
|
||||
|
||||
# ── The catalog ──────────────────────────────────────────────────────────────
|
||||
|
||||
services() {
|
||||
local dir
|
||||
for dir in "$SERVER_DIR"/*/; do
|
||||
[[ -d "$dir" ]] || continue
|
||||
basename "$dir"
|
||||
done
|
||||
}
|
||||
|
||||
require_service() {
|
||||
local name="$1"
|
||||
if [[ ! -d "$SERVER_DIR/$name" ]]; then
|
||||
err "No such service: '$name'"
|
||||
printf 'The catalog has:\n' >&2
|
||||
services | sed 's/^/ /' >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# The one unit file a service directory carries. Its filename is its identity
|
||||
# -- podman-<name>.service, exactly what lands in the systemd user directory --
|
||||
# so nothing here invents a name that could drift from the file's.
|
||||
unit_path_for() {
|
||||
local name="$1" unit
|
||||
for unit in "$SERVER_DIR/$name"/*.service; do
|
||||
[[ -e "$unit" ]] || continue
|
||||
printf '%s' "$unit"
|
||||
return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
unit_name_for() {
|
||||
local unit
|
||||
unit="$(unit_path_for "$1")" || return 1
|
||||
basename "$unit"
|
||||
}
|
||||
|
||||
# Installed means our symlink is in the systemd user directory. A regular file
|
||||
# of the same name is a pre-Panama install done by hand -- reported, never
|
||||
# silently replaced.
|
||||
unit_installed() {
|
||||
local unit_name
|
||||
unit_name="$(unit_name_for "$1")" || return 1
|
||||
[[ -L "$UNIT_DIR/$unit_name" ]]
|
||||
}
|
||||
|
||||
definition_hash() {
|
||||
local name="$1" unit
|
||||
unit="$(unit_path_for "$name")" || unit=/dev/null
|
||||
cat "$SERVER_DIR/$name/compose.yml" "$unit" 2>/dev/null | sha256sum | cut -d' ' -f1
|
||||
}
|
||||
|
||||
recorded_hash() {
|
||||
[[ -r "$STATE_FILE" ]] || return 0
|
||||
awk -v name="$1" '$1 == name {print $2}' "$STATE_FILE"
|
||||
}
|
||||
|
||||
record_hash() {
|
||||
local name="$1" hash="$2"
|
||||
mkdir -p "$(dirname "$STATE_FILE")"
|
||||
{ [[ -r "$STATE_FILE" ]] && awk -v name="$name" '$1 != name' "$STATE_FILE"; \
|
||||
printf '%s %s\n' "$name" "$hash"; } >"$STATE_FILE.tmp"
|
||||
mv "$STATE_FILE.tmp" "$STATE_FILE"
|
||||
}
|
||||
|
||||
# Replace path with a symlink to src. An existing regular file is moved aside
|
||||
# with its name intact plus a suffix, because the only regular file that can be
|
||||
# here is a pre-Panama one somebody wrote by hand -- worth keeping to diff.
|
||||
link_file() {
|
||||
local src="$1" path="$2"
|
||||
if [[ -L "$path" ]]; then
|
||||
[[ "$(readlink -f "$path")" == "$(readlink -f "$src")" ]] && return 0
|
||||
rm "$path"
|
||||
elif [[ -e "$path" ]]; then
|
||||
mv "$path" "$path.pre-panama"
|
||||
warn "Kept the existing $(basename "$path") as $(basename "$path").pre-panama"
|
||||
fi
|
||||
ln -s "$src" "$path"
|
||||
}
|
||||
|
||||
# ── Verbs ────────────────────────────────────────────────────────────────────
|
||||
|
||||
cmd_list() {
|
||||
local name unit_name state enabled
|
||||
for name in $(services); do
|
||||
unit_name="$(unit_name_for "$name")" || { printf '%-24s %s\n' "$name" "no unit file"; continue; }
|
||||
if unit_installed "$name"; then
|
||||
state="$(systemctl --user is-active "$unit_name" 2>/dev/null || true)"
|
||||
enabled="enabled"
|
||||
[[ "$state" == active ]] && state="${GREEN}active${RESET}" || state="${YELLOW}${state:-unknown}${RESET}"
|
||||
printf '%-24s %s, %b\n' "$name" "$enabled" "$state"
|
||||
elif [[ -e "$UNIT_DIR/$unit_name" ]]; then
|
||||
printf '%-24s %s\n' "$name" "installed by hand (not Panama's symlink)"
|
||||
else
|
||||
printf '%-24s %s\n' "$name" "-"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
cmd_enable() {
|
||||
local name="${1:-}"
|
||||
[[ -n "$name" ]] || { err "Which service? Usage: $PROGRAM enable <Name>"; exit 1; }
|
||||
require_service "$name"
|
||||
|
||||
local repo_dir="$SERVER_DIR/$name" live_dir="$TARGET_DIR/$name"
|
||||
[[ -f "$repo_dir/compose.yml" ]] || { err "$name has no compose.yml in the catalog"; exit 1; }
|
||||
local unit_path unit_name
|
||||
unit_path="$(unit_path_for "$name")" || { err "$name has no unit file in the catalog"; exit 1; }
|
||||
unit_name="$(basename "$unit_path")"
|
||||
|
||||
mkdir -p "$live_dir"
|
||||
link_file "$repo_dir/compose.yml" "$live_dir/compose.yml"
|
||||
ok "Linked compose.yml → $live_dir/compose.yml"
|
||||
|
||||
# The .env lives with the machine, not the checkout. Seeded once from the
|
||||
# example and never overwritten -- it is where the person's secrets go.
|
||||
if [[ -f "$repo_dir/.env.example" && ! -e "$live_dir/.env" ]]; then
|
||||
cp "$repo_dir/.env.example" "$live_dir/.env"
|
||||
chmod 600 "$live_dir/.env"
|
||||
ok "Seeded $live_dir/.env from .env.example"
|
||||
fi
|
||||
|
||||
# Refusing to start on placeholders, and saying which: a service brought up
|
||||
# with CHANGE_ME as its database password does not fail loudly, it runs --
|
||||
# and what it runs is a service whose password is CHANGE_ME.
|
||||
if [[ -e "$live_dir/.env" ]] && grep -q 'CHANGE_ME' "$live_dir/.env"; then
|
||||
warn "$live_dir/.env still has placeholder values:"
|
||||
grep -n 'CHANGE_ME' "$live_dir/.env" | sed 's/^/ /'
|
||||
printf 'Fill them in, then run: %s enable %s\n' "$PROGRAM" "$name"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$UNIT_DIR"
|
||||
link_file "$unit_path" "$UNIT_DIR/$unit_name"
|
||||
systemctl --user daemon-reload
|
||||
if systemctl --user enable --now "$unit_name"; then
|
||||
record_hash "$name" "$(definition_hash "$name")"
|
||||
ok "$name enabled and started ($unit_name)"
|
||||
else
|
||||
err "$unit_name did not start; see: systemctl --user status $unit_name"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_disable() {
|
||||
local name="${1:-}"
|
||||
[[ -n "$name" ]] || { err "Which service? Usage: $PROGRAM disable <Name>"; exit 1; }
|
||||
require_service "$name"
|
||||
local unit_name
|
||||
unit_name="$(unit_name_for "$name")" || { err "$name has no unit file in the catalog"; exit 1; }
|
||||
|
||||
systemctl --user disable --now "$unit_name" 2>/dev/null || true
|
||||
if [[ -L "$UNIT_DIR/$unit_name" ]]; then
|
||||
rm "$UNIT_DIR/$unit_name"
|
||||
systemctl --user daemon-reload
|
||||
fi
|
||||
ok "$name disabled. ~/Server/$name, its .env and its data were not touched."
|
||||
}
|
||||
|
||||
cmd_status() {
|
||||
local name="${1:-}"
|
||||
if [[ -n "$name" ]]; then
|
||||
require_service "$name"
|
||||
local unit_name
|
||||
unit_name="$(unit_name_for "$name")" || { err "$name has no unit file"; exit 1; }
|
||||
systemctl --user status --no-pager "$unit_name" || true
|
||||
if [[ -d "$TARGET_DIR/$name" ]]; then
|
||||
( cd "$TARGET_DIR/$name" && podman compose ps 2>/dev/null ) || true
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
|
||||
# The role's health summary: what is enabled here, and is it actually up.
|
||||
local any=0 unit_name state
|
||||
for name in $(services); do
|
||||
unit_installed "$name" || continue
|
||||
any=1
|
||||
unit_name="$(unit_name_for "$name")"
|
||||
state="$(systemctl --user is-active "$unit_name" 2>/dev/null || true)"
|
||||
if [[ "$state" == active ]]; then
|
||||
ok "$name"
|
||||
else
|
||||
err "$name is ${state:-unknown}"
|
||||
fi
|
||||
done
|
||||
(( any )) || info "No services enabled on this machine yet. See: $PROGRAM list"
|
||||
return 0
|
||||
}
|
||||
|
||||
cmd_relink() {
|
||||
local name unit_path unit_name changed=() unmanaged=() refreshed=0
|
||||
for name in $(services); do
|
||||
unit_path="$(unit_path_for "$name")" || continue
|
||||
unit_name="$(basename "$unit_path")"
|
||||
|
||||
if [[ -e "$UNIT_DIR/$unit_name" && ! -L "$UNIT_DIR/$unit_name" ]]; then
|
||||
unmanaged+=("$name")
|
||||
continue
|
||||
fi
|
||||
unit_installed "$name" || continue
|
||||
|
||||
link_file "$unit_path" "$UNIT_DIR/$unit_name"
|
||||
mkdir -p "$TARGET_DIR/$name"
|
||||
link_file "$SERVER_DIR/$name/compose.yml" "$TARGET_DIR/$name/compose.yml"
|
||||
refreshed=$((refreshed + 1))
|
||||
|
||||
# Changed since the last time this machine looked, which is what makes a
|
||||
# pull actionable: the restart is deliberately yours to run, so the least
|
||||
# this can do is say which services are running a definition that is no
|
||||
# longer what the repository says.
|
||||
local now
|
||||
now="$(definition_hash "$name")"
|
||||
if [[ "$(recorded_hash "$name")" != "$now" ]]; then
|
||||
changed+=("$name")
|
||||
record_hash "$name" "$now"
|
||||
fi
|
||||
done
|
||||
|
||||
systemctl --user daemon-reload 2>/dev/null || true
|
||||
info "Relinked $refreshed enabled service(s)"
|
||||
if (( ${#unmanaged[@]} > 0 )); then
|
||||
warn "Installed by hand, left alone: ${unmanaged[*]}"
|
||||
fi
|
||||
if (( ${#changed[@]} > 0 )); then
|
||||
warn "Definitions changed; restart each when ready:"
|
||||
for name in "${changed[@]}"; do
|
||||
printf ' %s: systemctl --user restart %s\n' "$name" "$(unit_name_for "$name")"
|
||||
done
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Dispatcher ───────────────────────────────────────────────────────────────
|
||||
|
||||
main() {
|
||||
if [[ ! -d "$SERVER_DIR" ]]; then
|
||||
err "No service catalog at $SERVER_DIR"
|
||||
exit 1
|
||||
fi
|
||||
local cmd="${1:-}"
|
||||
case "$cmd" in
|
||||
list) shift; cmd_list "$@" ;;
|
||||
enable) shift; cmd_enable "$@" ;;
|
||||
disable) shift; cmd_disable "$@" ;;
|
||||
status) shift; cmd_status "$@" ;;
|
||||
relink) shift; cmd_relink "$@" ;;
|
||||
help|-h|--help|"") usage ;;
|
||||
*)
|
||||
err "Unknown command: '$cmd'"
|
||||
echo
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Executable
+34
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# pkexec, with a stated reason on the password prompt.
|
||||
#
|
||||
# panama-sudo --reason "Installing gamemode hooks" -- dnf install gamemode
|
||||
#
|
||||
# The reason travels to the shell over Quickshell IPC before pkexec runs, and
|
||||
# Panama's prompt shows it clearly labeled beside polkitd's own action message
|
||||
# -- beside, never instead of: anything can claim any reason, so the real
|
||||
# action text stays the trust anchor. Meant for agents and scripts, so the
|
||||
# person at the keyboard learns WHY before typing their password.
|
||||
#
|
||||
# Degrades to plain pkexec: no --reason, no running shell, or no qs on PATH
|
||||
# all behave identically to calling pkexec yourself.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
reason=""
|
||||
if [[ "${1:-}" == "--reason" ]]; then
|
||||
reason="${2:?panama-sudo: --reason needs a value}"
|
||||
shift 2
|
||||
fi
|
||||
[[ "${1:-}" == "--" ]] && shift
|
||||
|
||||
if (( $# == 0 )); then
|
||||
echo 'usage: panama-sudo [--reason "why"] -- command [args...]' >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [[ -n "$reason" ]] && command -v qs >/dev/null 2>&1; then
|
||||
qs ipc call polkit reason "$reason" >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
exec pkexec "$@"
|
||||
Executable
+118
@@ -0,0 +1,118 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Make a video smaller, or turn a picture into another format.
|
||||
#
|
||||
# panama-transcode video <file> 1080p|720p|480p
|
||||
# panama-transcode image <file> jpg|png|webp
|
||||
#
|
||||
# The two things people actually right-click a media file to do. macOS has
|
||||
# Quick Actions for both; Linux file managers have neither, and the usual
|
||||
# answer is a web uploader or a forgotten ffmpeg incantation.
|
||||
#
|
||||
# Two rules, both about not losing work:
|
||||
#
|
||||
# * The input is never written to. Output goes beside it with a suffix.
|
||||
# * An existing output is never overwritten. The suffix gains a number
|
||||
# rather than replacing something somebody made earlier.
|
||||
#
|
||||
# ffmpeg does the work and is already a declared dependency; this only decides
|
||||
# the arguments, which is the part worth writing down once.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
err() { printf 'panama-transcode: %s\n' "$*" >&2; }
|
||||
|
||||
command -v ffmpeg >/dev/null 2>&1 || { err 'ffmpeg is not installed'; exit 1; }
|
||||
|
||||
# A path that does not exist yet, beside the input.
|
||||
free_path() {
|
||||
local dir="$1" stem="$2" suffix="$3" ext="$4"
|
||||
local candidate="$dir/$stem-$suffix.$ext"
|
||||
local counter=2
|
||||
while [[ -e "$candidate" ]]; do
|
||||
candidate="$dir/$stem-$suffix-$counter.$ext"
|
||||
counter=$(( counter + 1 ))
|
||||
done
|
||||
printf '%s' "$candidate"
|
||||
}
|
||||
|
||||
notify() {
|
||||
command -v notify-send >/dev/null 2>&1 || return 0
|
||||
notify-send --icon="${3:-video-x-generic}" "$1" "$2" 2>/dev/null || true
|
||||
}
|
||||
|
||||
cmd_video() {
|
||||
local input="${1:-}" preset="${2:-1080p}"
|
||||
[[ -f "$input" ]] || { err 'that file does not exist'; return 2; }
|
||||
|
||||
local height
|
||||
case "$preset" in
|
||||
1080p) height=1080 ;;
|
||||
720p) height=720 ;;
|
||||
480p) height=480 ;;
|
||||
*) err "unknown size: $preset"; return 2 ;;
|
||||
esac
|
||||
|
||||
local dir stem output
|
||||
dir="$(dirname "$input")"
|
||||
stem="$(basename "${input%.*}")"
|
||||
output="$(free_path "$dir" "$stem" "$preset" mp4)"
|
||||
|
||||
notify "Transcoding" "$(basename "$input") → $preset" video-x-generic
|
||||
# -2 rather than -1 on width: H.264 needs even dimensions, and an odd one
|
||||
# fails at the very end of a long encode.
|
||||
if ffmpeg -nostdin -loglevel error -i "$input" \
|
||||
-vf "scale=-2:'min($height,ih)'" \
|
||||
-c:v libx264 -crf 23 -preset medium \
|
||||
-c:a aac -b:a 128k \
|
||||
"$output" </dev/null; then
|
||||
notify "Transcoded" "$(basename "$output")" video-x-generic
|
||||
printf '%s\n' "$output"
|
||||
else
|
||||
rm -f "$output"
|
||||
notify "Transcode failed" "$(basename "$input")" dialog-error-symbolic
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_image() {
|
||||
local input="${1:-}" format="${2:-jpg}"
|
||||
[[ -f "$input" ]] || { err 'that file does not exist'; return 2; }
|
||||
|
||||
case "$format" in
|
||||
jpg|png|webp) ;;
|
||||
*) err "unknown format: $format"; return 2 ;;
|
||||
esac
|
||||
|
||||
local dir stem output
|
||||
dir="$(dirname "$input")"
|
||||
stem="$(basename "${input%.*}")"
|
||||
output="$(free_path "$dir" "$stem" converted "$format")"
|
||||
|
||||
local -a quality=()
|
||||
[[ "$format" == "jpg" ]] && quality=(-q:v 3)
|
||||
[[ "$format" == "webp" ]] && quality=(-quality 82)
|
||||
|
||||
if ffmpeg -nostdin -loglevel error -i "$input" "${quality[@]}" "$output" </dev/null; then
|
||||
notify "Converted" "$(basename "$output")" image-x-generic
|
||||
printf '%s\n' "$output"
|
||||
else
|
||||
rm -f "$output"
|
||||
notify "Conversion failed" "$(basename "$input")" dialog-error-symbolic
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
video) shift; cmd_video "$@" ;;
|
||||
image) shift; cmd_image "$@" ;;
|
||||
-h|--help|"")
|
||||
cat <<'USAGE'
|
||||
usage: panama-transcode video <file> [1080p|720p|480p]
|
||||
panama-transcode image <file> [jpg|png|webp]
|
||||
|
||||
Writes beside the input, never over it, and never over an existing output.
|
||||
USAGE
|
||||
;;
|
||||
*) err "unknown command: $1"; exit 2 ;;
|
||||
esac
|
||||
Executable
+194
@@ -0,0 +1,194 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# A website, as an application.
|
||||
#
|
||||
# panama-webapp install https://app.example.com "Example"
|
||||
# panama-webapp list
|
||||
# panama-webapp remove "Example"
|
||||
#
|
||||
# Both macOS and Windows ship this now -- Safari's "Add to Dock", Edge's
|
||||
# "Install this site as an app" -- and the dock and launcher here had nothing
|
||||
# to feed them but installed packages. A web app gets its own icon, its own
|
||||
# window with no browser chrome, and its own entry in the launcher, which is
|
||||
# most of what "installed" means in practice.
|
||||
#
|
||||
# The desktop entry is ordinary and inspectable: it lives in
|
||||
# ~/.local/share/applications with everything else, and `remove` finds its own
|
||||
# entries by the launcher line rather than by keeping a list somewhere.
|
||||
#
|
||||
# Chromium-family browsers implement --app. Firefox does not, and there is no
|
||||
# honest equivalent, so a machine whose default browser is Firefox is told
|
||||
# rather than given something that opens a normal window and pretends.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
APPLICATIONS="${XDG_DATA_HOME:-$HOME/.local/share}/applications"
|
||||
ICONS="${XDG_DATA_HOME:-$HOME/.local/share}/icons/hicolor/256x256/apps"
|
||||
LAUNCH_MARKER="panama-webapp"
|
||||
|
||||
err() { printf 'panama-webapp: %s\n' "$*" >&2; }
|
||||
|
||||
# A filename that cannot escape the applications directory. Everything that is
|
||||
# not a letter or a digit becomes a hyphen, which also makes the result
|
||||
# predictable enough for `remove` to find.
|
||||
slugify() {
|
||||
printf '%s' "$1" \
|
||||
| tr '[:upper:]' '[:lower:]' \
|
||||
| sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//'
|
||||
}
|
||||
|
||||
# The browser to open the app window with. Follows the desktop's own default
|
||||
# rather than naming one, and refuses rather than degrading when that browser
|
||||
# cannot do app mode.
|
||||
resolve_browser() {
|
||||
local desktop exec_line binary
|
||||
desktop="$(xdg-settings get default-web-browser 2>/dev/null || true)"
|
||||
|
||||
if [[ -n "$desktop" ]]; then
|
||||
local file
|
||||
for dir in "$APPLICATIONS" /usr/local/share/applications /usr/share/applications; do
|
||||
file="$dir/$desktop"
|
||||
[[ -r "$file" ]] || continue
|
||||
exec_line="$(sed -n 's/^Exec=//p' "$file" | head -1)"
|
||||
binary="$(awk '{ print $1 }' <<<"$exec_line")"
|
||||
break
|
||||
done
|
||||
fi
|
||||
|
||||
[[ -n "${binary:-}" ]] || binary="$(command -v chromium || command -v google-chrome || true)"
|
||||
[[ -n "$binary" ]] || return 1
|
||||
|
||||
# Chromium-family only. The name check is crude but the alternative is
|
||||
# launching a browser to ask it, which is worse.
|
||||
case "$(basename "$binary")" in
|
||||
*firefox*|*librewolf*|*zen*) return 2 ;;
|
||||
esac
|
||||
printf '%s' "$binary"
|
||||
}
|
||||
|
||||
# Four attempts, in descending order of how much the site had to say about it.
|
||||
# An icon is never worth failing an install over: a web app with the generic
|
||||
# icon still works.
|
||||
fetch_icon() {
|
||||
local url="$1" slug="$2" origin html href target
|
||||
origin="$(sed -E 's#^(https?://[^/]+).*#\1#' <<<"$url")"
|
||||
target="$ICONS/$slug.png"
|
||||
mkdir -p "$ICONS"
|
||||
|
||||
html="$(curl -fsSL --max-time 10 "$url" 2>/dev/null || true)"
|
||||
href="$(grep -oiE '<link[^>]+rel="[^"]*apple-touch-icon[^"]*"[^>]*>' <<<"$html" \
|
||||
| grep -oiE 'href="[^"]+"' | head -1 | sed 's/href="//I; s/"$//' || true)"
|
||||
|
||||
if [[ -n "$href" ]]; then
|
||||
case "$href" in
|
||||
http*) ;;
|
||||
/*) href="$origin$href" ;;
|
||||
*) href="$origin/$href" ;;
|
||||
esac
|
||||
curl -fsSL --max-time 10 -o "$target" "$href" 2>/dev/null && { printf '%s' "$slug"; return 0; }
|
||||
fi
|
||||
|
||||
curl -fsSL --max-time 10 -o "$target" "$origin/apple-touch-icon.png" 2>/dev/null \
|
||||
&& { printf '%s' "$slug"; return 0; }
|
||||
|
||||
local host
|
||||
host="$(sed -E 's#^https?://([^/]+).*#\1#' <<<"$url")"
|
||||
curl -fsSL --max-time 10 -o "$target" \
|
||||
"https://www.google.com/s2/favicons?sz=256&domain=$host" 2>/dev/null \
|
||||
&& { printf '%s' "$slug"; return 0; }
|
||||
|
||||
rm -f "$target"
|
||||
printf 'applications-internet'
|
||||
}
|
||||
|
||||
cmd_install() {
|
||||
local url="${1:-}" name="${2:-}"
|
||||
[[ -n "$url" ]] || { err 'install needs a URL'; return 2; }
|
||||
[[ "$url" =~ ^https?:// ]] || { err 'the URL must begin with http:// or https://'; return 2; }
|
||||
|
||||
# Default the name from the host, so `install https://app.example.com` is
|
||||
# enough for the common case.
|
||||
[[ -n "$name" ]] || name="$(sed -E 's#^https?://(www\.)?([^/]+).*#\2#' <<<"$url")"
|
||||
|
||||
local slug; slug="$(slugify "$name")"
|
||||
[[ -n "$slug" ]] || { err 'that name has no usable characters in it'; return 2; }
|
||||
|
||||
local browser status
|
||||
browser="$(resolve_browser)"; status=$?
|
||||
if (( status == 2 )); then
|
||||
err 'the default browser cannot open a site as its own application.'
|
||||
err 'Chromium-family browsers implement --app; Firefox does not.'
|
||||
return 1
|
||||
fi
|
||||
[[ -n "$browser" ]] || { err 'no browser found'; return 1; }
|
||||
|
||||
local icon; icon="$(fetch_icon "$url" "$slug")"
|
||||
|
||||
mkdir -p "$APPLICATIONS"
|
||||
local entry="$APPLICATIONS/$LAUNCH_MARKER-$slug.desktop"
|
||||
cat >"$entry" <<ENTRY
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=$name
|
||||
Comment=$url
|
||||
Exec=$browser --app=$url
|
||||
Icon=$icon
|
||||
Terminal=false
|
||||
Categories=Network;
|
||||
StartupWMClass=$browser
|
||||
X-Panama-WebApp=$url
|
||||
ENTRY
|
||||
|
||||
command -v update-desktop-database >/dev/null 2>&1 \
|
||||
&& update-desktop-database "$APPLICATIONS" >/dev/null 2>&1
|
||||
command -v gtk-update-icon-cache >/dev/null 2>&1 \
|
||||
&& gtk-update-icon-cache -f -t "${XDG_DATA_HOME:-$HOME/.local/share}/icons/hicolor" 2>/dev/null
|
||||
|
||||
printf 'Installed %s\n' "$name"
|
||||
printf ' %s\n' "$entry"
|
||||
}
|
||||
|
||||
cmd_list() {
|
||||
local entry name url
|
||||
shopt -s nullglob
|
||||
for entry in "$APPLICATIONS/$LAUNCH_MARKER-"*.desktop; do
|
||||
name="$(sed -n 's/^Name=//p' "$entry" | head -1)"
|
||||
url="$(sed -n 's/^X-Panama-WebApp=//p' "$entry" | head -1)"
|
||||
printf '%s\t%s\n' "$name" "$url"
|
||||
done
|
||||
}
|
||||
|
||||
cmd_remove() {
|
||||
local name="${1:-}"
|
||||
[[ -n "$name" ]] || { err 'remove needs a name'; return 2; }
|
||||
local slug; slug="$(slugify "$name")"
|
||||
local entry="$APPLICATIONS/$LAUNCH_MARKER-$slug.desktop"
|
||||
|
||||
# Only ever removes an entry this command installed. The prefix and the
|
||||
# X-Panama-WebApp key both have to be there, so a name collision with a
|
||||
# real application cannot delete it.
|
||||
[[ -f "$entry" ]] || { err "no web app named '$name'"; return 1; }
|
||||
grep -q '^X-Panama-WebApp=' "$entry" || { err "$entry is not a Panama web app"; return 1; }
|
||||
|
||||
rm -f "$entry" "$ICONS/$slug.png"
|
||||
command -v update-desktop-database >/dev/null 2>&1 \
|
||||
&& update-desktop-database "$APPLICATIONS" >/dev/null 2>&1
|
||||
printf 'Removed %s\n' "$name"
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
install) shift; cmd_install "$@" ;;
|
||||
list) shift; cmd_list "$@" ;;
|
||||
remove) shift; cmd_remove "$@" ;;
|
||||
-h|--help|"")
|
||||
cat <<'USAGE'
|
||||
usage: panama-webapp install <url> [name]
|
||||
panama-webapp list
|
||||
panama-webapp remove <name>
|
||||
|
||||
Turns a website into an application: its own icon, its own window with no
|
||||
browser chrome, and its own entry in the launcher.
|
||||
USAGE
|
||||
;;
|
||||
*) err "unknown command: $1"; exit 2 ;;
|
||||
esac
|
||||
@@ -0,0 +1,589 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Panama's front door: the one command a fresh Fedora machine needs.
|
||||
#
|
||||
# Download this file from the documented commit URL, verify its documented
|
||||
# SHA-256, then pass both immutable values as PANAMA_BOOT_REVISION and
|
||||
# PANAMA_BOOT_SHA256 when invoking it.
|
||||
#
|
||||
# Deliberately dumb, because a copy of this script leaves the repository the
|
||||
# moment somebody curls it -- nothing here can be fixed by re-running
|
||||
# ./install, so there is as little here as possible: get git, get the clone,
|
||||
# hand off. Everything with judgment in it lives in `install`, which is also
|
||||
# where re-runs and upgrades already work.
|
||||
#
|
||||
# The one exception to dumb is the root path below, which cannot live in
|
||||
# `install`: a fresh VPS hands you a root login and nothing else, and the user
|
||||
# that `install` needs to exist is exactly what has not been created yet.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [[ ! "${PANAMA_BOOT_REVISION:-}" =~ ^[0-9a-f]{40}$ ]]; then
|
||||
echo "boot: PANAMA_BOOT_REVISION must be a full lowercase commit" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! "${PANAMA_BOOT_SHA256:-}" =~ ^[0-9a-f]{64}$ ]]; then
|
||||
echo "boot: PANAMA_BOOT_SHA256 must be a lowercase SHA-256" >&2
|
||||
exit 1
|
||||
fi
|
||||
actual_boot_sha="$(sha256sum "${BASH_SOURCE[0]}" | cut -d' ' -f1)"
|
||||
if [[ "$actual_boot_sha" != "$PANAMA_BOOT_SHA256" ]]; then
|
||||
echo "boot: downloaded boot file does not match PANAMA_BOOT_SHA256" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
REPO_URL="https://git.gbrown.org/gib/Panama.git"
|
||||
PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}"
|
||||
export PANAMA_PATH
|
||||
BOOTSTRAP_USER=""
|
||||
|
||||
checkout_command() {
|
||||
if [[ -n "$BOOTSTRAP_USER" ]]; then
|
||||
runuser -u "$BOOTSTRAP_USER" -- "$@"
|
||||
else
|
||||
"$@"
|
||||
fi
|
||||
}
|
||||
|
||||
# Git's index hints are performance promises, not trust evidence. In
|
||||
# particular, assume-unchanged and skip-worktree can make porcelain status
|
||||
# report a clean checkout whose files no longer match HEAD. Compare every
|
||||
# tracked blob and Git mode with the verified commit before handing control to
|
||||
# any file in the worktree.
|
||||
checkout_matches_verified_commit() (
|
||||
local checkout="$1" listing="" entry metadata mode type expected path actual
|
||||
local link_target_with_sentinel link_target
|
||||
|
||||
trap '[[ -z "$listing" ]] || rm -f -- "$listing"' EXIT
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
listing="$(mktemp -u -t panama-boot-tree.XXXXXX)" || exit 1
|
||||
umask 077
|
||||
if ! (set -o noclobber; : >"$listing") 2>/dev/null; then
|
||||
listing=""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
checkout_command git -C "$checkout" ls-tree -rz --full-tree \
|
||||
"$PANAMA_BOOT_REVISION" >"$listing" || exit 1
|
||||
while IFS= read -r -d '' entry; do
|
||||
[[ "$entry" == *$'\t'* ]] || exit 1
|
||||
metadata="${entry%%$'\t'*}"
|
||||
path="${entry#*$'\t'}"
|
||||
read -r mode type expected <<<"$metadata"
|
||||
[[ "$type" == blob && -n "$path" && "$path" != /* ]] || exit 1
|
||||
|
||||
case "$mode" in
|
||||
100644) [[ -f "$checkout/$path" && ! -L "$checkout/$path" \
|
||||
&& ! -x "$checkout/$path" ]] || exit 1 ;;
|
||||
100755) [[ -f "$checkout/$path" && ! -L "$checkout/$path" \
|
||||
&& -x "$checkout/$path" ]] || exit 1 ;;
|
||||
120000)
|
||||
[[ -L "$checkout/$path" ]] || exit 1
|
||||
# hash-object given a pathname follows a symlink. Git's 120000 blob is
|
||||
# the link text itself, including any trailing newlines, so preserve
|
||||
# those bytes with a sentinel and hash stdin instead.
|
||||
link_target_with_sentinel="$(
|
||||
readlink -n -- "$checkout/$path" && printf .
|
||||
)" || exit 1
|
||||
[[ "$link_target_with_sentinel" == *. ]] || exit 1
|
||||
link_target="${link_target_with_sentinel%.}"
|
||||
actual="$(
|
||||
printf '%s' "$link_target" \
|
||||
| checkout_command git -C "$checkout" hash-object --stdin
|
||||
)" || exit 1
|
||||
[[ "$actual" == "$expected" ]] || exit 1
|
||||
continue
|
||||
;;
|
||||
*) exit 1 ;;
|
||||
esac
|
||||
|
||||
actual="$(checkout_command git -C "$checkout" hash-object --no-filters -- "$path")" \
|
||||
|| exit 1
|
||||
[[ "$actual" == "$expected" ]] || exit 1
|
||||
done <"$listing"
|
||||
)
|
||||
|
||||
prepare_panama_checkout() {
|
||||
local checkout="$1" actual_head checkout_status
|
||||
|
||||
if [[ -e "$checkout" && ! -d "$checkout/.git" ]]; then
|
||||
printf 'boot: %s exists but is not a Panama Git checkout\n' "$checkout" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ -d "$checkout/.git" ]]; then
|
||||
checkout_status="$(checkout_command git -C "$checkout" status --porcelain)" || {
|
||||
printf 'boot: could not inspect the existing checkout at %s\n' "$checkout" >&2
|
||||
return 1
|
||||
}
|
||||
if [[ -n "$checkout_status" ]]; then
|
||||
printf 'boot: existing checkout at %s has local changes\n' "$checkout" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "Panama is already cloned at $checkout; fetching the verified revision"
|
||||
checkout_command git -C "$checkout" fetch origin "$PANAMA_BOOT_REVISION" || return 1
|
||||
if ! checkout_command git -C "$checkout" merge-base --is-ancestor \
|
||||
HEAD "$PANAMA_BOOT_REVISION"; then
|
||||
echo "boot: existing checkout cannot fast-forward to the verified revision" >&2
|
||||
return 1
|
||||
fi
|
||||
checkout_command git -C "$checkout" merge --ff-only "$PANAMA_BOOT_REVISION" || return 1
|
||||
else
|
||||
checkout_command mkdir -p "$(dirname "$checkout")" || return 1
|
||||
checkout_command git init "$checkout" || return 1
|
||||
checkout_command git -C "$checkout" remote add origin "$REPO_URL" || return 1
|
||||
checkout_command git -C "$checkout" fetch --depth=1 origin \
|
||||
"$PANAMA_BOOT_REVISION" || return 1
|
||||
checkout_command git -C "$checkout" checkout --detach \
|
||||
"$PANAMA_BOOT_REVISION" || return 1
|
||||
|
||||
actual_head="$(checkout_command git -C "$checkout" rev-parse 'HEAD^{commit}')" || return 1
|
||||
if [[ "$actual_head" != "$PANAMA_BOOT_REVISION" ]]; then
|
||||
echo "boot: fetched checkout does not match PANAMA_BOOT_REVISION" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
checkout_command git -C "$checkout" checkout -b main || return 1
|
||||
checkout_command git -C "$checkout" config branch.main.remote origin || return 1
|
||||
checkout_command git -C "$checkout" config branch.main.merge refs/heads/main || return 1
|
||||
fi
|
||||
|
||||
actual_head="$(checkout_command git -C "$checkout" rev-parse 'HEAD^{commit}')" || return 1
|
||||
if [[ "$actual_head" != "$PANAMA_BOOT_REVISION" ]]; then
|
||||
echo "boot: checkout HEAD does not match PANAMA_BOOT_REVISION" >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
SERVER=0
|
||||
INSTALL_ARGS=()
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--server) SERVER=1; INSTALL_ARGS+=(--server) ;;
|
||||
*)
|
||||
printf 'boot: unknown argument: %s\n' "$arg" >&2
|
||||
printf 'usage: boot [--server]\n' >&2
|
||||
exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Keep the worktree comparison at the last possible boundary. Checkout
|
||||
# preparation may invoke several commands and return to the caller; performing
|
||||
# the byte/mode/link check here ensures a change in that interval is rejected
|
||||
# before any tracked file is executed.
|
||||
verified_install_handoff() {
|
||||
local use_tty="$1"
|
||||
if ! checkout_matches_verified_commit "$PANAMA_PATH"; then
|
||||
echo "boot: checkout files do not match PANAMA_BOOT_REVISION" >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ -n "$BOOTSTRAP_USER" ]]; then
|
||||
if (( use_tty )); then
|
||||
exec runuser -u "$BOOTSTRAP_USER" -- env PANAMA_PATH="$PANAMA_PATH" \
|
||||
"$PANAMA_PATH/install" ${INSTALL_ARGS[@]+"${INSTALL_ARGS[@]}"} </dev/tty
|
||||
fi
|
||||
exec runuser -u "$BOOTSTRAP_USER" -- env PANAMA_PATH="$PANAMA_PATH" \
|
||||
"$PANAMA_PATH/install" ${INSTALL_ARGS[@]+"${INSTALL_ARGS[@]}"}
|
||||
fi
|
||||
if (( use_tty )); then
|
||||
exec "$PANAMA_PATH/install" ${INSTALL_ARGS[@]+"${INSTALL_ARGS[@]}"} </dev/tty
|
||||
fi
|
||||
exec "$PANAMA_PATH/install" ${INSTALL_ARGS[@]+"${INSTALL_ARGS[@]}"}
|
||||
}
|
||||
|
||||
# The public bootstrap contract runs this branch as an ordinary user with a
|
||||
# stubbed root identity. Keep its filesystem adapter unavailable to a real root
|
||||
# shell so it cannot redirect a real installation by accident.
|
||||
BOOT_ROOT="${PANAMA_BOOT_FIXTURE_ROOT:-}"
|
||||
if [[ -n "$BOOT_ROOT" && "$EUID" -eq 0 ]]; then
|
||||
echo "boot: PANAMA_BOOT_FIXTURE_ROOT is test-only" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
system_path() {
|
||||
local path="$1"
|
||||
[[ "$path" == /* ]] || return 2
|
||||
printf '%s%s\n' "$BOOT_ROOT" "$path"
|
||||
}
|
||||
|
||||
valid_authorized_keys() {
|
||||
local keys="$1" line saw_key=0
|
||||
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||
if [[ "$line" =~ ^[[:space:]]*$ || "$line" =~ ^[[:space:]]*# ]]; then
|
||||
continue
|
||||
fi
|
||||
if ! ssh-keygen -l -f /dev/stdin >/dev/null 2>&1 <<<"$line"; then
|
||||
return 1
|
||||
fi
|
||||
saw_key=1
|
||||
done <"$keys"
|
||||
(( saw_key ))
|
||||
}
|
||||
|
||||
safe_authorized_keys() {
|
||||
local username="$1" user_home="$2" uid ssh_dir keys
|
||||
uid="$(id -u "$username")" || return 1
|
||||
[[ "$uid" =~ ^[0-9]+$ && "$uid" != 0 && "$user_home" == /* ]] || return 1
|
||||
ssh_dir="$user_home/.ssh"
|
||||
keys="$ssh_dir/authorized_keys"
|
||||
[[ -d "$ssh_dir" && ! -L "$ssh_dir" && -f "$keys" && ! -L "$keys" ]] || return 1
|
||||
[[ "$(stat -Lc '%u:%a' "$ssh_dir")" == "$uid:700" ]] || return 1
|
||||
[[ "$(stat -Lc '%u:%a' "$keys")" == "$uid:600" ]] || return 1
|
||||
valid_authorized_keys "$keys"
|
||||
}
|
||||
|
||||
safe_root_authorized_keys() {
|
||||
local keys
|
||||
keys="$(system_path /root/.ssh/authorized_keys)" || return 1
|
||||
[[ -f "$keys" && ! -L "$keys" ]] || return 1
|
||||
[[ "$(stat -Lc '%u:%a' "$keys")" == '0:600' ]] || return 1
|
||||
valid_authorized_keys "$keys"
|
||||
}
|
||||
|
||||
detect_ssh_unit() {
|
||||
local unit
|
||||
for unit in sshd.service ssh.service; do
|
||||
systemctl cat "$unit" >/dev/null 2>&1 && {
|
||||
printf '%s\n' "$unit"
|
||||
return 0
|
||||
}
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
restore_ssh_dropin() {
|
||||
local restore
|
||||
if (( ssh_had_prior )); then
|
||||
[[ -n "$ssh_backup" && -f "$ssh_backup" && ! -L "$ssh_backup" ]] || return 1
|
||||
restore="$(mktemp --tmpdir="$sshd_dir" .00-panama.XXXXXX.restore)" || return 1
|
||||
if ! cp -a -- "$ssh_backup" "$restore"; then
|
||||
remove_ssh_artifact "$restore" || true
|
||||
return 1
|
||||
fi
|
||||
if ! mv -f -- "$restore" "$ssh_dropin"; then
|
||||
remove_ssh_artifact "$restore" || true
|
||||
return 1
|
||||
fi
|
||||
else
|
||||
remove_ssh_artifact "$ssh_dropin"
|
||||
fi
|
||||
}
|
||||
|
||||
restore_ssh_transaction_traps() {
|
||||
trap - EXIT INT TERM
|
||||
[[ -n "${ssh_saved_exit_trap:-}" ]] && eval "$ssh_saved_exit_trap"
|
||||
[[ -n "${ssh_saved_int_trap:-}" ]] && eval "$ssh_saved_int_trap"
|
||||
[[ -n "${ssh_saved_term_trap:-}" ]] && eval "$ssh_saved_term_trap"
|
||||
return 0
|
||||
}
|
||||
|
||||
remove_ssh_artifact() {
|
||||
local artifact="$1"
|
||||
[[ -n "$artifact" && ( -e "$artifact" || -L "$artifact" ) ]] || return 0
|
||||
if rm -f -- "$artifact"; then
|
||||
return 0
|
||||
fi
|
||||
printf 'SSH transaction cleanup failed. Retained artifact: %s\n' "$artifact" >&2
|
||||
printf ' rm -f -- %q\n' "$artifact" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
print_ssh_recovery() {
|
||||
if (( ssh_had_prior )); then
|
||||
printf 'SSH rollback needs manual recovery. Backup: %s\n' "$ssh_backup" >&2
|
||||
printf ' cp -a -- %q %q\n' "$ssh_backup" "$ssh_dropin" >&2
|
||||
else
|
||||
printf 'SSH rollback needs manual recovery. No prior drop-in existed.\n' >&2
|
||||
printf ' rm -f -- %q\n' "$ssh_dropin" >&2
|
||||
fi
|
||||
printf ' sshd -t\n' >&2
|
||||
printf ' systemctl reload %s\n' "$ssh_unit" >&2
|
||||
}
|
||||
|
||||
policy_is_no() {
|
||||
local policy="$1" setting="$2"
|
||||
awk -v setting="$setting" '
|
||||
$1 == setting { count += 1; if ($2 != "no") bad = 1 }
|
||||
END { exit count != 1 || bad }
|
||||
' <<<"$policy"
|
||||
}
|
||||
|
||||
effective_ssh_policy_is_hardened() {
|
||||
local username="$1" root_policy target_policy context
|
||||
context='host=localhost,addr=127.0.0.1'
|
||||
root_policy="$(sshd -T -C "user=root,$context")" || return 1
|
||||
policy_is_no "$root_policy" permitrootlogin || return 1
|
||||
policy_is_no "$root_policy" passwordauthentication || return 1
|
||||
policy_is_no "$root_policy" kbdinteractiveauthentication || return 1
|
||||
|
||||
target_policy="$(sshd -T -C "user=$username,$context")" || return 1
|
||||
policy_is_no "$target_policy" passwordauthentication || return 1
|
||||
policy_is_no "$target_policy" kbdinteractiveauthentication
|
||||
}
|
||||
|
||||
rollback_ssh_transaction() {
|
||||
local reload_restored="$1" rollback_failed=0
|
||||
restore_ssh_dropin || rollback_failed=1
|
||||
sshd -t || rollback_failed=1
|
||||
if (( reload_restored )); then
|
||||
systemctl reload "$ssh_unit" || rollback_failed=1
|
||||
fi
|
||||
ssh_transaction_state=""
|
||||
restore_ssh_transaction_traps
|
||||
if (( rollback_failed )); then
|
||||
print_ssh_recovery
|
||||
else
|
||||
remove_ssh_artifact "$ssh_backup" || true
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
handle_ssh_transaction_exit() {
|
||||
if [[ "$ssh_transaction_state" == preparing \
|
||||
|| ( "$ssh_transaction_state" == activating && -e "$ssh_candidate" ) ]]; then
|
||||
remove_ssh_artifact "$ssh_candidate" || true
|
||||
remove_ssh_artifact "$ssh_backup" || true
|
||||
elif [[ "$ssh_transaction_state" == activating || "$ssh_transaction_state" == activated ]]; then
|
||||
restore_ssh_dropin || true
|
||||
fi
|
||||
}
|
||||
|
||||
handle_ssh_transaction_signal() {
|
||||
local signal_status="$1"
|
||||
trap - INT TERM
|
||||
if [[ "$ssh_transaction_state" == preparing \
|
||||
|| ( "$ssh_transaction_state" == activating && -e "$ssh_candidate" ) ]]; then
|
||||
ssh_transaction_state=""
|
||||
restore_ssh_transaction_traps
|
||||
remove_ssh_artifact "$ssh_candidate" || true
|
||||
remove_ssh_artifact "$ssh_backup" || true
|
||||
else
|
||||
rollback_ssh_transaction 1 || true
|
||||
fi
|
||||
exit "$signal_status"
|
||||
}
|
||||
|
||||
harden_server_ssh() {
|
||||
local username="$1" user_home="$2" sshd_dir ssh_dropin harden ssh_unit
|
||||
local ssh_candidate="" ssh_backup="" ssh_had_prior=0
|
||||
local ssh_transaction_state=""
|
||||
local ssh_saved_exit_trap ssh_saved_int_trap ssh_saved_term_trap
|
||||
sshd_dir="$(system_path /etc/ssh/sshd_config.d)" || return 1
|
||||
ssh_dropin="$sshd_dir/00-panama.conf"
|
||||
|
||||
if [[ -L "$ssh_dropin" || ( -e "$ssh_dropin" && ! -f "$ssh_dropin" ) ]]; then
|
||||
printf 'SSH hardening unavailable: %s is not a regular file\n' "$ssh_dropin" >&2
|
||||
return 2
|
||||
fi
|
||||
|
||||
if ! ssh_unit="$(detect_ssh_unit)"; then
|
||||
echo "SSH hardening unavailable: neither sshd.service nor ssh.service is installed" >&2
|
||||
return 2
|
||||
fi
|
||||
|
||||
printf 'Harden sshd (disable root, password, and keyboard-interactive authentication)? [Y/n]: '
|
||||
read -r harden </dev/tty || harden=""
|
||||
if [[ "$harden" =~ ^[Nn] ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
ssh_saved_exit_trap="$(trap -p EXIT)"
|
||||
ssh_saved_int_trap="$(trap -p INT)"
|
||||
ssh_saved_term_trap="$(trap -p TERM)"
|
||||
ssh_transaction_state=preparing
|
||||
trap 'handle_ssh_transaction_exit' EXIT
|
||||
trap 'handle_ssh_transaction_signal 130' INT
|
||||
trap 'handle_ssh_transaction_signal 143' TERM
|
||||
|
||||
if ! ssh_candidate="$(umask 077; mktemp --tmpdir="$sshd_dir" .00-panama.XXXXXX.tmp)"; then
|
||||
ssh_transaction_state=""
|
||||
restore_ssh_transaction_traps
|
||||
return 1
|
||||
fi
|
||||
if ! printf 'PermitRootLogin no\nPasswordAuthentication no\nKbdInteractiveAuthentication no\n' >"$ssh_candidate"; then
|
||||
ssh_transaction_state=""
|
||||
restore_ssh_transaction_traps
|
||||
remove_ssh_artifact "$ssh_candidate" || true
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ -e "$ssh_dropin" ]]; then
|
||||
ssh_had_prior=1
|
||||
if ! ssh_backup="$(umask 077; mktemp --tmpdir="$sshd_dir" .00-panama.XXXXXX.backup)"; then
|
||||
ssh_transaction_state=""
|
||||
restore_ssh_transaction_traps
|
||||
remove_ssh_artifact "$ssh_candidate" || true
|
||||
return 1
|
||||
fi
|
||||
if ! cp -a -- "$ssh_dropin" "$ssh_backup"; then
|
||||
ssh_transaction_state=""
|
||||
restore_ssh_transaction_traps
|
||||
remove_ssh_artifact "$ssh_candidate" || true
|
||||
remove_ssh_artifact "$ssh_backup" || true
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
ssh_transaction_state=activating
|
||||
if ! mv -f -- "$ssh_candidate" "$ssh_dropin"; then
|
||||
ssh_transaction_state=""
|
||||
restore_ssh_transaction_traps
|
||||
remove_ssh_artifact "$ssh_candidate" || true
|
||||
remove_ssh_artifact "$ssh_backup" || true
|
||||
return 1
|
||||
fi
|
||||
ssh_candidate=""
|
||||
ssh_transaction_state=activated
|
||||
|
||||
if ! sshd -t || ! effective_ssh_policy_is_hardened "$username"; then
|
||||
rollback_ssh_transaction 0 || true
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! systemctl reload "$ssh_unit"; then
|
||||
rollback_ssh_transaction 1 || true
|
||||
return 1
|
||||
fi
|
||||
|
||||
ssh_transaction_state=""
|
||||
restore_ssh_transaction_traps
|
||||
remove_ssh_artifact "$ssh_backup" || return 1
|
||||
echo "Wrote $ssh_dropin; make sure your key works before logging out."
|
||||
}
|
||||
|
||||
# Panama assumes Fedora's repositories and package names.
|
||||
if ! grep -qi '^ID=fedora' /etc/os-release 2>/dev/null; then
|
||||
echo "This looks like something other than Fedora; Panama only supports Fedora." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── Root ─────────────────────────────────────────────────────────────────────
|
||||
#
|
||||
# On a desktop, root is a mistake: the clone and every dotfile would land in
|
||||
# root's home and configure the wrong user. On a fresh VPS it is the starting
|
||||
# condition -- Hetzner hands over a root login and nothing else -- so with
|
||||
# --server this walks the machine from that to a normal Panama install: a
|
||||
# user with sudo, keys, an optionally hardened sshd, and `install --server`
|
||||
# running as that user. Every step checks before acting, because the machine
|
||||
# may be anywhere along this path already: a user half-created by hand, keys
|
||||
# already copied, sshd already locked down.
|
||||
if [[ "$(id -u)" -eq 0 ]]; then
|
||||
if (( ! SERVER )); then
|
||||
echo "Run this as your own user, not root: the install configures YOUR desktop." >&2
|
||||
echo "Setting up a fresh server from its root login is: boot --server" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Everything here asks, and a root shell from `bash <(curl ...)` can have
|
||||
# the pipe as stdin, so every prompt reads the terminal explicitly.
|
||||
if ! (exec </dev/tty) 2>/dev/null; then
|
||||
echo "No terminal to ask on; run this from an interactive root shell." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf 'Username for this server [gib]: '
|
||||
read -r username </dev/tty || username=""
|
||||
username="${username:-gib}"
|
||||
|
||||
if id -u "$username" >/dev/null 2>&1; then
|
||||
echo "User $username already exists"
|
||||
# wheel is what makes sudo work on Fedora; a user created by hand may not
|
||||
# have it, and everything after this depends on it.
|
||||
id -nG "$username" | grep -qw wheel || usermod -aG wheel "$username"
|
||||
else
|
||||
echo "Creating $username with sudo (wheel)"
|
||||
useradd -m -G wheel "$username"
|
||||
fi
|
||||
|
||||
# useradd leaves the account locked, and sudo asks for this password -- a
|
||||
# user who cannot sudo is a user the install cannot run as.
|
||||
if ! passwd -S "$username" 2>/dev/null | awk '{exit $2 != "PS" && $2 != "P"}'; then
|
||||
echo "Set a password for $username (sudo will ask for it):"
|
||||
passwd "$username" </dev/tty
|
||||
fi
|
||||
|
||||
# Do not close root/password access until the account's key is an exact,
|
||||
# usable login path. The fixture adapter resolves these logical system paths
|
||||
# beneath a temporary root; ordinary execution receives the original paths.
|
||||
logical_user_home="$(getent passwd "$username" | cut -d: -f6)"
|
||||
user_home=""
|
||||
if [[ "$logical_user_home" == /* ]]; then
|
||||
user_home="$(system_path "$logical_user_home")" || true
|
||||
fi
|
||||
bootstrap_home="$user_home"
|
||||
if [[ -z "$bootstrap_home" ]]; then
|
||||
bootstrap_home="$(system_path "/home/$username")"
|
||||
fi
|
||||
|
||||
user_ssh_dir="$user_home/.ssh"
|
||||
user_keys="$user_ssh_dir/authorized_keys"
|
||||
if [[ -n "$user_home" && ! -e "$user_keys" && ! -L "$user_keys" \
|
||||
&& ! -L "$user_ssh_dir" ]] && safe_root_authorized_keys; then
|
||||
copy_root_key=0
|
||||
if [[ ! -e "$user_ssh_dir" ]]; then
|
||||
copy_root_key=1
|
||||
elif [[ ! -d "$user_ssh_dir" \
|
||||
|| "$(stat -Lc '%u:%a' "$user_ssh_dir")" != "$(id -u "$username"):700" ]]; then
|
||||
echo "SSH hardening unavailable: $username has no safe authorized_keys" >&2
|
||||
else
|
||||
copy_root_key=1
|
||||
fi
|
||||
if (( copy_root_key )); then
|
||||
echo "Copying root's authorized_keys to $username"
|
||||
root_keys="$(system_path /root/.ssh/authorized_keys)"
|
||||
if ! runuser -u "$username" -- install -d -m 0700 -- "$user_ssh_dir" \
|
||||
|| ! runuser -u "$username" -- install -m 0600 -- /dev/stdin "$user_keys" \
|
||||
<"$root_keys"; then
|
||||
echo "SSH hardening unavailable: could not install root's key for $username" >&2
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if safe_authorized_keys "$username" "$user_home"; then
|
||||
harden_status=0
|
||||
harden_server_ssh "$username" "$user_home" || harden_status=$?
|
||||
if (( harden_status != 0 && harden_status != 2 )); then
|
||||
echo "SSH hardening failed; stopping before install handoff." >&2
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "SSH hardening unavailable: $username has no safe authorized_keys" >&2
|
||||
fi
|
||||
|
||||
if ! command -v git >/dev/null 2>&1; then
|
||||
echo "Installing git, which the clone needs"
|
||||
dnf install -y --repo=fedora --repo=updates --from-repo=fedora,updates git
|
||||
fi
|
||||
|
||||
# Create or advance the checkout as the target user. A root-owned .git in a
|
||||
# user's home would break every later update.
|
||||
PANAMA_PATH="$bootstrap_home/.local/share/Panama"
|
||||
BOOTSTRAP_USER="$username"
|
||||
prepare_panama_checkout "$PANAMA_PATH"
|
||||
|
||||
echo "Handing off to install as $username"
|
||||
verified_install_handoff 1
|
||||
fi
|
||||
|
||||
# git is the one dependency the clone itself needs. Everything else -- gum
|
||||
# included -- is bootstrapped by `install`.
|
||||
if ! command -v git >/dev/null 2>&1; then
|
||||
echo "Installing git, which the clone needs"
|
||||
sudo dnf install -y --repo=fedora --repo=updates --from-repo=fedora,updates git
|
||||
fi
|
||||
|
||||
prepare_panama_checkout "$PANAMA_PATH"
|
||||
|
||||
# A shell invoked from automation can have a pipe as stdin, while the first
|
||||
# thing install runs is the interview, which has to be able to ask.
|
||||
# Reattach the terminal when there is one; without one the interview will say
|
||||
# so itself.
|
||||
# The probe actually opens /dev/tty rather than testing -r: a process with no
|
||||
# controlling terminal passes -r and then fails the redirect.
|
||||
handoff_tty=0
|
||||
if [[ ! -t 0 ]] && (exec </dev/tty) 2>/dev/null; then
|
||||
handoff_tty=1
|
||||
fi
|
||||
verified_install_handoff "$handoff_tty"
|
||||
@@ -24,8 +24,3 @@ else
|
||||
fi
|
||||
export PATH
|
||||
fi
|
||||
. "$HOME/.cargo/env"
|
||||
|
||||
# >>> Codex installer >>>
|
||||
export PATH="/home/gib/.local/bin:$PATH"
|
||||
# <<< Codex installer <<<
|
||||
|
||||
+12
-3
@@ -3,11 +3,19 @@
|
||||
# Aliases I like
|
||||
alias :q="exit"
|
||||
alias :wq="exit"
|
||||
alias startsunshine="systemctl --user restart sunshine.service"
|
||||
alias sourcerc="source ~/.bashrc"
|
||||
alias c="clear"
|
||||
alias shutdown="systemctl poweroff"
|
||||
alias update-grub="sudo grub-mkconfig -o /etc/grub2-efi.cfg"
|
||||
# The config target differs by firmware: EFI machines regenerate the EFI
|
||||
# config, BIOS machines /boot/grub2/grub.cfg -- writing the EFI path on a BIOS
|
||||
# machine updates a file nothing boots from.
|
||||
update-grub() {
|
||||
if [ -d /sys/firmware/efi ]; then
|
||||
sudo grub2-mkconfig -o /etc/grub2-efi.cfg
|
||||
else
|
||||
sudo grub2-mkconfig -o /boot/grub2/grub.cfg
|
||||
fi
|
||||
}
|
||||
alias nvidia-smi-docker='sudo docker run --rm --gpus all --privileged nvidia/cuda:12.8.1-base-ubuntu24.04 nvidia-smi'
|
||||
alias ncconnect='sudo docker exec -u www-data -it nextcloud-aio-nextcloud bash'
|
||||
alias avante='nvim -c "lua vim.defer_fn(function()require(\"avante.api\").zen_mode()end, 100)"'
|
||||
@@ -32,7 +40,8 @@ alias ls='eza -lh --group-directories-first --icons'
|
||||
alias lsa='ls -a'
|
||||
alias lt='eza --tree --level=2 --long --icons --git'
|
||||
alias lta='lt -a'
|
||||
alias ff="fzf --preview 'batcat --style=numbers --color=always {}'"
|
||||
# Fedora's bat installs /usr/bin/bat; batcat is the Debian name.
|
||||
alias ff="fzf --preview 'bat --style=numbers --color=always {}'"
|
||||
|
||||
# Directories
|
||||
alias ..='cd ..'
|
||||
|
||||
+41
-6
@@ -1,5 +1,18 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Personal environment -- API keys, tokens -- lives OUTSIDE the checkout,
|
||||
# where agents, backup tools and `panama update` walk, and is kept owner-only
|
||||
# every time it is read: a secrets file that drifts to 644 is quietly
|
||||
# re-tightened rather than trusted. Sourced first, because settings below
|
||||
# (PANAMA_SSH_TMUX) read it. (config/bash/env, its old home inside the repo,
|
||||
# is still sourced by .bashrc's glob if a machine has not been migrated yet.)
|
||||
PANAMA_ENV="${XDG_CONFIG_HOME:-$HOME/.config}/panama/env"
|
||||
if [ -f "$PANAMA_ENV" ]; then
|
||||
[ "$(stat -c %a "$PANAMA_ENV" 2>/dev/null)" = "600" ] || chmod 600 "$PANAMA_ENV"
|
||||
. "$PANAMA_ENV"
|
||||
fi
|
||||
unset PANAMA_ENV
|
||||
|
||||
# Editor used by CLI
|
||||
export EDITOR="nvim"
|
||||
export SUDO_EDITOR="$EDITOR"
|
||||
@@ -22,18 +35,33 @@ export DOTNETPATH="$HOME/.dotnet/tools"
|
||||
# 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"
|
||||
|
||||
# Nvm
|
||||
source /etc/profile.d/nvm.sh
|
||||
# rustup writes this file, and initial-packages installs rustup rather than
|
||||
# running rustup-init -- so on a fresh machine it does not exist yet and an
|
||||
# unguarded source made every single shell start with an error.
|
||||
[ -f "$HOME/.cargo/env" ] && . "$HOME/.cargo/env"
|
||||
|
||||
# Nvm. Guarded because the file belongs to the nvm package: before that is
|
||||
# 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
|
||||
_nvm_auto_use() {
|
||||
if [[ -f .nvmrc ]]; then
|
||||
# Guarded on nvm actually being loaded: without this, a machine where the
|
||||
# nvm profile script is absent printed "command not found" on every single
|
||||
# prompt in any directory carrying a .nvmrc.
|
||||
if [[ -f .nvmrc ]] && type -t nvm >/dev/null 2>&1; then
|
||||
nvm use --silent
|
||||
fi
|
||||
}
|
||||
export PROMPT_COMMAND="${PROMPT_COMMAND:+$PROMPT_COMMAND; }_nvm_auto_use"
|
||||
|
||||
# Auto-start or attach tmux for SSH interactive shells
|
||||
if [[ -n "$SSH_CONNECTION" && -z "$TMUX" && $- == *i* ]]; then
|
||||
# Auto-start or attach tmux for SSH interactive shells. A deliberate Panama
|
||||
# behavior (tmux is in initial-packages, and a dropped SSH session keeping its
|
||||
# work is the point), but guarded: it must not replace the shell of someone
|
||||
# whose machine lacks tmux, and PANAMA_SSH_TMUX=off turns it off for people
|
||||
# who want a plain shell -- set it in ~/.config/panama/env.
|
||||
if [[ -n "$SSH_CONNECTION" && -z "$TMUX" && $- == *i* &&
|
||||
"${PANAMA_SSH_TMUX:-on}" != "off" ]] && command -v tmux >/dev/null 2>&1; then
|
||||
exec tmux new-session -A -s main
|
||||
fi
|
||||
|
||||
@@ -41,4 +69,11 @@ fi
|
||||
eval "$(zoxide init bash)"
|
||||
|
||||
# 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
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
# Dictation's speech-to-text server, as a Quadlet.
|
||||
#
|
||||
# Quadlet rather than a hand-written unit wrapping `podman run`: systemd
|
||||
# generates the unit from this at boot, so there is one description of the
|
||||
# container rather than a unit and a command line drifting apart.
|
||||
#
|
||||
# Upstream's own Vulkan image, not one built here. whisper.cpp publishes it, it
|
||||
# already contains whisper-server, and it is maintained by the people who write
|
||||
# the thing -- a Containerfile in this repository would be a compile step on
|
||||
# every machine and a build to keep working, in exchange for nothing.
|
||||
#
|
||||
# Vulkan rather than ROCm, which is the reason this tag and not another. ROCm's
|
||||
# runtime is seven gigabytes and serves AMD alone; Vulkan compute runs on AMD,
|
||||
# Intel and NVIDIA through whatever Mesa driver a machine already has. These
|
||||
# machines are a mix of all three, and this image works on every one of them.
|
||||
#
|
||||
# Deliberately no [Install] section. whisper-server loads the model when it
|
||||
# starts and holds it, so a container started at login costs half a gigabyte of
|
||||
# memory in every session where nobody dictates. panama-dictate starts it on the
|
||||
# first press of the key, and it stays up for the rest of the session.
|
||||
|
||||
[Unit]
|
||||
Description=Panama dictation speech-to-text server
|
||||
Documentation=https://github.com/ggml-org/whisper.cpp
|
||||
|
||||
[Container]
|
||||
Image=ghcr.io/ggml-org/whisper.cpp:main-vulkan
|
||||
|
||||
# The image's own entrypoint is a shell; the server is what is wanted.
|
||||
Entrypoint=/app/build/bin/whisper-server
|
||||
Exec=--host 0.0.0.0 --port 8791 --model /models/ggml-small.bin --inference-path /inference
|
||||
|
||||
# The whole directory rather than a renderD node: the number differs between
|
||||
# machines, and this file is meant to be identical on all of them.
|
||||
AddDevice=/dev/dri
|
||||
|
||||
# The model is host state -- fetched once, kept across image updates, and shared
|
||||
# with nothing else. Read-only because the server never writes to it, and :z
|
||||
# relabels for SELinux, which is enforcing on Fedora.
|
||||
Volume=%h/.local/share/panama/whisper:/models:ro,z
|
||||
|
||||
# Loopback only. This transcribes whatever it is sent, with no authentication,
|
||||
# and has no business being reachable from the network.
|
||||
PublishPort=127.0.0.1:8791:8791
|
||||
|
||||
NoNewPrivileges=true
|
||||
|
||||
[Service]
|
||||
# Loading the model takes a few seconds on a cold start; systemd should wait for
|
||||
# it rather than give up, and should bring the server back if it dies mid-session.
|
||||
TimeoutStartSec=180
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
@@ -1,8 +0,0 @@
|
||||
# see `man dnf.conf` for defaults and possible options
|
||||
|
||||
[main]
|
||||
fastestmirror=True
|
||||
max_parallel_downloads=10
|
||||
defaultyes=True
|
||||
keepcache=True
|
||||
deltarpm=True
|
||||
@@ -0,0 +1,8 @@
|
||||
# A stray press of the power button should ask, not act. Fedora's logind
|
||||
# default is HandlePowerKey=poweroff -- an instant, unconfirmed shutdown.
|
||||
# Panama binds the key in the compositor to open its power menu instead
|
||||
# (keybinds.lua), the way GNOME turns the key into a dialog, so logind is
|
||||
# told to stand down. Holding the button still hard-cuts through firmware,
|
||||
# which remains the escape hatch this file cannot and should not remove.
|
||||
[Login]
|
||||
HandlePowerKey=ignore
|
||||
@@ -0,0 +1,35 @@
|
||||
# Clear the mountpoint before starting, so a start that follows a crash lands
|
||||
# on clean ground.
|
||||
#
|
||||
# This service provides the fuse mount at /run/user/$UID/doc, and bwrap binds
|
||||
# doc/by-app/<id> into every flatpak sandbox it builds. No mount, no sandbox,
|
||||
# no application -- all of them, not one, because they all share this.
|
||||
#
|
||||
# The shipped unit is Type=dbus with Restart=no, so nothing restarts it on
|
||||
# failure: it comes back only when something next calls its bus name. On this
|
||||
# machine that was a flatpak launching two seconds after the daemon exited
|
||||
# 21, and the activation raced the dying instance's fuse state and came up
|
||||
# with no mount at all. systemctl then reports active (running) -- the
|
||||
# fusermount3 helper is even still there as a child -- while every new sandbox
|
||||
# fails. Nothing running at the time notices, because a sandbox needs the
|
||||
# mount only while it is being built, so the breakage surfaces whenever you
|
||||
# next open something you had not opened yet. Here that was three days later,
|
||||
# and it was the second time: the first was written off as a one-off.
|
||||
#
|
||||
# `-` because a clean start has nothing to unmount and fusermount3 exits 1
|
||||
# saying so, which is not a failure to start on.
|
||||
#
|
||||
# The unmount alone did not close the hole: the third recurrence (2026-08-26)
|
||||
# started on ground the ExecStartPre had cleaned and still came up with no
|
||||
# mount, sitting active-and-broken for fourteen hours until the next fresh
|
||||
# flatpak launch found it. Hence ExecStartPost: the unit is not "started"
|
||||
# until the mount it exists to provide actually exists. Type=dbus declares
|
||||
# readiness at bus-name acquisition, which can land before the fuse thread
|
||||
# has the mount up, so this polls rather than checks once. If five seconds
|
||||
# pass without a mount, the start fails -- loudly, in the journal, as failed
|
||||
# -- and the next bus activation retries from the ExecStartPre cleanup
|
||||
# instead of every sandbox build failing against a unit that claims to be
|
||||
# fine.
|
||||
[Service]
|
||||
ExecStartPre=-/usr/bin/fusermount3 -u /run/user/%U/doc
|
||||
ExecStartPost=/usr/bin/timeout 5 /bin/sh -c 'until /usr/bin/mountpoint -q /run/user/%U/doc; do /usr/bin/sleep 0.2; done'
|
||||
@@ -0,0 +1,9 @@
|
||||
# External monitor brightness (DDC/CI) on GPUs that are not "VGA".
|
||||
#
|
||||
# ddcutil ships 60-ddcutil-i2c.rules, which grants the seated user access to
|
||||
# the GPU's i2c buses -- but only when the GPU's PCI class is 0x030000 (VGA
|
||||
# compatible controller). An AMD iGPU that is not the primary boot display
|
||||
# enumerates as 0x038000 (Display controller) instead, so every DDC bus it
|
||||
# exposes stays root-only and the Brightness service reads EACCES. Same grant,
|
||||
# broadened to the class that hardware actually reports.
|
||||
SUBSYSTEM=="i2c-dev", KERNEL=="i2c-[0-9]*", ATTRS{class}=="0x038000", TAG+="uaccess"
|
||||
@@ -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()
|
||||
@@ -2,7 +2,7 @@
|
||||
#
|
||||
# btop ships no Tokyo Night light variant at all, and the nearest stock light
|
||||
# theme (flat-remix-light) is a different palette that happens to have a similar
|
||||
# background. Same colours as kitty/themes/tokyonight-day.conf so the terminal
|
||||
# background. Same colors as kitty/themes/tokyonight-day.conf so the terminal
|
||||
# and what runs inside it cannot disagree.
|
||||
#
|
||||
# Gradients keep the same low -> middle -> high meaning as the dark theme, using
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
# espanso match file
|
||||
# https://espanso.org/docs/
|
||||
# Name and email triggers (:fn, :em, ...) are NOT here: they are personal, and
|
||||
# this file is shared. setup-identity seeds them into match/identity.yml --
|
||||
# per-machine, gitignored, yours to edit -- from the install interview.
|
||||
matches:
|
||||
# Name
|
||||
- trigger: ":fn"
|
||||
replace: "Gabriel Brown"
|
||||
- trigger: ":fin"
|
||||
replace: "Gabriel A Brown"
|
||||
# Email
|
||||
- trigger: ":em"
|
||||
replace: "[email protected]"
|
||||
- trigger: ":empro"
|
||||
replace: "[email protected]"
|
||||
|
||||
# Date
|
||||
- trigger: ":date"
|
||||
replace: "{{mydate}}"
|
||||
@@ -147,18 +139,6 @@ matches:
|
||||
- trigger: "dokcer"
|
||||
replace: "docker"
|
||||
|
||||
- trigger: "gborwn"
|
||||
replace: "gbrown"
|
||||
|
||||
- trigger: "gbrwon"
|
||||
replace: "gbrown"
|
||||
|
||||
- trigger: "gbronw"
|
||||
replace: "gbrown"
|
||||
|
||||
- trigger: "grbown"
|
||||
replace: "gbrown"
|
||||
|
||||
- trigger: "restrat"
|
||||
replace: "restart"
|
||||
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
{
|
||||
"overrides": [
|
||||
{
|
||||
"wmClass": "jetbrains-toolbox",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Com.github.amezin.ddterm",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Com.github.donadigo.eddy",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Conky",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Gnome-initial-setup",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "org.gnome.Calculator",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "gnome-terminal-preferences",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Guake",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "zoom",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "mpv",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Bitwarden",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Hidamari",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "com.mattjakeman.ExtensionManager",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Cider",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Ulauncher",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "com.nextcloud.desktopclient.nextcloud",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Emulator",
|
||||
"mode": "float"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
{
|
||||
"overrides": [
|
||||
{
|
||||
"wmClass": "jetbrains-toolbox",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Com.github.amezin.ddterm",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Com.github.donadigo.eddy",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Conky",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Gnome-initial-setup",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "org.gnome.Calculator",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "gnome-terminal-preferences",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Guake",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "zoom",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "mpv",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Bitwarden",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Hidamari",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "com.mattjakeman.ExtensionManager",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Cider",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Ulauncher",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "com.nextcloud.desktopclient.nextcloud",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Emulator",
|
||||
"mode": "float"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
{
|
||||
"overrides": [
|
||||
{
|
||||
"wmClass": "jetbrains-toolbox",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Com.github.amezin.ddterm",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Com.github.donadigo.eddy",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Conky",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Gnome-initial-setup",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "org.gnome.Calculator",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "gnome-terminal-preferences",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Guake",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "zoom",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "mpv",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Bitwarden",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Hidamari",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "com.mattjakeman.ExtensionManager",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Cider",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Ulauncher",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "com.nextcloud.desktopclient.nextcloud",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Emulator",
|
||||
"mode": "float"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
{
|
||||
"overrides": [
|
||||
{
|
||||
"wmClass": "jetbrains-toolbox",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Com.github.amezin.ddterm",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Com.github.donadigo.eddy",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Conky",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Gnome-initial-setup",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "org.gnome.Calculator",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "gnome-terminal-preferences",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Guake",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "zoom",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "mpv",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Bitwarden",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Hidamari",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "com.mattjakeman.ExtensionManager",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Cider",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Ulauncher",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "com.nextcloud.desktopclient.nextcloud",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Emulator",
|
||||
"mode": "float"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
{
|
||||
"overrides": [
|
||||
{
|
||||
"wmClass": "jetbrains-toolbox",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Com.github.amezin.ddterm",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Com.github.donadigo.eddy",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Conky",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Gnome-initial-setup",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "org.gnome.Calculator",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "gnome-terminal-preferences",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Guake",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "zoom",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "mpv",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Bitwarden",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Hidamari",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "com.mattjakeman.ExtensionManager",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Cider",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Ulauncher",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "com.nextcloud.desktopclient.nextcloud",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Emulator",
|
||||
"mode": "float"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
{
|
||||
"overrides": [
|
||||
{
|
||||
"wmClass": "jetbrains-toolbox",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Com.github.amezin.ddterm",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Com.github.donadigo.eddy",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Conky",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Gnome-initial-setup",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "org.gnome.Calculator",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "gnome-terminal-preferences",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Guake",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "zoom",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "mpv",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Bitwarden",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Hidamari",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "com.mattjakeman.ExtensionManager",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Cider",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Ulauncher",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "com.nextcloud.desktopclient.nextcloud",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Emulator",
|
||||
"mode": "float"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
{
|
||||
"overrides": [
|
||||
{
|
||||
"wmClass": "jetbrains-toolbox",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Com.github.amezin.ddterm",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Com.github.donadigo.eddy",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Conky",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Gnome-initial-setup",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "org.gnome.Calculator",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "gnome-terminal-preferences",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Guake",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "zoom",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Bitwarden",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Hidamari",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "com.mattjakeman.ExtensionManager",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Cider",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Ulauncher",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "com.nextcloud.desktopclient.nextcloud",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "mpv",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "Spotify",
|
||||
"mode": "float"
|
||||
},
|
||||
{
|
||||
"wmClass": "bitwarden",
|
||||
"mode": "float"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
.tiled {
|
||||
color: rgba(236, 94, 94, 1);
|
||||
opacity: 1;
|
||||
border-width: 3px;
|
||||
}
|
||||
|
||||
.split {
|
||||
color: rgba(255, 246, 108, 1);
|
||||
opacity: 1;
|
||||
border-width: 3px;
|
||||
}
|
||||
|
||||
.stacked {
|
||||
color: rgba(247, 162, 43, 1);
|
||||
opacity: 1;
|
||||
border-width: 3px;
|
||||
}
|
||||
|
||||
.tabbed {
|
||||
color: rgba(17, 199, 224, 1);
|
||||
opacity: 1;
|
||||
border-width: 3px;
|
||||
}
|
||||
|
||||
.floated {
|
||||
color: rgba(180, 167, 214, 1);
|
||||
border-width: 3px;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.window-tiled-border {
|
||||
border-width: 3px;
|
||||
border-color: rgb(98,160,234);
|
||||
border-style: solid;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.window-split-border {
|
||||
border-width: 3px;
|
||||
border-color: rgb(98,160,234);
|
||||
border-style: solid;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
/* forge-36le: St does not paint an asymmetric (single-side) border when a
|
||||
* border-radius is set, so the directional hint never rendered. Drop the radius
|
||||
* on the one-edge variants; St paints a straight single-side border reliably. */
|
||||
|
||||
.window-split-horizontal {
|
||||
border-left-width: 0;
|
||||
border-top-width: 0;
|
||||
border-bottom-width: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.window-split-vertical {
|
||||
border-left-width: 0;
|
||||
border-top-width: 0;
|
||||
border-right-width: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.window-stacked-border {
|
||||
border-width: 3px;
|
||||
border-color: rgb(98,160,234);
|
||||
border-style: solid;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.window-tabbed-border {
|
||||
border-width: 3px;
|
||||
border-color: rgb(98,160,234);
|
||||
border-style: solid;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.window-tabbed-bg {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.window-tabbed-tab {
|
||||
background-color: rgba(54, 47, 45, 1);
|
||||
border-color: rgba(98,160,234,0.6);
|
||||
border-width: 1px;
|
||||
border-radius: 8px;
|
||||
color: white;
|
||||
margin: 1px;
|
||||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.window-tabbed-tab-active {
|
||||
background-color: rgb(98,160,234);
|
||||
color: black;
|
||||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.window-tabbed-tab-close {
|
||||
padding: 3px;
|
||||
margin: 4px;
|
||||
border-radius: 16px;
|
||||
width: 16px;
|
||||
background-color: #e06666;
|
||||
}
|
||||
|
||||
.window-tabbed-tab-icon {
|
||||
margin: 3px;
|
||||
}
|
||||
|
||||
.window-floated-border {
|
||||
border-width: 3px;
|
||||
border-color: rgb(98,160,234);
|
||||
border-style: solid;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.window-tilepreview-tiled {
|
||||
border-width: 1px;
|
||||
border-color: rgba(98,160,234,0.3);
|
||||
border-style: solid;
|
||||
border-radius: 14px;
|
||||
background-color: rgba(98,160,234,0.2);
|
||||
}
|
||||
|
||||
.window-tilepreview-stacked {
|
||||
border-width: 1px;
|
||||
border-color: rgba(98,160,234,0.3);
|
||||
border-style: solid;
|
||||
border-radius: 14px;
|
||||
background-color: rgba(98,160,234,0.2);
|
||||
}
|
||||
|
||||
.window-tilepreview-swap {
|
||||
border-width: 1px;
|
||||
border-color: rgba(162, 247, 43, 0.4);
|
||||
border-style: solid;
|
||||
border-radius: 14px;
|
||||
background-color: rgba(162, 247, 43, 0.4);
|
||||
}
|
||||
|
||||
.window-tilepreview-tabbed {
|
||||
border-width: 1px;
|
||||
border-color: rgba(98,160,234,0.3);
|
||||
border-style: solid;
|
||||
border-radius: 14px;
|
||||
background-color: rgba(98,160,234,0.2);
|
||||
}
|
||||
|
||||
/* Keybindings Cheatsheet Overlay */
|
||||
|
||||
.forge-cheatsheet {
|
||||
background-color: #2d2d2d;
|
||||
border-radius: 16px;
|
||||
padding: 24px 32px;
|
||||
border: 1px solid #4d4d4d;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.forge-cheatsheet-title {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: #ffffff;
|
||||
margin-bottom: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.forge-cheatsheet-category {
|
||||
font-size: 13px;
|
||||
font-weight: bold;
|
||||
color: #78aeed;
|
||||
margin-top: 16px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.forge-cheatsheet-row {
|
||||
font-size: 12px;
|
||||
color: #eeeeec;
|
||||
margin: 4px 0;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.forge-cheatsheet-key {
|
||||
font-family: monospace;
|
||||
font-weight: bold;
|
||||
color: #f5c211;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.forge-cheatsheet-desc {
|
||||
color: #d0cfcc;
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
.tiled {
|
||||
color: rgba(236, 94, 94, 1);
|
||||
opacity: 1;
|
||||
border-width: 3px;
|
||||
}
|
||||
|
||||
.split {
|
||||
color: rgba(255, 246, 108, 1);
|
||||
opacity: 1;
|
||||
border-width: 3px;
|
||||
}
|
||||
|
||||
.stacked {
|
||||
color: rgba(247, 162, 43, 1);
|
||||
opacity: 1;
|
||||
border-width: 3px;
|
||||
}
|
||||
|
||||
.tabbed {
|
||||
color: rgba(17, 199, 224, 1);
|
||||
opacity: 1;
|
||||
border-width: 3px;
|
||||
}
|
||||
|
||||
.floated {
|
||||
color: rgba(180, 167, 214, 1);
|
||||
border-width: 3px;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.window-tiled-border {
|
||||
border-width: 3px;
|
||||
border-color: rgb(98,160,234);
|
||||
border-style: solid;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.window-split-border {
|
||||
border-width: 3px;
|
||||
border-color: rgb(98,160,234);
|
||||
border-style: solid;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
/* forge-36le: St does not paint an asymmetric (single-side) border when a
|
||||
* border-radius is set, so the directional hint never rendered. Drop the radius
|
||||
* on the one-edge variants; St paints a straight single-side border reliably. */
|
||||
|
||||
.window-split-horizontal {
|
||||
border-left-width: 0;
|
||||
border-top-width: 0;
|
||||
border-bottom-width: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.window-split-vertical {
|
||||
border-left-width: 0;
|
||||
border-top-width: 0;
|
||||
border-right-width: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.window-stacked-border {
|
||||
border-width: 3px;
|
||||
border-color: rgb(98,160,234);
|
||||
border-style: solid;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.window-tabbed-border {
|
||||
border-width: 3px;
|
||||
border-color: rgb(98,160,234);
|
||||
border-style: solid;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.window-tabbed-bg {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.window-tabbed-tab {
|
||||
background-color: rgba(54, 47, 45, 1);
|
||||
border-color: rgba(98,160,234,0.6);
|
||||
border-width: 1px;
|
||||
border-radius: 8px;
|
||||
color: white;
|
||||
margin: 1px;
|
||||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.window-tabbed-tab-active {
|
||||
background-color: rgb(98,160,234);
|
||||
color: black;
|
||||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.window-tabbed-tab-close {
|
||||
padding: 3px;
|
||||
margin: 4px;
|
||||
border-radius: 16px;
|
||||
width: 16px;
|
||||
background-color: #e06666;
|
||||
}
|
||||
|
||||
.window-tabbed-tab-icon {
|
||||
margin: 3px;
|
||||
}
|
||||
|
||||
.window-floated-border {
|
||||
border-width: 1px;
|
||||
border-color: rgb(98,160,234);
|
||||
border-style: solid;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.window-tilepreview-tiled {
|
||||
border-width: 1px;
|
||||
border-color: rgba(98,160,234,0.3);
|
||||
border-style: solid;
|
||||
border-radius: 14px;
|
||||
background-color: rgba(98,160,234,0.2);
|
||||
}
|
||||
|
||||
.window-tilepreview-stacked {
|
||||
border-width: 1px;
|
||||
border-color: rgba(98,160,234,0.3);
|
||||
border-style: solid;
|
||||
border-radius: 14px;
|
||||
background-color: rgba(98,160,234,0.2);
|
||||
}
|
||||
|
||||
.window-tilepreview-swap {
|
||||
border-width: 1px;
|
||||
border-color: rgba(162, 247, 43, 0.4);
|
||||
border-style: solid;
|
||||
border-radius: 14px;
|
||||
background-color: rgba(162, 247, 43, 0.4);
|
||||
}
|
||||
|
||||
.window-tilepreview-tabbed {
|
||||
border-width: 1px;
|
||||
border-color: rgba(98,160,234,0.3);
|
||||
border-style: solid;
|
||||
border-radius: 14px;
|
||||
background-color: rgba(98,160,234,0.2);
|
||||
}
|
||||
|
||||
/* Keybindings Cheatsheet Overlay */
|
||||
|
||||
.forge-cheatsheet {
|
||||
background-color: #2d2d2d;
|
||||
border-radius: 16px;
|
||||
padding: 24px 32px;
|
||||
border: 1px solid #4d4d4d;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.forge-cheatsheet-title {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: #ffffff;
|
||||
margin-bottom: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.forge-cheatsheet-category {
|
||||
font-size: 13px;
|
||||
font-weight: bold;
|
||||
color: #78aeed;
|
||||
margin-top: 16px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.forge-cheatsheet-row {
|
||||
font-size: 12px;
|
||||
color: #eeeeec;
|
||||
margin: 4px 0;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.forge-cheatsheet-key {
|
||||
font-family: monospace;
|
||||
font-weight: bold;
|
||||
color: #f5c211;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.forge-cheatsheet-desc {
|
||||
color: #d0cfcc;
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
file:///home/gib/Documents Documents
|
||||
file:///home/gib/Documents/Code Code
|
||||
file:///home/gib/Downloads Downloads
|
||||
file:///home/gib/Music Music
|
||||
file:///home/gib/Pictures Pictures
|
||||
file:///home/gib/Videos Videos
|
||||
file:///home/gib/.local/share/Panama Panama
|
||||
smb://server-gib.local/homes/ Gib's Server
|
||||
@@ -0,0 +1,6 @@
|
||||
file://$HOME/Documents Documents
|
||||
file://$HOME/Downloads Downloads
|
||||
file://$HOME/Music Music
|
||||
file://$HOME/Pictures Pictures
|
||||
file://$HOME/Videos Videos
|
||||
file://$HOME/.local/share/Panama Panama
|
||||
+60
-33
@@ -1,36 +1,63 @@
|
||||
@define-color accent_color rgba(192,153,255,0.8);
|
||||
@define-color accent_bg_color rgba(130,170,255,0.8);
|
||||
@define-color accent_fg_color #ffffff;
|
||||
@define-color destructive_color #ff7b63;
|
||||
@define-color destructive_bg_color #c01c28;
|
||||
@define-color destructive_fg_color #ffffff;
|
||||
@define-color success_color #8ff0a4;
|
||||
@define-color success_bg_color #26a269;
|
||||
@define-color success_fg_color #ffffff;
|
||||
@define-color warning_color #f8e45c;
|
||||
@define-color warning_bg_color #cd9309;
|
||||
@define-color warning_fg_color rgba(0, 0, 0, 0.8);
|
||||
@define-color error_color #ff7b63;
|
||||
@define-color error_bg_color #c01c28;
|
||||
@define-color error_fg_color #ffffff;
|
||||
@define-color window_bg_color rgba(46,47,61,0.98);
|
||||
@define-color window_fg_color #ffffff;
|
||||
@define-color view_bg_color rgba(46,47,61,0.98);
|
||||
@define-color view_fg_color #ffffff;
|
||||
@define-color headerbar_bg_color rgba(46,47,61,0.98);
|
||||
@define-color headerbar_fg_color #ffffff;
|
||||
@define-color headerbar_border_color #ffffff;
|
||||
@define-color headerbar_backdrop_color @window_bg_color;
|
||||
@define-color headerbar_shade_color rgba(46,47,61,0.05);
|
||||
@define-color card_bg_color rgba(180, 200, 255, 0.15);
|
||||
@define-color card_fg_color #ffffff;
|
||||
@define-color card_shade_color rgba(0, 0, 0, 0.36);
|
||||
@define-color dialog_bg_color rgba(46,47,61,0.98);
|
||||
@define-color dialog_fg_color #ffffff;
|
||||
@define-color popover_bg_color rgba(46,47,61,0.98);
|
||||
@define-color popover_fg_color #ffffff;
|
||||
@define-color shade_color rgba(0, 0, 0, 0);
|
||||
@define-color scrollbar_outline_color rgba(0, 0, 0, 0.5);
|
||||
/* Panama regenerates the block below from the active theme's palette, and only
|
||||
* the block: every rule outside the markers belongs to the vendored theme and
|
||||
* is never touched. Edit the theme, not this file -- the next theme change
|
||||
* overwrites whatever is between the markers. The values shipped here are Tokyo
|
||||
* Moon, the default dark theme, so the file is valid before it has ever been
|
||||
* rewritten. See quickshell/scripts/panama-theme-apps. */
|
||||
/* PANAMA THEME BEGIN */
|
||||
/* Generated from the "moon" theme by panama-theme-apps. Edit the
|
||||
* theme, not this block -- the next theme change overwrites it. */
|
||||
@define-color accent_bg_color #82aaff;
|
||||
@define-color accent_color #65bcff;
|
||||
@define-color accent_fg_color #1e2030;
|
||||
@define-color destructive_bg_color #c53b53;
|
||||
@define-color destructive_color #ff757f;
|
||||
@define-color destructive_fg_color #c8d3f5;
|
||||
@define-color success_bg_color #c3e88d;
|
||||
@define-color success_color #c3e88d;
|
||||
@define-color success_fg_color #1e2030;
|
||||
@define-color warning_bg_color #ffc777;
|
||||
@define-color warning_color #ffc777;
|
||||
@define-color warning_fg_color #1e2030;
|
||||
@define-color error_bg_color #c53b53;
|
||||
@define-color error_color #ff757f;
|
||||
@define-color error_fg_color #c8d3f5;
|
||||
@define-color window_bg_color #2e2f3d;
|
||||
@define-color window_fg_color #c8d3f5;
|
||||
@define-color view_bg_color #222436;
|
||||
@define-color view_fg_color #c8d3f5;
|
||||
@define-color headerbar_bg_color #2e2f3d;
|
||||
@define-color headerbar_fg_color #c8d3f5;
|
||||
@define-color headerbar_border_color #3b4261;
|
||||
@define-color headerbar_backdrop_color #1e2030;
|
||||
@define-color headerbar_shade_color rgba(30, 32, 48, 0.08);
|
||||
@define-color headerbar_darker_shade_color rgba(30, 32, 48, 0.9);
|
||||
@define-color sidebar_bg_color #1e2030;
|
||||
@define-color sidebar_fg_color #c8d3f5;
|
||||
@define-color sidebar_backdrop_color #1e2030;
|
||||
@define-color sidebar_shade_color rgba(59, 66, 97, 0.08);
|
||||
@define-color sidebar_border_color rgba(59, 66, 97, 0.36);
|
||||
@define-color secondary_sidebar_bg_color #1e2030;
|
||||
@define-color secondary_sidebar_fg_color #c8d3f5;
|
||||
@define-color secondary_sidebar_backdrop_color #1e2030;
|
||||
@define-color secondary_sidebar_shade_color rgba(30, 32, 48, 0.25);
|
||||
@define-color secondary_sidebar_border_color rgba(59, 66, 97, 0.36);
|
||||
@define-color card_bg_color #2f334d;
|
||||
@define-color card_fg_color #c8d3f5;
|
||||
@define-color card_shade_color rgba(30, 32, 48, 0.25);
|
||||
@define-color dialog_bg_color #21212f;
|
||||
@define-color dialog_fg_color #c8d3f5;
|
||||
@define-color popover_bg_color #21212f;
|
||||
@define-color popover_fg_color #c8d3f5;
|
||||
@define-color popover_shade_color rgba(30, 32, 48, 0.25);
|
||||
@define-color thumbnail_bg_color #2f334d;
|
||||
@define-color thumbnail_fg_color #c8d3f5;
|
||||
@define-color shade_color rgba(30, 32, 48, 0.25);
|
||||
@define-color scrollbar_outline_color rgba(30, 32, 48, 0.5);
|
||||
/* PANAMA THEME END */
|
||||
|
||||
/* The GNOME named palette below is reference, not theme: applications ask for
|
||||
* @red_3 by name and expect GNOME's red. It stays outside the block. */
|
||||
@define-color blue_1 #99c1f1;
|
||||
@define-color blue_2 #62a0ea;
|
||||
@define-color blue_3 #3584e4;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# GENERATED FILE -- edit settings.ini.template instead.
|
||||
#
|
||||
# The theme name and dark preference below follow Panama's colour
|
||||
# The theme name and dark preference below follow Panama's color
|
||||
# scheme, so this file is regenerated on every switch and is not
|
||||
# committed. Under GNOME, gnome-settings-daemon publishes these over
|
||||
# XSETTINGS and this file is ignored; under Hyprland there is no
|
||||
|
||||
Executable → Regular
+58
-47
@@ -1,53 +1,64 @@
|
||||
/* GTK NAMED COLORS ---------------- use responsibly! */
|
||||
@define-color accent_bg_color RGB(130 170 255 / 80%);
|
||||
|
||||
@define-color destructive_bg_color #c01c28;
|
||||
@define-color destructive_fg_color white;
|
||||
@define-color success_bg_color #26a269;
|
||||
@define-color success_fg_color white;
|
||||
@define-color warning_bg_color #cd9309;
|
||||
@define-color warning_fg_color RGB(0 0 0 / 80%);
|
||||
@define-color error_bg_color #c01c28;
|
||||
@define-color error_fg_color white;
|
||||
@define-color accent_color oklab(from @accent_bg_color max(l, 0.85) a b);
|
||||
@define-color destructive_color oklab(from @destructive_bg_color max(l, 0.85) a b);
|
||||
@define-color success_color oklab(from @success_bg_color max(l, 0.85) a b);
|
||||
@define-color warning_color oklab(from @warning_bg_color max(l, 0.85) a b);
|
||||
@define-color error_color oklab(from @error_bg_color max(l, 0.85) a b);
|
||||
|
||||
/* Panama regenerates the block below from the active theme's palette, and only
|
||||
* the block: every rule outside the markers belongs to the vendored theme and
|
||||
* is never touched. Edit the theme, not this file -- the next theme change
|
||||
* overwrites whatever is between the markers. The values shipped here are Tokyo
|
||||
* Moon, the default dark theme, so the file is valid before it has ever been
|
||||
* rewritten. See quickshell/scripts/panama-theme-apps. */
|
||||
/* PANAMA THEME BEGIN */
|
||||
/* Generated from the "moon" theme by panama-theme-apps. Edit the
|
||||
* theme, not this block -- the next theme change overwrites it. */
|
||||
@define-color accent_bg_color #82aaff;
|
||||
@define-color accent_color #65bcff;
|
||||
@define-color accent_fg_color #1e2030;
|
||||
@define-color destructive_bg_color #c53b53;
|
||||
@define-color destructive_color #ff757f;
|
||||
@define-color destructive_fg_color #c8d3f5;
|
||||
@define-color success_bg_color #c3e88d;
|
||||
@define-color success_color #c3e88d;
|
||||
@define-color success_fg_color #1e2030;
|
||||
@define-color warning_bg_color #ffc777;
|
||||
@define-color warning_color #ffc777;
|
||||
@define-color warning_fg_color #1e2030;
|
||||
@define-color error_bg_color #c53b53;
|
||||
@define-color error_color #ff757f;
|
||||
@define-color error_fg_color #c8d3f5;
|
||||
@define-color window_bg_color #2e2f3d;
|
||||
@define-color window_fg_color white;
|
||||
@define-color view_bg_color @window_bg_color
|
||||
@define-color view_fg_color white;
|
||||
@define-color headerbar_bg_color @window_bg_color
|
||||
@define-color headerbar_fg_color white;
|
||||
@define-color headerbar_border_color white;
|
||||
@define-color headerbar_backdrop_color @window_bg_color;
|
||||
@define-color headerbar_shade_color RGB(46 47 61 / 8%);
|
||||
@define-color headerbar_darker_shade_color RGB(0, 0, 6, 0.9);
|
||||
@define-color window_fg_color #c8d3f5;
|
||||
@define-color view_bg_color #222436;
|
||||
@define-color view_fg_color #c8d3f5;
|
||||
@define-color headerbar_bg_color #2e2f3d;
|
||||
@define-color headerbar_fg_color #c8d3f5;
|
||||
@define-color headerbar_border_color #3b4261;
|
||||
@define-color headerbar_backdrop_color #1e2030;
|
||||
@define-color headerbar_shade_color rgba(30, 32, 48, 0.08);
|
||||
@define-color headerbar_darker_shade_color rgba(30, 32, 48, 0.9);
|
||||
@define-color sidebar_bg_color #1e2030;
|
||||
@define-color sidebar_fg_color #c8d3f5;
|
||||
@define-color sidebar_backdrop_color #1e2030;
|
||||
@define-color sidebar_shade_color rgba(59, 66, 97, 0.08);
|
||||
@define-color sidebar_border_color rgba(59, 66, 97, 0.36);
|
||||
@define-color secondary_sidebar_bg_color #1e2030;
|
||||
@define-color secondary_sidebar_fg_color #c8d3f5;
|
||||
@define-color secondary_sidebar_backdrop_color #1e2030;
|
||||
@define-color secondary_sidebar_shade_color rgba(30, 32, 48, 0.25);
|
||||
@define-color secondary_sidebar_border_color rgba(59, 66, 97, 0.36);
|
||||
@define-color card_bg_color #2f334d;
|
||||
@define-color card_fg_color #c8d3f5;
|
||||
@define-color card_shade_color rgba(30, 32, 48, 0.25);
|
||||
@define-color dialog_bg_color #21212f;
|
||||
@define-color dialog_fg_color #c8d3f5;
|
||||
@define-color popover_bg_color #21212f;
|
||||
@define-color popover_fg_color #c8d3f5;
|
||||
@define-color popover_shade_color rgba(30, 32, 48, 0.25);
|
||||
@define-color thumbnail_bg_color #2f334d;
|
||||
@define-color thumbnail_fg_color #c8d3f5;
|
||||
@define-color shade_color rgba(30, 32, 48, 0.25);
|
||||
@define-color scrollbar_outline_color rgba(30, 32, 48, 0.5);
|
||||
/* PANAMA THEME END */
|
||||
|
||||
@define-color sidebar_bg_color #3c3c4b;
|
||||
@define-color sidebar_fg_color white;
|
||||
@define-color sidebar_backdrop_color #3c3c4b;
|
||||
@define-color sidebar_shade_color RGB(60 60 75/8%);
|
||||
@define-color sidebar_border_color RGB(75 75 90/36%);
|
||||
@define-color secondary_sidebar_bg_color #3c3c4b;
|
||||
@define-color secondary_sidebar_fg_color white;
|
||||
@define-color secondary_sidebar_backdrop_color #3c3c4b;
|
||||
@define-color secondary_sidebar_shade_color RGB(0 0 6/25%);
|
||||
@define-color secondary_sidebar_border_color RGB(0 0 6/36%);
|
||||
@define-color card_bg_color RGB(180 200 255/15%);
|
||||
@define-color card_fg_color white;
|
||||
@define-color card_shade_color RGB(46 47 61/8%);
|
||||
@define-color dialog_bg_color @window_bg_color;
|
||||
@define-color dialog_fg_color white;
|
||||
@define-color popover_bg_color @window_bg_color;
|
||||
@define-color popover_fg_color white;
|
||||
@define-color popover_shade_color RGB(0 0 6/25%);
|
||||
@define-color thumbnail_bg_color @window_bg_color;
|
||||
@define-color thumbnail_fg_color white;
|
||||
@define-color shade_color RGB(0 0 6/25%);
|
||||
@define-color scrollbar_outline_color RGB(0 0 12/95%);
|
||||
/* The GNOME named palette below is reference, not theme: applications ask for
|
||||
* @red_3 by name and expect GNOME's red. It stays outside the block. */
|
||||
@define-color blue_1 #99c1f1;
|
||||
@define-color blue_2 #62a0ea;
|
||||
@define-color blue_3 #3584e4;
|
||||
|
||||
Executable → Regular
+58
-47
@@ -1,53 +1,64 @@
|
||||
/* GTK NAMED COLORS ---------------- use responsibly! */
|
||||
@define-color accent_bg_color RGB(130 170 255 / 80%);
|
||||
|
||||
@define-color destructive_bg_color #c01c28;
|
||||
@define-color destructive_fg_color white;
|
||||
@define-color success_bg_color #26a269;
|
||||
@define-color success_fg_color white;
|
||||
@define-color warning_bg_color #cd9309;
|
||||
@define-color warning_fg_color RGB(0 0 0 / 80%);
|
||||
@define-color error_bg_color #c01c28;
|
||||
@define-color error_fg_color white;
|
||||
@define-color accent_color oklab(from @accent_bg_color max(l, 0.85) a b);
|
||||
@define-color destructive_color oklab(from @destructive_bg_color max(l, 0.85) a b);
|
||||
@define-color success_color oklab(from @success_bg_color max(l, 0.85) a b);
|
||||
@define-color warning_color oklab(from @warning_bg_color max(l, 0.85) a b);
|
||||
@define-color error_color oklab(from @error_bg_color max(l, 0.85) a b);
|
||||
|
||||
/* Panama regenerates the block below from the active theme's palette, and only
|
||||
* the block: every rule outside the markers belongs to the vendored theme and
|
||||
* is never touched. Edit the theme, not this file -- the next theme change
|
||||
* overwrites whatever is between the markers. The values shipped here are Tokyo
|
||||
* Moon, the default dark theme, so the file is valid before it has ever been
|
||||
* rewritten. See quickshell/scripts/panama-theme-apps. */
|
||||
/* PANAMA THEME BEGIN */
|
||||
/* Generated from the "moon" theme by panama-theme-apps. Edit the
|
||||
* theme, not this block -- the next theme change overwrites it. */
|
||||
@define-color accent_bg_color #82aaff;
|
||||
@define-color accent_color #65bcff;
|
||||
@define-color accent_fg_color #1e2030;
|
||||
@define-color destructive_bg_color #c53b53;
|
||||
@define-color destructive_color #ff757f;
|
||||
@define-color destructive_fg_color #c8d3f5;
|
||||
@define-color success_bg_color #c3e88d;
|
||||
@define-color success_color #c3e88d;
|
||||
@define-color success_fg_color #1e2030;
|
||||
@define-color warning_bg_color #ffc777;
|
||||
@define-color warning_color #ffc777;
|
||||
@define-color warning_fg_color #1e2030;
|
||||
@define-color error_bg_color #c53b53;
|
||||
@define-color error_color #ff757f;
|
||||
@define-color error_fg_color #c8d3f5;
|
||||
@define-color window_bg_color #2e2f3d;
|
||||
@define-color window_fg_color white;
|
||||
@define-color view_bg_color @window_bg_color
|
||||
@define-color view_fg_color white;
|
||||
@define-color headerbar_bg_color @window_bg_color
|
||||
@define-color headerbar_fg_color white;
|
||||
@define-color headerbar_border_color white;
|
||||
@define-color headerbar_backdrop_color @window_bg_color;
|
||||
@define-color headerbar_shade_color RGB(46 47 61 / 8%);
|
||||
@define-color headerbar_darker_shade_color RGB(0, 0, 6, 0.9);
|
||||
@define-color window_fg_color #c8d3f5;
|
||||
@define-color view_bg_color #222436;
|
||||
@define-color view_fg_color #c8d3f5;
|
||||
@define-color headerbar_bg_color #2e2f3d;
|
||||
@define-color headerbar_fg_color #c8d3f5;
|
||||
@define-color headerbar_border_color #3b4261;
|
||||
@define-color headerbar_backdrop_color #1e2030;
|
||||
@define-color headerbar_shade_color rgba(30, 32, 48, 0.08);
|
||||
@define-color headerbar_darker_shade_color rgba(30, 32, 48, 0.9);
|
||||
@define-color sidebar_bg_color #1e2030;
|
||||
@define-color sidebar_fg_color #c8d3f5;
|
||||
@define-color sidebar_backdrop_color #1e2030;
|
||||
@define-color sidebar_shade_color rgba(59, 66, 97, 0.08);
|
||||
@define-color sidebar_border_color rgba(59, 66, 97, 0.36);
|
||||
@define-color secondary_sidebar_bg_color #1e2030;
|
||||
@define-color secondary_sidebar_fg_color #c8d3f5;
|
||||
@define-color secondary_sidebar_backdrop_color #1e2030;
|
||||
@define-color secondary_sidebar_shade_color rgba(30, 32, 48, 0.25);
|
||||
@define-color secondary_sidebar_border_color rgba(59, 66, 97, 0.36);
|
||||
@define-color card_bg_color #2f334d;
|
||||
@define-color card_fg_color #c8d3f5;
|
||||
@define-color card_shade_color rgba(30, 32, 48, 0.25);
|
||||
@define-color dialog_bg_color #21212f;
|
||||
@define-color dialog_fg_color #c8d3f5;
|
||||
@define-color popover_bg_color #21212f;
|
||||
@define-color popover_fg_color #c8d3f5;
|
||||
@define-color popover_shade_color rgba(30, 32, 48, 0.25);
|
||||
@define-color thumbnail_bg_color #2f334d;
|
||||
@define-color thumbnail_fg_color #c8d3f5;
|
||||
@define-color shade_color rgba(30, 32, 48, 0.25);
|
||||
@define-color scrollbar_outline_color rgba(30, 32, 48, 0.5);
|
||||
/* PANAMA THEME END */
|
||||
|
||||
@define-color sidebar_bg_color #3c3c4b;
|
||||
@define-color sidebar_fg_color white;
|
||||
@define-color sidebar_backdrop_color #3c3c4b;
|
||||
@define-color sidebar_shade_color RGB(60 60 75/8%);
|
||||
@define-color sidebar_border_color RGB(75 75 90/36%);
|
||||
@define-color secondary_sidebar_bg_color #3c3c4b;
|
||||
@define-color secondary_sidebar_fg_color white;
|
||||
@define-color secondary_sidebar_backdrop_color #3c3c4b;
|
||||
@define-color secondary_sidebar_shade_color RGB(0 0 6/25%);
|
||||
@define-color secondary_sidebar_border_color RGB(0 0 6/36%);
|
||||
@define-color card_bg_color RGB(180 200 255/15%);
|
||||
@define-color card_fg_color white;
|
||||
@define-color card_shade_color RGB(46 47 61/8%);
|
||||
@define-color dialog_bg_color @window_bg_color;
|
||||
@define-color dialog_fg_color white;
|
||||
@define-color popover_bg_color @window_bg_color;
|
||||
@define-color popover_fg_color white;
|
||||
@define-color popover_shade_color RGB(0 0 6/25%);
|
||||
@define-color thumbnail_bg_color @window_bg_color;
|
||||
@define-color thumbnail_fg_color white;
|
||||
@define-color shade_color RGB(0 0 6/25%);
|
||||
@define-color scrollbar_outline_color RGB(0 0 12/95%);
|
||||
/* The GNOME named palette below is reference, not theme: applications ask for
|
||||
* @red_3 by name and expect GNOME's red. It stays outside the block. */
|
||||
@define-color blue_1 #99c1f1;
|
||||
@define-color blue_2 #62a0ea;
|
||||
@define-color blue_3 #3584e4;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# GENERATED FILE -- edit settings.ini.template instead.
|
||||
#
|
||||
# The theme name and dark preference below follow Panama's colour
|
||||
# The theme name and dark preference below follow Panama's color
|
||||
# scheme, so this file is regenerated on every switch and is not
|
||||
# committed. Under GNOME, gnome-settings-daemon publishes these over
|
||||
# XSETTINGS and this file is ignored; under Hyprland there is no
|
||||
|
||||
@@ -15,17 +15,17 @@ Last live audit: 2026-08-17, Fedora 44, Hyprland 0.56.2, Quickshell 0.3.0.
|
||||
| Hidden applications | GNOME App Hider list in Vicinae provider config | Live |
|
||||
| Tiling and dynamic workspaces | Hyprland Lua config, Forge keymap preserved | Live |
|
||||
| Overview | Continuum filmstrip, large workspace preview, cross-workspace search | Live |
|
||||
| Minimise | `SUPER+SHIFT+X` sends to scratchpad; `SUPER+X` restores | Live |
|
||||
| Dock | Quickshell dock with GNOME favourites and intellihide | Live |
|
||||
| Minimize | `SUPER+SHIFT+X` sends to scratchpad; `SUPER+X` restores | Live |
|
||||
| Dock | Quickshell dock with GNOME favorites and intellihide | Live |
|
||||
| Panel | Workspaces, CPU/RAM/GPU, clock, weather, quiet event capsule, media, clipboard, tray, status | Live |
|
||||
| Clipboard history | Vicinae owns capture/storage; Quickshell provides the panel | Live |
|
||||
| Notifications | Quickshell server, toasts, grouped history, DND, clear actions | Live |
|
||||
| Date menu | Two-column Daybook with Agenda, Ongoing activity, Notifications, month calendar, weather, and media | Live |
|
||||
| Ongoing activity | Focus, Caffeine, recording, screen sharing, camera, and microphone state with direct controls | Live |
|
||||
| Calendar agenda | Existing GNOME, Google, iCloud, and Nextcloud sources through Evolution Data Server | Live |
|
||||
| Control Center | Wi-Fi, Bluetooth, audio input/output, Caffeine, Night Light, Focus, Home Assistant favourites, and KDE Connect phone continuity | Live |
|
||||
| Control Center | Wi-Fi, Bluetooth, audio input/output, Caffeine, Night Light, Focus, Home Assistant favorites, and KDE Connect phone continuity | Live |
|
||||
| Focus sessions | Persistent workspace-bound timer, DND/caffeine coordination, Signal Glass controls | Live |
|
||||
| Brightness | Appears only when a backlight device exists | Live, not applicable on this monitor |
|
||||
| Brightness | Appears only when a backlight device exists | Live where the hardware exposes it |
|
||||
| AppIndicator / tray | Native StatusNotifierItem host and themed menus | Live |
|
||||
| Screenshot and recording | Quickshell picker, grim/slurp/satty/wf-recorder | Live |
|
||||
| Screen text and code recognition | Local Tesseract/ZBar Read mode, selectable Prism result sheet | Live after package install |
|
||||
@@ -36,14 +36,37 @@ Last live audit: 2026-08-17, Fedora 44, Hyprland 0.56.2, Quickshell 0.3.0.
|
||||
| Removable media | udiskie plus udisks notifications | Live |
|
||||
| Autostart apps | Nextcloud, Bitwarden, and RustDesk system service/tray | Live |
|
||||
| Printer administration | CUPS with the `system-config-printer` graphical interface | Live |
|
||||
| System settings | The Settings app for display policy, appearance, desktop, sound, focus, shortcuts, and services; labelled GNOME hardware/account handoffs | Live |
|
||||
| System health and recovery | Settings → System Health, `Panama: Check System Health` in Vicinae, a degraded-only bar indicator, redacted reports, and bounded Panama-owned repairs | Live |
|
||||
| System settings | The Settings app for display policy, appearance, desktop, sound, focus, shortcuts, and services; labeled GNOME hardware/account handoffs | Live |
|
||||
| System health and recovery | Settings → System Health, `Check System Health` in Vicinae, a degraded-only bar indicator, redacted reports, and bounded Panama-owned repairs | Live |
|
||||
|
||||
## Portable machines
|
||||
|
||||
Panama was a desktop configuration until 2026-08-22, in the code as well as on
|
||||
the machine: there was no reference to a battery, a lid, or a power source
|
||||
anywhere in it, and `hypridle.conf` said "This is a desktop" in its own header.
|
||||
|
||||
| Capability | Panama implementation | Status |
|
||||
|---|---|---|
|
||||
| Battery level | `services/Battery.qml` reads sysfs the way Vitals reads procfs; bar indicator hides entirely without a battery | Live |
|
||||
| Power source | `panama-hw ac`; a machine with no mains supply reports as on wall power, so a desktop is never treated as running out | Live |
|
||||
| Charge threshold | Power page control, only where the firmware exposes a ceiling; written through `panama-sudo` | Live |
|
||||
| Idle timings per power source | hypridle holds one set at a time, so `panama-idle` rebuilds its config when the charger comes or goes | Live |
|
||||
| Lock on lid close | hypridle's `before_sleep_cmd` already locks, so a lid-close suspend is a locked suspend | Live |
|
||||
| Clamshell / docked | A logind `handle-lid-switch` inhibitor held while an external display is connected. No lid watcher, and a failure returns logind's default | Live, untested on a lid |
|
||||
| Display arrangement on reconnect | `Displays.plannedRestore()` reapplies a confirmed layout, refusing when the stored mode is not offered | Live |
|
||||
| Charger and low-battery notices | `StatusEvents`; the charger is ambient so DND quiets it, critical battery is not | Live |
|
||||
| Hardware predicates | `panama-hw`: laptop, battery, ac, lid-closed, external-monitor, clamshell, touchpad, nvidia | Live |
|
||||
|
||||
The clamshell path is the one row that has not run on hardware with a lid.
|
||||
Everything testable about it is tested; the end-to-end behaviour of closing a
|
||||
real lid on a docked machine is not, and the helper's header says so rather
|
||||
than letting silence imply otherwise.
|
||||
|
||||
## System health and recovery
|
||||
|
||||
Panama stays silent while the desktop is healthy. A compact bar indicator
|
||||
appears only for actionable warnings or errors and opens the same **System
|
||||
Health** page available from Settings and the Vicinae command **Panama: Check
|
||||
Health** page available from Settings and the Vicinae command **Check
|
||||
System Health**. The terminal summary is available with:
|
||||
|
||||
```bash
|
||||
@@ -59,7 +82,7 @@ They never install packages, invoke `sudo`, delete user data, or rewrite
|
||||
arbitrary configuration.
|
||||
|
||||
Generic Fedora configuration remains with the system tools that own it. The
|
||||
final System Health card hands network settings, users, sharing, colour
|
||||
final System Health card hands network settings, users, sharing, color
|
||||
profiles, and digital wellbeing to their exact GNOME Settings panels rather
|
||||
than presenting inert Hyprland controls.
|
||||
|
||||
@@ -77,9 +100,9 @@ than presenting inert Hyprland controls.
|
||||
| Caffeine | Replaced by a real logind inhibitor in quick settings |
|
||||
| Blur My Shell / Openbar / User Theme | Replaced by the Prism shell and compositor blur |
|
||||
| Bluetooth Quick Connect | Replaced by the full Bluetooth picker |
|
||||
| Wi-Fi QR | Deliberately omitted; it is not useful enough to justify another credential-reading surface |
|
||||
| Wi-Fi QR | Replaced by an on-demand QR-code sharing flow in Control Center's Wi-Fi panel; the code is generated only while shown and written to tmpfs, never persisted |
|
||||
| GSConnect | Replaced by capability-aware KDE Connect phone continuity in Control Center; the paired iPhone exposes file, clipboard, and Ring actions when reachable |
|
||||
| Home Assistant | Replaced by secure favourites in Control Center; explicit private environment values take precedence over the existing GNOME extension and Secret Service setup |
|
||||
| Home Assistant | Replaced by secure favorites in Control Center; explicit private environment values take precedence over the existing GNOME extension and Secret Service setup |
|
||||
| Custom Hot Corners Extended | No action was configured, so there is no behavior to port |
|
||||
| App Hider | Hidden app list is now applied in Vicinae |
|
||||
| Allow Locked Remote Desktop | Mutter-specific patch; RustDesk is independent, but lock-screen access needs a real remote test |
|
||||
@@ -91,6 +114,22 @@ a polished general-purpose desktop can go beyond the current shell.
|
||||
|
||||
1. A real external RustDesk test while hyprlock is active. Local process state
|
||||
cannot prove what a remote client sees or whether input is accepted.
|
||||
2. Closing a real lid on a docked laptop. The inhibitor, the predicates and the
|
||||
decision are all tested; the physical gesture is not.
|
||||
3. A keyboard-layout change notice. Hyprland reports the active keymap but not
|
||||
a change event Quickshell already consumes, so this needs either polling or
|
||||
new event plumbing, and a single-layout machine cannot test it.
|
||||
4. Sticky keys, slow keys and bounce keys. Wayland has no protocol for these,
|
||||
so each compositor implements them for itself — mutter does, which is how
|
||||
GNOME has them on Wayland, and Hyprland does not. There is no XKB option to
|
||||
lean on either: the accessx option group is X11-only and does not appear in
|
||||
evdev.lst at all (checked, not assumed — `grep -c accessx
|
||||
/usr/share/X11/xkb/rules/evdev.lst` is 0), and Hyprland will happily store
|
||||
`accessx:enable` as a keyboard option that nothing ever acts on. This is a
|
||||
Hyprland gap rather than a Wayland impossibility, and the Accessibility page
|
||||
says so in those terms; an earlier version of this line blamed X11 and sent
|
||||
anyone who needs sticky keys to the wrong conclusion about the platform.
|
||||
5. An on-screen keyboard, for a touch or convertible machine.
|
||||
|
||||
KDE Connect, the printer UI, Tesseract, and ZBar are installed and remain
|
||||
tracked in `setup/packages/hyprland-packages`.
|
||||
@@ -111,4 +150,4 @@ menu for a minority, which is worse than omitting it. Relevant references:
|
||||
- <https://github.com/hyprwm/Hyprland/discussions/1358>
|
||||
|
||||
A future focused-window menu could expose compositor actions such as float,
|
||||
move, fullscreen, and close. It should not be labelled as an application menu.
|
||||
move, fullscreen, and close. It should not be labeled as an application menu.
|
||||
|
||||
+25
-21
@@ -29,13 +29,15 @@ Don't "fix" them.
|
||||
| `hyprland.lua` | Entry point. Each `require()` is its own error scope |
|
||||
| `prefs.lua` | Reads the settings file the Settings app writes. See below |
|
||||
| `env.lua` | Environment. Note the uwsm caveat below |
|
||||
| `monitors.lua` | DP-2 geometry, scaling, and the HDR decision |
|
||||
| `looks.lua` | Colours, blur, glow, shadows, animations, VRR, scanout |
|
||||
| `monitors.lua` | Monitor geometry and scaling (the Kuycon by description), and the HDR decision |
|
||||
| `looks.lua` | Colors, blur, glow, shadows, animations, VRR, scanout |
|
||||
| `input.lua` | Keyboard/mouse. Click-to-focus, like GNOME |
|
||||
| `rules.lua` | Window rules, gaming rules, layer rules for the shell |
|
||||
| `keybinds.lua` | The full keymap |
|
||||
| `keybinds.lua` | The full keymap, including the custom shortcuts the Settings app stores |
|
||||
| `actions.lua` | Named-action resolver: the whitelist tables that turn stored `{kind, target}` data into binds, gestures — never into free-form commands |
|
||||
| `autostart.lua` | Session startup |
|
||||
| `overrides.lua` | Per-machine escape hatch, loaded last |
|
||||
| `shaders/` | Whole-screen color-filter shaders (grayscale and the three color-blindness corrections) `looks.lua` maps the `colorFilter` setting onto |
|
||||
| `hyprlock.conf` / `hypridle.conf` / `hyprpaper.conf` / `hyprtoolkit.conf` | Ecosystem tools (hyprlang) |
|
||||
|
||||
Validate any change without leaving your session:
|
||||
@@ -87,8 +89,8 @@ persistence, validation, reset, and the live write are all derived from that
|
||||
entry.
|
||||
|
||||
`prefs.lua` never raises. A missing, empty, truncated, malformed, or
|
||||
wrong-typed settings file costs you your customisations and nothing else;
|
||||
`tests/hypr/prefs-fallback-contract.sh` pins that, including that Hyprland still
|
||||
wrong-typed settings file costs you your customizations and nothing else;
|
||||
`tests/hypr/prefs-fallback-contract` pins that, including that Hyprland still
|
||||
accepts the config in each of those states.
|
||||
|
||||
### Adding a keybind: use `bind`, not `hl.bind`
|
||||
@@ -119,8 +121,8 @@ shipped one, so a hand-edited `settings.json` cannot cost you a keymap.
|
||||
Every `hl.bind` must pass a `description`. Hyprland reports Lua-defined binds
|
||||
with dispatcher `__lua` and a bytecode offset as the argument, so a bind without
|
||||
one has nothing readable beside its chord, and the Settings app drops it from the
|
||||
Input & Shortcuts page rather than showing a mystery row.
|
||||
`tests/quickshell/keybinds-contract.sh` fails if any bind lacks a description, so
|
||||
Keyboard page under Input rather than showing a mystery row.
|
||||
`tests/quickshell/keybinds-contract` fails if any bind lacks a description, so
|
||||
this cannot regress silently.
|
||||
|
||||
```lua
|
||||
@@ -163,7 +165,7 @@ four places, and nowhere else —
|
||||
dock, popovers), running blue on the left to orchid on the right and fading
|
||||
out before the corners. See `quickshell/widgets/PrismEdge.qml`.
|
||||
2. The focused window's border — `general.col.active_border`, blue→orchid at
|
||||
115°. Unfocused windows get no colour at all, because the gradient only
|
||||
115°. Unfocused windows get no color at all, because the gradient only
|
||||
means something if one window on screen is wearing it.
|
||||
3. The active workspace pill in the bar.
|
||||
4. Slider fills.
|
||||
@@ -179,9 +181,9 @@ which is the opposite of the intent.
|
||||
|
||||
The Nerd Font is still used, but only to draw **icon glyphs** — it is the
|
||||
pragmatic alternative to freedesktop symbolic icons, which ship with a
|
||||
hardcoded `#2e3436` fill that Qt (unlike GTK) will not recolour. Where a
|
||||
hardcoded `#2e3436` fill that Qt (unlike GTK) will not recolor. Where a
|
||||
themed freedesktop icon is wanted instead, `quickshell/widgets/ThemedIcon.qml`
|
||||
paints a palette colour through the icon's alpha.
|
||||
paints a palette color through the icon's alpha.
|
||||
|
||||
Anything whose digits change in place — the clock, the vitals percentages, the
|
||||
recording timer, the selection readout — sets `font.features:
|
||||
@@ -247,7 +249,7 @@ The mental model is unchanged from Forge:
|
||||
| `SUPER + SHIFT + H/J/K/L` | Move window |
|
||||
| `SUPER + CTRL + H/J/K/L` | Swap window |
|
||||
| `SUPER + SHIFT + Y/O` · `B/M` | Wider · narrower |
|
||||
| `SUPER + SHIFT + I/U` · `P/N` | Taller · shorter |
|
||||
| `SUPER + SHIFT + I/U` · `,/N` | Taller · shorter |
|
||||
| `SUPER + [` / `]` / `=` | Shrink / expand / reset split |
|
||||
| `SUPER + Q` | Close |
|
||||
| `SUPER + U` | Fullscreen |
|
||||
@@ -256,7 +258,7 @@ The mental model is unchanged from Forge:
|
||||
| `SUPER + CTRL + G` / `Z` / `V` | Toggle split / preselect right / preselect down |
|
||||
| `SUPER + Tab` / `SHIFT + Tab` | Cycle windows |
|
||||
| `SUPER + SHIFT + grave` | Last window |
|
||||
| `SUPER + X` / `SUPER + SHIFT + X` | Restore scratchpad / minimise to scratchpad |
|
||||
| `SUPER + X` / `SUPER + SHIFT + X` | Restore scratchpad / minimize to scratchpad |
|
||||
|
||||
### Workspaces (dynamic, like GNOME)
|
||||
| Key | Action |
|
||||
@@ -270,20 +272,22 @@ The mental model is unchanged from Forge:
|
||||
### Launcher and shell
|
||||
| Key | Action |
|
||||
|---|---|
|
||||
| `SUPER + A` / `R` / `Space` | Launcher (vicinae) — all three, pick your favourite |
|
||||
| `SUPER + A` / `R` / `Space` | Launcher (vicinae) — all three, pick your favorite |
|
||||
| `SUPER + SHIFT + R` | Fallback launcher (wofi) if the shell is broken |
|
||||
| `SUPER + V` | Clipboard history |
|
||||
| `SUPER + .` | Emoji picker |
|
||||
| `SUPER + S` | Quick settings |
|
||||
| `SUPER + I` | Settings |
|
||||
| `SUPER + SHIFT + F` | Start or reveal focus session |
|
||||
| `SUPER + B` | Notification centre |
|
||||
| `SUPER + B` | Notification center |
|
||||
| `SUPER + grave` | Workspace overview |
|
||||
| `Print` | Screenshot / record picker |
|
||||
| `SHIFT` / `ALT + Print` | Screenshot screen / window immediately |
|
||||
| `SUPER + SHIFT + S` | Screen Intelligence — read text and codes from a selection |
|
||||
| `SUPER + SHIFT + P` | Colour picker |
|
||||
| `CTRL + ALT + L` | Lock (SUPER+L is "focus right") |
|
||||
| `SUPER + SHIFT + P` | Color picker |
|
||||
| `CTRL + ALT + L` · `SUPER + Backspace` | Lock (SUPER+L is "focus right") |
|
||||
| `SUPER + /` | Every shortcut, on screen. Reads the live keymap, so a rebind shows here |
|
||||
| `SUPER + ALT + T/N/W/F/C/E` | Go to that application if it is open, rather than starting another |
|
||||
| `CTRL + ALT + Delete` | Power menu |
|
||||
|
||||
### Apps
|
||||
@@ -325,8 +329,8 @@ Caffeine, Night Light, Focus, audio input/output, user, settings, and power in
|
||||
one place. Home and Phone continue the same surface rather than opening extra
|
||||
dashboard windows.
|
||||
|
||||
Home shows the first four selected favourites at rest and every selected light
|
||||
when expanded. Use **Settings → Home & Phone** to choose favourites,
|
||||
Home shows the first four selected favorites at rest and every selected light
|
||||
when expanded. Use **Settings → Home & Phone** to choose favorites,
|
||||
set Panama-only aliases, and arrange their order. Dragging a brightness control
|
||||
only previews the value; releasing it sends one brightness request. A normal
|
||||
power toggle leaves Home Assistant responsible for restoring its previous
|
||||
@@ -334,7 +338,7 @@ level.
|
||||
|
||||
Credentials stay private in the gitignored `config/bash/env` file, with the
|
||||
existing GNOME extension and Secret Service setup retained as a compatibility
|
||||
fallback. Favourites, aliases, and order live in Quickshell state. No shell
|
||||
fallback. Favorites, aliases, and order live in Quickshell state. No shell
|
||||
restart is required after changing credentials; close and reopen Control Center
|
||||
to refresh. If Home Assistant is offline, the last known values stay visible
|
||||
with a stale-state label and Retry action.
|
||||
@@ -361,10 +365,10 @@ These are the places a 1:1 port was impossible, and what was done instead:
|
||||
|
||||
- **Per-edge resize.** Forge resized one named edge; Hyprland resizes along an
|
||||
axis and lets the layout pick the edge. The eight Forge keys collapse to four
|
||||
behaviours, keeping the horizontal/vertical and grow/shrink pairing.
|
||||
behaviors, keeping the horizontal/vertical and grow/shrink pairing.
|
||||
- **Overview on `SUPER + grave`, not a bare SUPER tap.** Tap-detection on a
|
||||
modifier misfires when you're quick with SUPER combos.
|
||||
- **Lock on `CTRL + ALT + L`.** `SUPER + L` is "focus right" in this keymap.
|
||||
- **Lock on `CTRL + ALT + L` or `SUPER + Backspace`.** `SUPER + L` is "focus right" in this keymap.
|
||||
- **`SUPER + grave` was Forge's "cycle windows of same app"**, which Hyprland
|
||||
has no equivalent for. "Last window" moved to `SUPER + SHIFT + grave`.
|
||||
- **`gnome-control-center` is launched with `XDG_CURRENT_DESKTOP=GNOME`**,
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
-- Named actions
|
||||
--
|
||||
-- The one thing that makes it safe for settings.json to describe a shortcut.
|
||||
--
|
||||
-- Panama Settings lets a person invent a keyboard shortcut and assign a
|
||||
-- four-finger gesture. Both are stored in the same user-editable JSON file the
|
||||
-- rest of the desktop reads, and both have to end up as something the
|
||||
-- compositor executes -- which is exactly the shape of every configuration
|
||||
-- format that turned out to be a shell injection.
|
||||
--
|
||||
-- It is not one here, and this file is why. A stored action is DATA:
|
||||
--
|
||||
-- { kind = "app" | "shell" | "window", target = "<id>", label = "<text>" }
|
||||
--
|
||||
-- `kind` is an enum with three members. `target` is either a key of one of the
|
||||
-- whitelist tables below -- whose values are literals written here, in Lua, by
|
||||
-- a human -- or, for `app`, an identifier that has to match a character class
|
||||
-- containing no shell metacharacter at all, and which is then quoted as a
|
||||
-- single argv element for panama-launch rather than pasted into a command.
|
||||
--
|
||||
-- So the worst a hand-edited (or maliciously written) settings file can do is
|
||||
-- pick a different entry from a list that is fixed at ship time, or launch an
|
||||
-- application by id. It cannot introduce a command. There is no path from a
|
||||
-- stored string to a new exec string; the table lookups are the only source of
|
||||
-- one.
|
||||
--
|
||||
-- Everything invalid returns nil and the caller skips the bind or gesture --
|
||||
-- the prefs.lua philosophy: never raise, never guess. A malformed entry costs
|
||||
-- one shortcut, never the keymap and never the compositor.
|
||||
--
|
||||
-- Required by keybinds.lua (custom shortcuts) and input.lua (four-finger
|
||||
-- gestures). services/Keybinds.qml's describeAction() is the QML mirror of the
|
||||
-- vocabulary below; the two lists have to be edited together.
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
local actions = {}
|
||||
|
||||
-- ── Shell verbs ─────────────────────────────────────────────────────────────
|
||||
--
|
||||
-- Every entry is a command string written HERE. Nothing stored anywhere else
|
||||
-- contributes a character to one; `target` only chooses which of these to use.
|
||||
--
|
||||
-- The `qs ipc call` targets and functions are the ones quickshell/shell.qml
|
||||
-- actually registers -- an IpcHandler silently declines to register a function
|
||||
-- it cannot type-check, so a verb invented here would be a shortcut that does
|
||||
-- nothing. Checked against shell.qml, not remembered.
|
||||
--
|
||||
-- The three that are not IPC (`launcher`, `color-picker`, `lock`) are the same
|
||||
-- literal commands the shipped binds in keybinds.lua use, for the same reason
|
||||
-- they use them: they are the tools, not the shell.
|
||||
local SHELL = {
|
||||
["dnd-toggle"] = { label = "Do Not Disturb", command = "qs ipc call notifications dnd" },
|
||||
["notifications"] = { label = "Notifications", command = "qs ipc call notifications toggle" },
|
||||
["overview"] = { label = "Overview", command = "qs ipc call overview toggle" },
|
||||
["launcher"] = { label = "Launcher", command = "vicinae toggle" },
|
||||
["clipboard"] = { label = "Clipboard history", command = "qs ipc call clipboard toggle" },
|
||||
["screenshot"] = { label = "Screenshot / record", command = "qs ipc call capture open" },
|
||||
["screenshot-screen"] = { label = "Screenshot: whole screen", command = "qs ipc call capture screenNow" },
|
||||
["screenshot-window"] = { label = "Screenshot: window", command = "qs ipc call capture windowNow" },
|
||||
["screen-intelligence"]= { label = "Screen Intelligence", command = "qs ipc call screen-intelligence open" },
|
||||
["color-picker"] = { label = "Color picker", command = "hyprpicker -a -f hex" },
|
||||
["quick-settings"] = { label = "Quick settings", command = "qs ipc call quicksettings toggle" },
|
||||
["settings"] = { label = "Settings", command = "qs ipc call settings toggle" },
|
||||
["cheatsheet"] = { label = "Keyboard shortcuts", command = "qs ipc call cheatsheet toggle" },
|
||||
["focus-session"] = { label = "Focus session", command = "qs ipc call focus reveal" },
|
||||
["caffeine"] = { label = "Keep awake", command = "qs ipc call caffeine toggle" },
|
||||
["night-light"] = { label = "Night Light", command = "qs ipc call night-light toggle" },
|
||||
["activity"] = { label = "Activity", command = "qs ipc call activity toggle" },
|
||||
["power-menu"] = { label = "Power menu", command = "qs ipc call powermenu toggle" },
|
||||
["lock"] = { label = "Lock", command = "loginctl lock-session" },
|
||||
}
|
||||
|
||||
-- ── Window verbs ────────────────────────────────────────────────────────────
|
||||
--
|
||||
-- Builders rather than dispatchers, so nothing is constructed for a verb that
|
||||
-- is never chosen, and so a gesture builds its dispatcher when the fingers
|
||||
-- move rather than holding one from config time.
|
||||
--
|
||||
-- `workspace:N` is not in the table: it is ten entries that differ by a number,
|
||||
-- and the number is validated as 1..10 in `window_action` below.
|
||||
local WINDOW = {
|
||||
["float-toggle"] = { label = "Toggle float", build = function() return hl.dsp.window.float({ action = "toggle" }) end },
|
||||
["fullscreen"] = { label = "Fullscreen", build = function() return hl.dsp.window.fullscreen({ mode = "fullscreen" }) end },
|
||||
["pin"] = { label = "Pin window", build = function() return hl.dsp.window.pin({ action = "toggle" }) end },
|
||||
}
|
||||
|
||||
-- Published so a contract can read the vocabulary without parsing this file,
|
||||
-- and so the ten workspace verbs have one definition rather than two.
|
||||
actions.shell_verbs = SHELL
|
||||
actions.window_verbs = WINDOW
|
||||
actions.workspace_min = 1
|
||||
actions.workspace_max = 10
|
||||
|
||||
-- ── Targets ─────────────────────────────────────────────────────────────────
|
||||
|
||||
-- Letters, digits, and the four punctuation marks a desktop id actually uses.
|
||||
-- Deliberately excludes every shell metacharacter, quote, slash and space, so
|
||||
-- an id that passes cannot change the meaning of a command line even before it
|
||||
-- is quoted -- the quoting below is the second lock on the same door.
|
||||
local APP_TARGET = "^[A-Za-z0-9@._%-]+$"
|
||||
|
||||
local function valid_app_target(target)
|
||||
return type(target) == "string"
|
||||
and #target >= 1 and #target <= 128
|
||||
and target:match(APP_TARGET) ~= nil
|
||||
end
|
||||
|
||||
-- Single-quoted for the shell, with the one escape single quotes need. Same
|
||||
-- function keybinds.lua uses for the go-to patterns, and used here for the
|
||||
-- same reason: the value reaches the command as one argument.
|
||||
local function shell_quote(value)
|
||||
return "'" .. value:gsub("'", "'\\''") .. "'"
|
||||
end
|
||||
|
||||
-- Google RE2 metacharacters, escaped so the id matches itself literally.
|
||||
-- panama-launch takes a regular expression, and an unescaped "org.gnome.Files"
|
||||
-- would also match "orgxgnomexFiles".
|
||||
local function escape_regex(value)
|
||||
return (value:gsub("[%^%$%(%)%%%.%[%]%*%+%-%?%{%}%|\\]", "\\%0"))
|
||||
end
|
||||
|
||||
-- ── Resolution ──────────────────────────────────────────────────────────────
|
||||
|
||||
local launcher_bin = "$HOME/.local/share/Panama/bin/panama-launch"
|
||||
|
||||
-- The launch-or-focus path the shipped application keys use: raise the window
|
||||
-- if it is already open, start it if it is not. The id is the class pattern
|
||||
-- (anchored, escaped) and the thing to start; on Wayland an application's
|
||||
-- desktop id and its window class are the same string often enough that this
|
||||
-- is the right first guess, and the wrong guess costs a second window rather
|
||||
-- than an error.
|
||||
--
|
||||
-- gtk-launch activates a desktop entry by id, which is what the applications
|
||||
-- catalog in Settings offers -- a desktop id is not a binary and cannot be
|
||||
-- exec'd directly.
|
||||
local function app_action(target)
|
||||
if not valid_app_target(target) then
|
||||
return nil
|
||||
end
|
||||
local launch_command = table.concat({
|
||||
launcher_bin,
|
||||
"--class", shell_quote("^" .. escape_regex(target) .. "$"),
|
||||
"--", "gtk-launch", shell_quote(target),
|
||||
}, " ")
|
||||
return function() return hl.dsp.exec_cmd(launch_command) end
|
||||
end
|
||||
|
||||
local function shell_action(target)
|
||||
if type(target) ~= "string" then
|
||||
return nil
|
||||
end
|
||||
local verb = SHELL[target]
|
||||
if verb == nil then
|
||||
return nil
|
||||
end
|
||||
return function() return hl.dsp.exec_cmd(verb.command) end
|
||||
end
|
||||
|
||||
local function window_action(target)
|
||||
if type(target) ~= "string" then
|
||||
return nil
|
||||
end
|
||||
|
||||
local verb = WINDOW[target]
|
||||
if verb ~= nil then
|
||||
return verb.build
|
||||
end
|
||||
|
||||
local index = target:match("^workspace:(%d+)$")
|
||||
if index == nil then
|
||||
return nil
|
||||
end
|
||||
local number = tonumber(index)
|
||||
if number == nil or number < actions.workspace_min or number > actions.workspace_max then
|
||||
return nil
|
||||
end
|
||||
return function() return hl.dsp.focus({ workspace = number }) end
|
||||
end
|
||||
|
||||
local KINDS = {
|
||||
app = app_action,
|
||||
shell = shell_action,
|
||||
window = window_action,
|
||||
}
|
||||
|
||||
-- The builder for one stored entry, or nil when the entry is anything this
|
||||
-- file does not recognise. Everything above funnels through here, so there is
|
||||
-- exactly one place where a stored value becomes an action.
|
||||
local function builder(entry)
|
||||
if type(entry) ~= "table" then
|
||||
return nil
|
||||
end
|
||||
local resolve = KINDS[entry.kind]
|
||||
if resolve == nil then
|
||||
return nil
|
||||
end
|
||||
return resolve(entry.target)
|
||||
end
|
||||
|
||||
-- For hl.bind: the dispatcher itself, or nil.
|
||||
function actions.dispatcher(entry)
|
||||
local build = builder(entry)
|
||||
if build == nil then
|
||||
return nil
|
||||
end
|
||||
return build()
|
||||
end
|
||||
|
||||
-- For hl.gesture: a function, which is what a gesture action has to be when it
|
||||
-- is not one of Hyprland's own built-in names ("workspace" and friends).
|
||||
function actions.gesture(entry)
|
||||
local build = builder(entry)
|
||||
if build == nil then
|
||||
return nil
|
||||
end
|
||||
return function() hl.dispatch(build()) end
|
||||
end
|
||||
|
||||
-- True when an entry resolves to something. Cheap enough to call twice; used
|
||||
-- where the caller wants to check before it commits to emitting anything.
|
||||
function actions.valid(entry)
|
||||
return builder(entry) ~= nil
|
||||
end
|
||||
|
||||
return actions
|
||||
@@ -23,12 +23,46 @@ hl.on("hyprland.start", function()
|
||||
hl.exec_cmd("dbus-update-activation-environment --systemd WAYLAND_DISPLAY XDG_CURRENT_DESKTOP=Hyprland")
|
||||
hl.exec_cmd("systemctl --user start hyprland-session.target")
|
||||
|
||||
-- Units: polkit prompts, wallpaper, launcher daemon, idle/lock.
|
||||
hl.exec_cmd("systemctl --user start hyprpolkitagent.service hyprpaper.service vicinae.service hypridle.service")
|
||||
-- Units: polkit prompts, wallpaper, launcher daemon, idle/lock. All four
|
||||
-- carry `ConditionEnvironment=WAYLAND_DISPLAY`, and hl.exec_cmd fires
|
||||
-- commands without waiting for them to finish, so the dbus-update call
|
||||
-- above racing this one is not safe to assume complete -- a lost race
|
||||
-- leaves the Condition unmet and the unit silently never starts (exit 0,
|
||||
-- no error). hypridle is the only listener for the logind Lock signal,
|
||||
-- so that failure mode is "lock-session goes to nobody". Re-import
|
||||
-- synchronously in the same shell invocation first so the Condition
|
||||
-- always sees it, regardless of how the dbus-update call above scheduled.
|
||||
-- panama-polkit-agent replaces hyprpolkitagent, whose prompt is compiled
|
||||
-- into its binary and cannot be themed. Only one agent may register per
|
||||
-- session, so they must not both start. hyprpolkitagent stays INSTALLED as
|
||||
-- the fallback: `systemctl --user start hyprpolkitagent` restores the stock
|
||||
-- prompt if Panama's ever fails to come up.
|
||||
hl.exec_cmd("systemctl --user import-environment WAYLAND_DISPLAY XDG_CURRENT_DESKTOP && systemctl --user start panama-polkit-agent.service hyprpaper.service vicinae.service hypridle.service")
|
||||
|
||||
-- Pending repairs for this machine, if any. Oneshot: it checks, tells the
|
||||
-- user when there is something to tell, and exits. Started here rather
|
||||
-- than enabled so it belongs to the Hyprland session; see the unit.
|
||||
hl.exec_cmd("systemctl --user start panama-migrate-notify.service")
|
||||
|
||||
-- Notices when a program dumps core and says so once per session. Under
|
||||
-- GNOME, ABRT does this; here nothing did, and applications died silently.
|
||||
hl.exec_cmd("systemctl --user start panama-crash-watch.service")
|
||||
|
||||
-- Text expansion. change-settings runs `espanso service register`, which
|
||||
-- writes and enables espanso's own user unit; the explicit start makes the
|
||||
-- first Hyprland login after a fresh install work rather than the second.
|
||||
-- Started on its own line: unlike the units above it carries no
|
||||
-- ConditionEnvironment, and a missing unit (espanso not yet registered)
|
||||
-- must not muddy the start of the four that lock and wallpaper depend on.
|
||||
hl.exec_cmd("systemctl --user start espanso.service")
|
||||
|
||||
-- The shell: bar, dock, overview, quick settings, notifications, capture.
|
||||
-- No systemd unit ships with quickshell, so it runs as a compositor child.
|
||||
hl.exec_cmd("quickshell --daemonize")
|
||||
-- A supervised unit rather than a compositor child, for the one property
|
||||
-- a child cannot have: if the shell crashes, systemd restarts it in two
|
||||
-- seconds instead of leaving a desktop with no bar until someone knows
|
||||
-- what to type. The unit ships in config/local/share/systemd/user and is
|
||||
-- started per-session here like every other Panama unit.
|
||||
hl.exec_cmd("systemctl --user start panama-quickshell.service")
|
||||
|
||||
-- Removable-media automounting. GNOME did this invisibly via gvfs+udisks;
|
||||
-- outside GNOME something has to ask udisks to mount. No tray icon: the
|
||||
@@ -39,22 +73,46 @@ hl.on("hyprland.start", function()
|
||||
-- Keyring unlock, for Nextcloud and Bitwarden credential storage.
|
||||
hl.exec_cmd("/usr/bin/gnome-keyring-daemon --start --components=secrets,ssh,pkcs11")
|
||||
|
||||
-- Tray applications carried over from ~/.config/autostart. These need the
|
||||
-- Quickshell tray (an SNI host) to be up, hence starting after it.
|
||||
-- Nextcloud and Bitwarden are NOT started here. Both ship a
|
||||
-- ~/.config/autostart/*.desktop entry, and systemd's own
|
||||
-- systemd-xdg-autostart-generator turns every such entry into a
|
||||
-- `PartOf=graphical-session.target` unit (`[email protected]`,
|
||||
-- `[email protected]`) that fires once the uwsm
|
||||
-- session brings up graphical-session.target -- confirmed live via
|
||||
-- `systemctl --user list-units 'app-*@autostart.service'`. An explicit
|
||||
-- second launch here used to duplicate that: for Bitwarden specifically,
|
||||
-- each `flatpak run` gets its own sandbox instance, so the two starts
|
||||
-- didn't just race, they left two competing processes fighting over the
|
||||
-- app's single-instance lock, with neither reliably owning a usable
|
||||
-- window. Trust the generator instead of re-launching.
|
||||
--
|
||||
-- Commands are copied verbatim from the GNOME .desktop files rather than
|
||||
-- guessed: Bitwarden is a flatpak with no `bitwarden` binary on PATH, and
|
||||
-- its autostart entry launches a specific script with --autostart.
|
||||
--
|
||||
-- RustDesk is deliberately absent: it ships an enabled *system* service
|
||||
-- (`rustdesk --service`) that spawns --server and --tray for the session on
|
||||
-- its own. Starting it here as well would give you two trays.
|
||||
hl.exec_cmd("nextcloud --background")
|
||||
hl.exec_cmd("flatpak run --command=bitwarden.sh com.bitwarden.desktop --autostart")
|
||||
-- RustDesk is deliberately absent too: it ships an enabled *system*
|
||||
-- service (`rustdesk --service`) that spawns --server and --tray for the
|
||||
-- session on its own. Starting it here as well would give you two trays.
|
||||
end)
|
||||
|
||||
-- Tear down what the start handler brought up.
|
||||
--
|
||||
-- This stopped `hyprland-session.target`, which does not exist on Fedora --
|
||||
-- systemctl reports "Unit not found" and the handler achieves nothing. The
|
||||
-- units started above are not children of the compositor, so nothing else
|
||||
-- stopped them either: hyprpaper, hypridle, vicinae and the polkit agent kept
|
||||
-- running after logout, and each one Requires=graphical-session.target, so
|
||||
-- that target stayed active with no session behind it.
|
||||
--
|
||||
-- The next login then failed. Both uwsm and gnome-session refuse to start into
|
||||
-- a session that is already running ("A compositor or graphical-session*
|
||||
-- target is already active!"), so "Hyprland (uwsm-managed)" and GNOME both
|
||||
-- bounced straight back to the login screen while plain Hyprland -- which
|
||||
-- makes no such check -- kept working. The desktop appeared to be the only one
|
||||
-- that functioned, when it was the one leaving the mess.
|
||||
--
|
||||
-- Stopping graphical-session.target is enough on its own: every unit above is
|
||||
-- PartOf= it, so they come down with it, and it carries StopWhenUnneeded=yes.
|
||||
-- It is stopped rather than the units listed individually so that a unit added
|
||||
-- to the start handler later cannot be forgotten here.
|
||||
hl.on("hyprland.shutdown", function()
|
||||
hl.exec_cmd("systemctl --user stop hyprland-session.target")
|
||||
hl.exec_cmd("systemctl --user stop graphical-session.target")
|
||||
end)
|
||||
|
||||
return true
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
-- Environment
|
||||
--
|
||||
-- hl.env() sets variables before the display server initialises, which is what
|
||||
-- hl.env() sets variables before the display server initializes, which is what
|
||||
-- makes it valid for AQ_* and cursor variables.
|
||||
--
|
||||
-- NOTE for uwsm sessions: if you log in via "Hyprland (uwsm-managed)", uwsm
|
||||
@@ -58,7 +58,7 @@ hl.env("QT_QPA_PLATFORM", "wayland;xcb")
|
||||
|
||||
-- "gtk3" makes Qt derive its palette from the live GTK3 theme via the qgtk3
|
||||
-- platform plugin, so Qt apps match adw-gtk3-dark exactly rather than
|
||||
-- approximating it -- and it gives them GTK file/font/colour dialogs. It also
|
||||
-- approximating it -- and it gives them GTK file/font/color dialogs. It also
|
||||
-- covers Qt5 and Qt6 with one value.
|
||||
--
|
||||
-- Note this is a single value, NOT a fallback list: Qt splits on ":" and uses
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# hypridle — idle behaviour
|
||||
# hypridle — idle behavior
|
||||
#
|
||||
# Mirrors the GNOME power profile this replaces, exactly:
|
||||
# org.gnome.desktop.session idle-delay = 300 (screen off at 5min)
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
general {
|
||||
# `pidof` guard prevents stacking lockers if this fires twice.
|
||||
lock_cmd = pidof hyprlock || hyprlock
|
||||
lock_cmd = pidof hyprlock || ~/.config/quickshell/scripts/panama-lock run
|
||||
before_sleep_cmd = loginctl lock-session
|
||||
after_sleep_cmd = hyprctl dispatch 'hl.dsp.dpms({ action = "on" })'
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
-- Shared preferences first: looks/input/monitors read their defaults through it.
|
||||
-- It never raises, so a missing or malformed settings file costs customisations
|
||||
-- It never raises, so a missing or malformed settings file costs customizations
|
||||
-- and nothing else.
|
||||
require("prefs")
|
||||
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# hyprlock — lock screen
|
||||
# hyprlock — lock screen.
|
||||
#
|
||||
# Tokyo Night Moon, matching quickshell/config/Theme.qml:
|
||||
# accent #82aaff fg #c8d3f5 dim #828bb8 bg #222436 red #ff757f
|
||||
# GENERATED FILE. Edit hyprlock.conf.template and re-run
|
||||
# quickshell/scripts/panama-theme-apps; editing this copy is overwritten on the
|
||||
# next color scheme change.
|
||||
#
|
||||
# The colors here follow the desktop's light/dark setting. They used to be
|
||||
# hardcoded Tokyo Night Moon, which meant the one screen you see most often
|
||||
# stayed dark when everything else went light.
|
||||
#
|
||||
# hyprlock takes rgba(r, g, b, a) in DECIMAL rather than hex, which is why the
|
||||
# template carries "R, G, B" triples where the rest of Panama uses hex. The two
|
||||
# Pango markup values are the exception and want ##rrggbb.
|
||||
#
|
||||
# hyprlang syntax, not Lua — hyprlock is a separate project from Hyprland.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -45,7 +54,7 @@ background {
|
||||
vibrancy_darkness = 0.05
|
||||
|
||||
# Shown if the screenshot is unavailable.
|
||||
color = rgba(34, 36, 54, 1.0)
|
||||
color = rgba(@BG@, 1.0)
|
||||
|
||||
zindex = -1
|
||||
}
|
||||
@@ -54,7 +63,7 @@ background {
|
||||
label {
|
||||
monitor =
|
||||
text = cmd[update:1000] date +"%-I:%M"
|
||||
color = rgba(200, 211, 245, 1.0)
|
||||
color = rgba(@FG@, 1.0)
|
||||
font_size = 120
|
||||
font_family = Adwaita Sans Light
|
||||
position = 0, 260
|
||||
@@ -65,7 +74,7 @@ label {
|
||||
label {
|
||||
monitor =
|
||||
text = cmd[update:60000] date +"%A, %B %-d"
|
||||
color = rgba(130, 139, 184, 1.0)
|
||||
color = rgba(@MUTED@, 1.0)
|
||||
font_size = 24
|
||||
font_family = Adwaita Sans
|
||||
position = 0, 160
|
||||
@@ -84,18 +93,18 @@ input-field {
|
||||
outline_thickness = 2
|
||||
rounding = 26
|
||||
|
||||
outer_color = rgba(130, 170, 255, 0.9)
|
||||
inner_color = rgba(46, 47, 61, 0.85)
|
||||
font_color = rgba(200, 211, 245, 1.0)
|
||||
check_color = rgba(130, 170, 255, 1.0)
|
||||
fail_color = rgba(255, 117, 127, 1.0)
|
||||
outer_color = rgba(@ACCENT@, 0.9)
|
||||
inner_color = rgba(@FIELD@, 0.85)
|
||||
font_color = rgba(@FG@, 1.0)
|
||||
check_color = rgba(@ACCENT@, 1.0)
|
||||
fail_color = rgba(@ERROR@, 1.0)
|
||||
|
||||
dots_size = 0.25
|
||||
dots_spacing = 0.3
|
||||
dots_center = true
|
||||
|
||||
placeholder_text = <span foreground="##828bb8"><i>Password</i></span>
|
||||
fail_text = <span foreground="##ff757f"><i>$FAIL ($ATTEMPTS)</i></span>
|
||||
placeholder_text = <span foreground="##@MUTED_HEX@"><i>Password</i></span>
|
||||
fail_text = <span foreground="##@ERROR_HEX@"><i>$FAIL ($ATTEMPTS)</i></span>
|
||||
|
||||
fade_on_empty = false
|
||||
hide_input = false
|
||||
@@ -105,7 +114,7 @@ input-field {
|
||||
label {
|
||||
monitor =
|
||||
text = $USER
|
||||
color = rgba(200, 211, 245, 0.9)
|
||||
color = rgba(@FG@, 0.9)
|
||||
font_size = 16
|
||||
font_family = Adwaita Sans
|
||||
position = 0, -110
|
||||
@@ -17,20 +17,16 @@
|
||||
# frame time for nothing.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# One block for every output: per-monitor wallpaper is applied at runtime by
|
||||
# the shell (services/Wallpaper.qml) over IPC, so a per-connector block here
|
||||
# would just be one machine's connector name in everyone's config.
|
||||
wallpaper {
|
||||
monitor = DP-2
|
||||
monitor =
|
||||
path = ~/Pictures/Wallpapers/faroe_islands.jpg
|
||||
# cover = fill the output, cropping overflow. Matches the "zoom" picture
|
||||
# option this setup used under GNOME.
|
||||
fit_mode = cover
|
||||
}
|
||||
|
||||
# Fallback for any output without its own block above.
|
||||
wallpaper {
|
||||
monitor =
|
||||
path = ~/Pictures/Wallpapers/faroe_islands.jpg
|
||||
fit_mode = cover
|
||||
}
|
||||
|
||||
ipc = on
|
||||
splash = false
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
# hyprland-guiutils hyprland-dialog / hyprland-run
|
||||
# hyprpolkitagent authentication prompts (via hyprland-qt-support)
|
||||
#
|
||||
# Colours are Tokyo Night Moon, matching quickshell/config/Theme.qml and
|
||||
# Colors are Tokyo Night Moon, matching quickshell/config/Theme.qml and
|
||||
# hypr/looks.lua. Format is hyprlang; path is fixed at
|
||||
# $XDG_CONFIG_HOME/hypr/hyprtoolkit.conf (confirmed in libhyprtoolkit).
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
+131
-6
@@ -6,6 +6,7 @@
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
local prefs = require("prefs")
|
||||
local actions = require("actions")
|
||||
|
||||
hl.config({
|
||||
input = {
|
||||
@@ -22,10 +23,10 @@ hl.config({
|
||||
repeat_rate = prefs.get("keyRepeatRate", 33),
|
||||
|
||||
-- 1 = FOLLOW. The window under the pointer takes focus. This comment
|
||||
-- previously claimed 1 was "click to focus, GNOME's behaviour", which
|
||||
-- previously claimed 1 was "click to focus, GNOME's behavior", which
|
||||
-- is the opposite of what Hyprland does -- `hyprctl descriptions` gives
|
||||
-- map: [{"separate":3},{"detached":2},{"follow":1},{"disabled":0}],
|
||||
-- so click-to-focus is 0. Changing the shipped value is a behaviour
|
||||
-- so click-to-focus is 0. Changing the shipped value is a behavior
|
||||
-- decision rather than a correction, so the value is left alone and
|
||||
-- only the description is fixed; Settings exposes all four.
|
||||
follow_mouse = prefs.getInt("followMouse", 1),
|
||||
@@ -35,16 +36,140 @@ hl.config({
|
||||
-- Still focus-follows-pointer, just less twitchy.
|
||||
mouse_refocus = false,
|
||||
|
||||
-- Flat pointer response, no acceleration. Matters for gaming.
|
||||
-- 0 = NEXT, the compositor's own default: focus goes to the next window
|
||||
-- in the layout order. `hyprctl descriptions` publishes
|
||||
-- map: [{"mru":2},{"cursor":1},{"next":0}], and Settings offers all
|
||||
-- three; 0 is repeated here so nothing changes on a machine with no
|
||||
-- settings file.
|
||||
focus_on_close = prefs.getInt("focusOnClose", 0),
|
||||
|
||||
-- Flat pointer response by default, no acceleration. Matters for
|
||||
-- gaming; Settings offers adaptive for people who want it back.
|
||||
sensitivity = prefs.get("pointerSensitivity", 0),
|
||||
accel_profile = "flat",
|
||||
accel_profile = prefs.get("accelProfile", "flat"),
|
||||
|
||||
natural_scroll = prefs.get("naturalScroll", false),
|
||||
scroll_factor = prefs.get("scrollFactor", 1.0),
|
||||
left_handed = prefs.get("leftHanded", false),
|
||||
|
||||
-- Empty is a value, not an omission: it is what `hyprctl getoption`
|
||||
-- reports as "[[EMPTY]]" before anything writes the option, and it means
|
||||
-- "let libinput pick per device" -- two fingers on a touchpad, the wheel
|
||||
-- on a mouse. Writing it back explicitly is the same branch a stock
|
||||
-- Hyprland takes, and it keeps the setting reversible: without an empty
|
||||
-- choice in the schema there would be no way back from a scroll method
|
||||
-- once one was picked. Settings offers 2fg / edge / on_button_down /
|
||||
-- no_scroll alongside it, the four words the option's own description
|
||||
-- names (it publishes no map).
|
||||
scroll_method = prefs.get("scrollMethod", ""),
|
||||
|
||||
-- Only consulted while scroll_method is on_button_down. 0 means the
|
||||
-- device's own middle button.
|
||||
scroll_button = prefs.get("scrollButton", 0),
|
||||
|
||||
-- Clicking a floating window raises and focuses it.
|
||||
float_switch_override_focus = 2,
|
||||
|
||||
-- Every touchpad preference Settings offers is read here as well as
|
||||
-- applied live: the live half (hyprctl eval) reaches a running
|
||||
-- compositor, and THIS half is what survives `hyprctl reload` and the
|
||||
-- gap before the shell starts. A schema `hypr:` entry without a
|
||||
-- prefs.get() here is a control that quietly reverts -- the whole
|
||||
-- Mouse & Touchpad page once had exactly that bug. Defaults must
|
||||
-- match PreferenceSchema's; the hypr-prefs-contract checks both.
|
||||
touchpad = {
|
||||
tap_to_click = prefs.get("touchpadTapToClick", true),
|
||||
natural_scroll = prefs.get("touchpadNaturalScroll", true),
|
||||
disable_while_typing = prefs.get("touchpadDisableWhileTyping", true),
|
||||
scroll_factor = prefs.get("touchpadScrollFactor", 1.0),
|
||||
drag_lock = prefs.getInt("touchpadDragLock", 0),
|
||||
middle_button_emulation = prefs.get("touchpadMiddleButtonEmulation", false),
|
||||
clickfinger_behavior = prefs.get("touchpadClickfinger", false),
|
||||
|
||||
-- Underscores here, hyphens in the option name getoption answers to
|
||||
-- (input:touchpad:tap-and-drag) -- the same split tap_to_click has
|
||||
-- at the top of this table. Hyprland's own default is true; it is
|
||||
-- repeated rather than omitted so the schema, the Lua, and the
|
||||
-- compositor all state the same value.
|
||||
tap_and_drag = prefs.get("touchpadTapAndDrag", true),
|
||||
},
|
||||
},
|
||||
|
||||
-- Pointer BEHAVIOUR, as opposed to pointer appearance: how the cursor reacts
|
||||
-- to typing and to workspace switches. The cursor's looks -- theme, size,
|
||||
-- hardware cursors, the inactivity fade -- are a separate cursor table in
|
||||
-- looks.lua. hl.config calls are additive per option, so the two tables
|
||||
-- coexist; they are split by what a person would go looking for, and these
|
||||
-- two appear on Settings' Mouse & Touchpad page rather than in Appearance.
|
||||
cursor = {
|
||||
hide_on_key_press = prefs.get("cursorHideWhileTyping", false),
|
||||
|
||||
-- An integer with three states, written from a switch: disable = 0,
|
||||
-- enable = 1, force = 2. Settings offers the first two, so getInt is
|
||||
-- what bridges a stored boolean to the number Hyprland wants -- the same
|
||||
-- pairing render.cm_auto_hdr uses in looks.lua.
|
||||
warp_on_change_workspace = prefs.getInt("cursorWarpOnWorkspaceChange", 0),
|
||||
},
|
||||
|
||||
-- Tuning for the three-finger gestures registered below.
|
||||
gestures = {
|
||||
workspace_swipe_distance = prefs.getInt("swipeDistance", 300),
|
||||
workspace_swipe_invert = prefs.get("swipeInvert", true),
|
||||
},
|
||||
})
|
||||
|
||||
-- Desktop machine: no touchpad, no gestures worth wiring. If a laptop ever
|
||||
-- runs this config, add touchpad settings in overrides.lua.
|
||||
-- ── Touchpad gestures ───────────────────────────────────────────────────────
|
||||
--
|
||||
-- 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") })
|
||||
|
||||
-- ── Four-finger gestures ────────────────────────────────────────────────────
|
||||
--
|
||||
-- The three above are the desktop's, fixed. These four are the user's: each
|
||||
-- holds a named action from settings.json, or {} for unassigned, and the
|
||||
-- vocabulary is exactly the one custom shortcuts use -- actions.lua resolves
|
||||
-- both, through the same whitelist tables, so a gesture can no more introduce
|
||||
-- a command than a keybind can.
|
||||
--
|
||||
-- Nothing is emitted for an unassigned direction. That matters more here than
|
||||
-- it looks: a registration is read at config time and there is no way to
|
||||
-- remove one afterwards, so emitting a no-op gesture for every direction would
|
||||
-- consume the four-finger swipes permanently, including for whatever the
|
||||
-- compositor might do with them later.
|
||||
--
|
||||
-- Four rather than three because three is spoken for, and because four fingers
|
||||
-- is the largest number of them a touchpad this size can tell apart.
|
||||
local custom_gestures = {
|
||||
{ pref = "gestureFourUp", direction = "up" },
|
||||
{ pref = "gestureFourDown", direction = "down" },
|
||||
{ pref = "gestureFourLeft", direction = "left" },
|
||||
{ pref = "gestureFourRight", direction = "right" },
|
||||
}
|
||||
|
||||
for _, gesture in ipairs(custom_gestures) do
|
||||
local action = actions.gesture(prefs.get(gesture.pref, {}))
|
||||
if action ~= nil then
|
||||
hl.gesture({ fingers = 4, direction = gesture.direction, action = action })
|
||||
end
|
||||
end
|
||||
|
||||
return true
|
||||
|
||||
+366
-24
@@ -12,6 +12,7 @@
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
local prefs = require("prefs")
|
||||
local actions = require("actions")
|
||||
|
||||
local mod = "SUPER"
|
||||
|
||||
@@ -34,6 +35,14 @@ local osd = function(action)
|
||||
return "$HOME/.config/quickshell/scripts/panama-osd " .. action
|
||||
end
|
||||
|
||||
local lid = function(action)
|
||||
return "$HOME/.config/quickshell/scripts/panama-lid " .. action
|
||||
end
|
||||
|
||||
local dictate = function(action)
|
||||
return "$HOME/.config/quickshell/scripts/panama-dictate " .. action
|
||||
end
|
||||
|
||||
-- Quickshell IPC targets. See quickshell/shell.qml for the handlers.
|
||||
local qs = function(target, fn) return "qs ipc call " .. target .. " " .. fn end
|
||||
|
||||
@@ -66,24 +75,137 @@ local function valid_chord(chord)
|
||||
return chord:match("^[%w_+%s:]+$") ~= nil
|
||||
end
|
||||
|
||||
-- ── Categories ──────────────────────────────────────────────────────────────
|
||||
-- What a bind is FOR, as opposed to what it does.
|
||||
--
|
||||
-- The cheatsheet groups by this, and the Shortcuts settings page uses it too.
|
||||
-- It is recorded here rather than guessed from the description, which is what
|
||||
-- Keybinds.qml used to do: matching substrings put "Close window" and "Close
|
||||
-- the notification list" in the same group and left anything phrased unusually
|
||||
-- in whichever bucket matched first.
|
||||
--
|
||||
-- The sections of this file already ARE the categories, so a section sets one
|
||||
-- and every bind below it inherits it. That keeps the annotation to one line
|
||||
-- per section instead of one per bind, and makes the grouping impossible to
|
||||
-- forget: a new bind lands in the category of the section it was written in.
|
||||
local categories = {}
|
||||
local current_category = "Other"
|
||||
|
||||
local function category(name)
|
||||
current_category = name
|
||||
end
|
||||
|
||||
local function bind(chord, action, opts)
|
||||
local override = overrides[chord]
|
||||
if valid_chord(override) then
|
||||
chord = override
|
||||
end
|
||||
-- Keyed by the chord actually bound, so the shell can join on what
|
||||
-- hyprctl reports without having to know about overrides.
|
||||
categories[chord] = current_category
|
||||
return hl.bind(chord, action, opts)
|
||||
end
|
||||
|
||||
-- Written where the shell can read it. Hyprland reports a Lua bind's
|
||||
-- dispatcher as `__lua` with a bytecode offset, so there is no way to attach
|
||||
-- anything to a bind that survives into `hyprctl binds` -- the manifest is
|
||||
-- how this side of the desktop tells the other what these binds are for.
|
||||
--
|
||||
-- Never raises. A read-only or missing state directory costs the categories,
|
||||
-- which the shell falls back from, and must never cost the keymap.
|
||||
local function write_categories()
|
||||
local state_home = os.getenv("XDG_STATE_HOME")
|
||||
if state_home == nil or state_home == "" then
|
||||
local home = os.getenv("HOME")
|
||||
if home == nil or home == "" then
|
||||
return
|
||||
end
|
||||
state_home = home .. "/.local/state"
|
||||
end
|
||||
|
||||
local parts = {}
|
||||
for chord, name in pairs(categories) do
|
||||
-- Chords and category names are both from this file, so the only
|
||||
-- escaping that can matter is the quote character itself.
|
||||
parts[#parts + 1] = string.format('%q:%q', chord, name)
|
||||
end
|
||||
table.sort(parts)
|
||||
|
||||
local path = state_home .. "/panama/keybind-categories.json"
|
||||
os.execute("mkdir -p " .. string.format("%q", state_home .. "/panama"))
|
||||
local file = io.open(path, "w")
|
||||
if file == nil then
|
||||
return
|
||||
end
|
||||
file:write("{" .. table.concat(parts, ",") .. "}\n")
|
||||
file:close()
|
||||
end
|
||||
|
||||
-- SUPER opens a new one. SUPER+ALT goes to the one you already have.
|
||||
--
|
||||
-- That order matters and was chosen deliberately after trying the reverse.
|
||||
-- Making the plain key focus an existing window reads well in a demo and is
|
||||
-- what macOS does, but it makes "give me another terminal" the awkward case --
|
||||
-- and on a tiling desktop, opening a second terminal beside the first is not
|
||||
-- an edge case, it is the normal way to work. So the plain key keeps doing
|
||||
-- what it has always done, and the modifier is the new capability rather than
|
||||
-- a tax on the old one.
|
||||
--
|
||||
-- ALT rather than SHIFT because SUPER+SHIFT is already the window-manipulation
|
||||
-- space: Files, Neovim and Settings would have collided with Focus session,
|
||||
-- Taller and Shorter, and breaking two keys out of the eight-key resize set to
|
||||
-- make room is a worse trade than borrowing a modifier.
|
||||
--
|
||||
-- The go-to binds still launch when nothing is open. A key that silently does
|
||||
-- nothing is worse than one that does the obvious thing.
|
||||
--
|
||||
-- Patterns are regular expressions and are anchored. An unanchored "mail"
|
||||
-- would match gmail-notifier, and the go-to-mail key would raise somebody's
|
||||
-- notifier instead. Single-quoted for the shell so a backslash reaches the
|
||||
-- matcher rather than being eaten on the way.
|
||||
local function shell_quote(value)
|
||||
return "'" .. value:gsub("'", "'\\''") .. "'"
|
||||
end
|
||||
|
||||
local launcher_bin = "$HOME/.local/share/Panama/bin/panama-launch"
|
||||
|
||||
local function go_to(class, command, title)
|
||||
local parts = { launcher_bin, "--class", shell_quote(class) }
|
||||
if title then
|
||||
parts[#parts + 1] = "--title"
|
||||
parts[#parts + 1] = shell_quote(title)
|
||||
end
|
||||
parts[#parts + 1] = "--"
|
||||
parts[#parts + 1] = command
|
||||
return table.concat(parts, " ")
|
||||
end
|
||||
|
||||
category("Applications")
|
||||
bind(mod .. " + T", hl.dsp.exec_cmd(terminal), { description = "Terminal" })
|
||||
bind(mod .. " + N", hl.dsp.exec_cmd(editor), { description = "Neovim" })
|
||||
bind(mod .. " + W", hl.dsp.exec_cmd(browser), { description = "Browser" })
|
||||
bind(mod .. " + F", hl.dsp.exec_cmd(files), { description = "Files" })
|
||||
bind(mod .. " + C", hl.dsp.exec_cmd(calculator), { description = "Calculator" })
|
||||
bind(mod .. " + E", hl.dsp.exec_cmd(mail), { description = "Mail" })
|
||||
|
||||
-- Go to the one already open, or start it if there is none.
|
||||
bind(mod .. " + ALT + T", hl.dsp.exec_cmd(go_to("^kitty$", terminal)),
|
||||
{ description = "Go to terminal" })
|
||||
bind(mod .. " + ALT + N", hl.dsp.exec_cmd(go_to("^kitty$", editor, "nvim")),
|
||||
{ description = "Go to Neovim" })
|
||||
bind(mod .. " + ALT + W", hl.dsp.exec_cmd(go_to("^helium", browser)),
|
||||
{ description = "Go to browser" })
|
||||
bind(mod .. " + ALT + F", hl.dsp.exec_cmd(go_to("^org\\.gnome\\.Nautilus$", files)),
|
||||
{ description = "Go to files" })
|
||||
bind(mod .. " + ALT + C", hl.dsp.exec_cmd(go_to("^org\\.gnome\\.Calculator$", calculator)),
|
||||
{ description = "Go to calculator" })
|
||||
bind(mod .. " + ALT + E", hl.dsp.exec_cmd(go_to("^org\\.mozilla\\.thunderbird", mail)),
|
||||
{ description = "Go to mail" })
|
||||
bind(mod .. " + I", hl.dsp.exec_cmd(settings), { description = "Settings" })
|
||||
bind("CTRL + SHIFT + Escape", hl.dsp.exec_cmd(sysmonitor), { description = "System monitor" })
|
||||
|
||||
-- ── Launcher ────────────────────────────────────────────────────────────────
|
||||
category("Applications")
|
||||
-- All three keys open the same launcher, on purpose: SUPER+A and SUPER+R were
|
||||
-- the GNOME app-grid and run-dialog shortcuts, and SUPER+SPACE is here as a
|
||||
-- third option to settle on. Vicinae covers apps, calculator, files, clipboard,
|
||||
@@ -97,6 +219,10 @@ bind(mod .. " + Space", hl.dsp.exec_cmd(launcher), { description = "Launcher" })
|
||||
-- application without dropping to a TTY. Depends on nothing but wofi itself.
|
||||
bind(mod .. " + SHIFT + R", hl.dsp.exec_cmd("wofi"), { description = "Fallback launcher" })
|
||||
|
||||
-- Every shortcut, on one key. Slash because "what are the keys" is a question,
|
||||
-- and because it is the one punctuation key no other bind wants.
|
||||
bind(mod .. " + slash", hl.dsp.exec_cmd(qs("cheatsheet", "toggle")), { description = "Keyboard shortcuts" })
|
||||
|
||||
-- Clipboard history and emoji, straight into the relevant launcher view.
|
||||
-- Deeplink form is the one from vicinae's own Hyprland quickstart.
|
||||
bind(mod .. " + V", hl.dsp.exec_cmd("vicinae vicinae://launch/clipboard/history"),
|
||||
@@ -105,6 +231,7 @@ bind(mod .. " + Period", hl.dsp.exec_cmd("vicinae vicinae://launch/emoji/search"
|
||||
{ description = "Emoji picker" })
|
||||
|
||||
-- ── Shell surfaces (Quickshell) ─────────────────────────────────────────────
|
||||
category("Shell")
|
||||
-- SUPER+S was GNOME's quick settings; kept.
|
||||
bind(mod .. " + S", hl.dsp.exec_cmd(qs("quicksettings", "toggle")), { description = "Quick settings" })
|
||||
|
||||
@@ -118,7 +245,7 @@ bind(mod .. " + SHIFT + F", hl.dsp.exec_cmd(qs("focus", "reveal")), { descriptio
|
||||
-- which Hyprland has no equivalent for.
|
||||
bind(mod .. " + grave", hl.dsp.exec_cmd(qs("overview", "toggle")), { description = "Overview" })
|
||||
|
||||
-- Notification centre.
|
||||
-- Notification center.
|
||||
bind(mod .. " + B", hl.dsp.exec_cmd(qs("notifications", "toggle")), { description = "Notifications" })
|
||||
|
||||
-- Screenshot / screen record. One key, then pick screen / window / region and
|
||||
@@ -133,10 +260,49 @@ bind("ALT + Print", hl.dsp.exec_cmd(qs("capture", "windowNow")), { description =
|
||||
bind(mod .. " + SHIFT + S", hl.dsp.exec_cmd(qs("screen-intelligence", "open")),
|
||||
{ description = "Screen Intelligence" })
|
||||
|
||||
-- Colour picker: copies the hex under the cursor to the clipboard.
|
||||
bind(mod .. " + SHIFT + P", hl.dsp.exec_cmd("hyprpicker -a -f hex"), { description = "Colour picker" })
|
||||
-- Color picker: copies the hex under the cursor to the clipboard.
|
||||
bind(mod .. " + SHIFT + P", hl.dsp.exec_cmd("hyprpicker -a -f hex"), { description = "Color picker" })
|
||||
|
||||
-- ── Magnifier ───────────────────────────────────────────────────────────────
|
||||
category("Shell")
|
||||
--
|
||||
-- The chords are NOT the obvious SUPER+=/-/0. SUPER+equal is already "Reset
|
||||
-- split" (Window management, below), and taking a daily tiling key away to
|
||||
-- give the magnifier the prettiest chord on the keyboard is the wrong trade.
|
||||
--
|
||||
-- SUPER+ALT is where they went instead, which is also where GNOME's magnifier
|
||||
-- lives: gsettings' magnifier-zoom-in / magnifier-zoom-out ship as
|
||||
-- <Alt><Super>= and <Alt><Super>-, so this is the shortcut the machine this
|
||||
-- desktop replaced already had. SUPER+ALT+0 -- free; the workspace digits are
|
||||
-- plain ALT -- resets to 1.00 ×, reading as "back to zero magnification".
|
||||
--
|
||||
-- These go THROUGH the shell rather than calling `hyprctl keyword
|
||||
-- cursor:zoom_factor` directly. Setting the compositor option behind Panama's
|
||||
-- back would leave the stored preference and the Magnifier slider claiming a
|
||||
-- magnification that is not the one on screen; the IPC call commits through
|
||||
-- the same verified-preference path the slider uses, so the store, the
|
||||
-- compositor and the settings page can never disagree. It also posts the OSD,
|
||||
-- which is the only way to see what the factor now is with the pointer
|
||||
-- somewhere else entirely.
|
||||
--
|
||||
-- Not `repeating`: the step is multiplicative (×1.25), so a held key repeating
|
||||
-- at the keyboard rate would arrive at the 5.00 × ceiling in about a tenth of
|
||||
-- a second. One press, one step.
|
||||
--
|
||||
-- Written as literal chords rather than `mod .. " + ALT + ..."` (as
|
||||
-- "SUPER + Backspace" already is, above) because these three are the most
|
||||
-- collision-prone binds in the file -- they were placed around one -- and a
|
||||
-- literal is the form both the duplicate-chord check and the settings page's
|
||||
-- chord display can actually read.
|
||||
bind("SUPER + ALT + equal", hl.dsp.exec_cmd(qs("accessibility", "zoom in")),
|
||||
{ description = "Zoom in" })
|
||||
bind("SUPER + ALT + minus", hl.dsp.exec_cmd(qs("accessibility", "zoom out")),
|
||||
{ description = "Zoom out" })
|
||||
bind("SUPER + ALT + 0", hl.dsp.exec_cmd(qs("accessibility", "zoom reset")),
|
||||
{ description = "Reset zoom" })
|
||||
|
||||
-- ── Window management ───────────────────────────────────────────────────────
|
||||
category("Windows")
|
||||
bind(mod .. " + Q", hl.dsp.window.close(), { description = "Close window" })
|
||||
bind(mod .. " + U", hl.dsp.window.fullscreen({ mode = "fullscreen" }), { description = "Fullscreen" })
|
||||
|
||||
@@ -180,24 +346,46 @@ bind(mod .. " + CTRL + L", hl.dsp.window.swap({ direction = "r" }), { descriptio
|
||||
|
||||
-- Resize (Forge: window-resize-<edge>-<increase|decrease>).
|
||||
--
|
||||
-- Forge resized one named edge at a time. Hyprland resizes the active window
|
||||
-- along an axis and lets the layout decide which edge actually moves, so the
|
||||
-- eight Forge keys collapse onto four behaviours. The pairing is kept
|
||||
-- consistent with the original: Y/B/O/M are horizontal, I/P/U/N are vertical,
|
||||
-- and "increase" always grows while "decrease" always shrinks.
|
||||
-- Forge resized one named EDGE at a time: its resize() grows the window for a
|
||||
-- positive amount in every direction, and the edge only decides which side
|
||||
-- moves -- Y grew leftward, O grew rightward, and so on. Hyprland resizes along
|
||||
-- an axis and lets the layout choose the border, so those eight distinct
|
||||
-- behaviours collapse onto four and the direction is simply not expressible.
|
||||
--
|
||||
-- Because of that the sizes are deliberately INVERTED from Forge's naming.
|
||||
-- Carried over faithfully, "increase" grew and "decrease" shrank, which was
|
||||
-- correct on paper and wrong under the fingers: with the edge gone, the keys
|
||||
-- that used to pull a window open from one side now push it from the other.
|
||||
-- Gabriel uses these daily and reads Y/O as shrink and B/M as grow, so that is
|
||||
-- what they do. Faithfulness to a mapping nobody can feel is not worth much.
|
||||
local step = 60
|
||||
bind(mod .. " + SHIFT + Y", hl.dsp.window.resize({ x = step, y = 0, relative = true }), { repeating = true, description = "Wider" })
|
||||
bind(mod .. " + SHIFT + O", hl.dsp.window.resize({ x = step, y = 0, relative = true }), { repeating = true, description = "Wider" })
|
||||
bind(mod .. " + SHIFT + B", hl.dsp.window.resize({ x = -step, y = 0, relative = true }), { repeating = true, description = "Narrower" })
|
||||
bind(mod .. " + SHIFT + M", hl.dsp.window.resize({ x = -step, y = 0, relative = true }), { repeating = true, description = "Narrower" })
|
||||
bind(mod .. " + SHIFT + I", hl.dsp.window.resize({ x = 0, y = step, relative = true }), { repeating = true, description = "Taller" })
|
||||
bind(mod .. " + SHIFT + U", hl.dsp.window.resize({ x = 0, y = step, relative = true }), { repeating = true, description = "Taller" })
|
||||
bind(mod .. " + SHIFT + P", hl.dsp.window.resize({ x = 0, y = -step, relative = true }), { repeating = true, description = "Shorter" })
|
||||
bind(mod .. " + SHIFT + N", hl.dsp.window.resize({ x = 0, y = -step, relative = true }), { repeating = true, description = "Shorter" })
|
||||
bind(mod .. " + SHIFT + Y", hl.dsp.window.resize({ x = -step, y = 0, relative = true }), { repeating = true, description = "Narrower" })
|
||||
bind(mod .. " + SHIFT + O", hl.dsp.window.resize({ x = -step, y = 0, relative = true }), { repeating = true, description = "Narrower" })
|
||||
bind(mod .. " + SHIFT + B", hl.dsp.window.resize({ x = step, y = 0, relative = true }), { repeating = true, description = "Wider" })
|
||||
bind(mod .. " + SHIFT + M", hl.dsp.window.resize({ x = step, y = 0, relative = true }), { repeating = true, description = "Wider" })
|
||||
bind(mod .. " + SHIFT + I", hl.dsp.window.resize({ x = 0, y = -step, relative = true }), { repeating = true, description = "Shorter" })
|
||||
bind(mod .. " + SHIFT + U", hl.dsp.window.resize({ x = 0, y = -step, relative = true }), { repeating = true, description = "Shorter" })
|
||||
-- SUPER+SHIFT+P was double-bound with the color picker above; moved to
|
||||
-- Comma, which continues the bottom-row cluster (B/M/N) this axis already
|
||||
-- uses rather than landing on an arbitrary free key.
|
||||
bind(mod .. " + SHIFT + Comma", hl.dsp.window.resize({ x = 0, y = step, relative = true }), { repeating = true, description = "Taller" })
|
||||
bind(mod .. " + SHIFT + N", hl.dsp.window.resize({ x = 0, y = step, relative = true }), { repeating = true, description = "Taller" })
|
||||
|
||||
-- Window cycling (GNOME: cycle-windows on SUPER+Tab).
|
||||
bind(mod .. " + Tab", hl.dsp.window.cycle_next({ next = true }), { description = "Next window" })
|
||||
bind(mod .. " + SHIFT + Tab", hl.dsp.window.cycle_next({ next = false }), { description = "Previous window" })
|
||||
-- Window cycling (GNOME: cycle-windows on SUPER+Tab), now with an overlay
|
||||
-- showing what you are choosing between.
|
||||
--
|
||||
-- The gesture needs three binds, not two. Tab steps the selection, and the
|
||||
-- switch is only COMMITTED when the modifier is released -- which is the sole
|
||||
-- way the compositor can tell the gesture is finished. That release bind is on
|
||||
-- the bare modifier, so it fires on EVERY Super release in the session; the
|
||||
-- handler returns immediately when no switch is open, which is why this is
|
||||
-- affordable.
|
||||
--
|
||||
-- The release bind carries no description on purpose: it is not a shortcut
|
||||
-- anyone would look up or rebind, and the Shortcuts page lists what it finds.
|
||||
bind(mod .. " + Tab", hl.dsp.exec_cmd(qs("switcher", "next")), { description = "Next window" })
|
||||
bind(mod .. " + SHIFT + Tab", hl.dsp.exec_cmd(qs("switcher", "previous")), { description = "Previous window" })
|
||||
bind(mod, hl.dsp.exec_cmd(qs("switcher", "commit")), { release = true, description = "Commit window switch" })
|
||||
-- Jump back to the previously focused window.
|
||||
bind(mod .. " + SHIFT + grave", hl.dsp.focus({ last = true }), { description = "Last window" })
|
||||
|
||||
@@ -206,11 +394,23 @@ bind(mod .. " + mouse:272", hl.dsp.window.drag(), { mouse = true, description =
|
||||
bind(mod .. " + mouse:273", hl.dsp.window.resize(), { mouse = true, description = "Resize window with pointer" })
|
||||
|
||||
-- ── Workspaces ──────────────────────────────────────────────────────────────
|
||||
category("Workspaces")
|
||||
-- ALT is the workspace modifier, matching the GNOME setup.
|
||||
--
|
||||
-- Plain relative selectors ("+1" / "-1") reproduce GNOME's dynamic workspaces:
|
||||
-- moving right past the last workspace creates a new one, and moving left from
|
||||
-- the first clamps instead of wrapping.
|
||||
|
||||
-- Behavior for the relative/cyclic binds below. These are Hyprland's own
|
||||
-- `binds:` options -- not part of general/dwindle -- and have no other home
|
||||
-- in the config, so they are read here rather than in looks.lua.
|
||||
hl.config({
|
||||
binds = {
|
||||
workspace_back_and_forth = prefs.get("workspaceBackAndForth", false),
|
||||
allow_workspace_cycles = prefs.get("allowWorkspaceCycles", false),
|
||||
},
|
||||
})
|
||||
|
||||
bind("ALT + H", hl.dsp.focus({ workspace = "-1" }), { description = "Workspace left" })
|
||||
bind("ALT + L", hl.dsp.focus({ workspace = "+1" }), { description = "Workspace right" })
|
||||
bind("ALT + SHIFT + H", hl.dsp.window.move({ workspace = "-1" }), { description = "Move window to workspace left" })
|
||||
@@ -233,26 +433,29 @@ end
|
||||
bind(mod .. " + mouse_down", hl.dsp.focus({ workspace = "+1" }), { description = "Workspace right" })
|
||||
bind(mod .. " + mouse_up", hl.dsp.focus({ workspace = "-1" }), { description = "Workspace left" })
|
||||
|
||||
-- Minimise, as far as Hyprland has one.
|
||||
-- Minimize, as far as Hyprland has one.
|
||||
--
|
||||
-- Hyprland has no minimise: it receives the request (the binary has
|
||||
-- Hyprland has no minimize: it receives the request (the binary has
|
||||
-- setSetMinimized handlers for xdg, XWayland and foreign-toplevel) but exposes
|
||||
-- no dispatcher, no config option and not even an event to hook, so titlebar
|
||||
-- minimise buttons are inert and cannot be made to work. A tiling WM has no
|
||||
-- minimize buttons are inert and cannot be made to work. A tiling WM has no
|
||||
-- iconified state and no taskbar to restore from.
|
||||
--
|
||||
-- The scratchpad is the honest equivalent: the window goes away, and the same
|
||||
-- key brings it back. Bound to X to match the muscle memory it replaces.
|
||||
bind(mod .. " + X", hl.dsp.workspace.toggle_special("scratch"), { description = "Toggle scratchpad (restore minimised)" })
|
||||
bind(mod .. " + SHIFT + X", hl.dsp.window.move({ workspace = "special:scratch" }), { description = "Minimise to scratchpad" })
|
||||
bind(mod .. " + X", hl.dsp.workspace.toggle_special("scratch"), { description = "Toggle scratchpad (restore minimized)" })
|
||||
bind(mod .. " + SHIFT + X", hl.dsp.window.move({ workspace = "special:scratch" }), { description = "Minimize to scratchpad" })
|
||||
|
||||
-- ── Session ─────────────────────────────────────────────────────────────────
|
||||
category("Session")
|
||||
-- GNOME's lock was SUPER+L, which is "focus right" here, so lock moves to
|
||||
-- CTRL+ALT+L -- the other binding most people already have in muscle memory.
|
||||
bind("CTRL + ALT + L", hl.dsp.exec_cmd("loginctl lock-session"), { description = "Lock" })
|
||||
bind("SUPER + Backspace", hl.dsp.exec_cmd("loginctl lock-session"), { description = "Lock" })
|
||||
bind("CTRL + ALT + Delete", hl.dsp.exec_cmd(qs("powermenu", "toggle")), { description = "Power menu" })
|
||||
|
||||
-- ── Media and volume ────────────────────────────────────────────────────────
|
||||
category("Media & hardware")
|
||||
-- locked = true keeps these working on the lock screen, as they do in GNOME.
|
||||
-- 6% steps match the GNOME volume-step setting.
|
||||
bind("XF86AudioRaiseVolume", hl.dsp.exec_cmd(osd("volume up 6")), { locked = true, repeating = true , description = "Volume up" })
|
||||
@@ -260,6 +463,31 @@ bind("XF86AudioLowerVolume", hl.dsp.exec_cmd(osd("volume down 6")), { locked = t
|
||||
bind("XF86AudioMute", hl.dsp.exec_cmd(osd("volume toggle")), { locked = true , description = "Mute" })
|
||||
bind("XF86AudioMicMute", hl.dsp.exec_cmd(osd("microphone toggle")), { locked = true , description = "Mute microphone" })
|
||||
|
||||
-- ── Dictation ───────────────────────────────────────────────────────────────
|
||||
category("Shell")
|
||||
--
|
||||
-- Hold to talk, exactly like push-to-talk anywhere else: the mic is open only
|
||||
-- while the key is down, so it cannot be left listening by forgetting about it.
|
||||
-- Two binds on one chord, the second flagged `release`.
|
||||
--
|
||||
-- No `repeating`: holding a key normally repeats the press, which would restart
|
||||
-- the recording several times a second. The daemon refuses a second start while
|
||||
-- one is running, so a repeat would be harmless -- but not asking for it is
|
||||
-- better than relying on being refused.
|
||||
bind(mod .. " + D", hl.dsp.exec_cmd(dictate("start")),
|
||||
{ description = "Dictate (hold to talk)" })
|
||||
bind(mod .. " + D", hl.dsp.exec_cmd(dictate("stop")),
|
||||
{ release = true, description = "Dictate (transcribe on release)" })
|
||||
|
||||
-- Escape out of a recording without transcribing it. Bound to the same modifier
|
||||
-- so it can be reached with the dictation key still held.
|
||||
bind(mod .. " + SHIFT + D", hl.dsp.exec_cmd(dictate("cancel")),
|
||||
{ description = "Cancel dictation" })
|
||||
|
||||
-- Back to media: the dictation binds sit here for historical reasons, and the
|
||||
-- category has to be set again or everything below inherits theirs.
|
||||
category("Media & hardware")
|
||||
|
||||
-- Fine-grained steps, matching GNOME's shift/alt volume modifiers.
|
||||
bind("SHIFT + XF86AudioRaiseVolume", hl.dsp.exec_cmd(osd("volume up 1")), { locked = true, repeating = true , description = "Volume up (fine)" })
|
||||
bind("SHIFT + XF86AudioLowerVolume", hl.dsp.exec_cmd(osd("volume down 1")), { locked = true, repeating = true , description = "Volume down (fine)" })
|
||||
@@ -273,6 +501,72 @@ bind("XF86AudioStop", hl.dsp.exec_cmd(osd("media stop")), { locked = true , desc
|
||||
bind("XF86MonBrightnessUp", hl.dsp.exec_cmd(osd("brightness up 5")), { locked = true, repeating = true , description = "Brightness up" })
|
||||
bind("XF86MonBrightnessDown", hl.dsp.exec_cmd(osd("brightness down 5")), { locked = true, repeating = true , description = "Brightness down" })
|
||||
|
||||
-- Airplane mode. The Framework's F10 emits exactly this keysym, and for a
|
||||
-- while it emitted it into silence. panama-osd owns the toggle so the OSD
|
||||
-- can say which way it went.
|
||||
bind("XF86RFKill", hl.dsp.exec_cmd(osd("airplane toggle")), { locked = true, description = "Airplane mode" })
|
||||
|
||||
-- F9 on the same row. GNOME shows a display-switching OSD here; until
|
||||
-- mirroring exists (DESKTOP-PARITY gap), the honest action is the page where
|
||||
-- displays are actually arranged.
|
||||
bind("XF86Display", hl.dsp.exec_cmd("qs ipc call settings page displays"), { description = "Display settings" })
|
||||
|
||||
-- ── The power button ────────────────────────────────────────────────────────
|
||||
category("Media & hardware")
|
||||
--
|
||||
-- logind is told to ignore the power key (config/copy ships the drop-in) so a
|
||||
-- stray press is a question, not an instant poweroff. That makes what the
|
||||
-- question IS Panama's to choose, and `powerButtonAction` is where the choice
|
||||
-- is recorded. Until the next boot after that drop-in lands, logind still acts
|
||||
-- on the key; this bind costs nothing extra then.
|
||||
--
|
||||
-- The branch runs ON EVERY PRESS rather than here at config time.
|
||||
--
|
||||
-- A config-time branch would be shorter -- `prefs.get` and four `if`s -- and it
|
||||
-- would also make this the one control on the Power page that does nothing
|
||||
-- until the compositor is reloaded. Every other setting in Panama applies as
|
||||
-- you change it, and a power button that ignores what the settings app says it
|
||||
-- does is a worse thing to ship than a long command string. So the bind is a
|
||||
-- `case` over what the settings file says at the moment the key goes down.
|
||||
--
|
||||
-- Everything that can go wrong lands on the shipped default: no jq, no file, a
|
||||
-- truncated file, or a value nobody recognises all fall through to `*)` and
|
||||
-- open the menu. The failure direction is "the power button opens a menu",
|
||||
-- never "the power button does something you did not ask for".
|
||||
--
|
||||
-- Powering off goes THROUGH the menu with Power Off pre-armed rather than
|
||||
-- calling `systemctl poweroff` here. The menu's two-press confirm is what
|
||||
-- stands between a pocketed key and an unsaved afternoon, and a direct
|
||||
-- poweroff would quietly throw it away -- so a person who picks "Powers off"
|
||||
-- gets a fast poweroff, not an unguarded one.
|
||||
local power_button = {
|
||||
menu = qs("powermenu", "toggle"),
|
||||
suspend = "systemctl suspend",
|
||||
poweroff = qs("powermenu", "open") .. " poweroff",
|
||||
nothing = ":",
|
||||
}
|
||||
|
||||
local power_button_command = table.concat({
|
||||
[[case "$(jq -r '.powerButtonAction // empty' "${XDG_CONFIG_HOME:-$HOME/.config}/panama/settings.json" 2>/dev/null)" in]],
|
||||
"suspend) " .. power_button.suspend .. " ;;",
|
||||
"poweroff) " .. power_button.poweroff .. " ;;",
|
||||
"nothing) " .. power_button.nothing .. " ;;",
|
||||
"*) " .. power_button.menu .. " ;;",
|
||||
"esac",
|
||||
}, " ")
|
||||
|
||||
bind("XF86PowerOff", hl.dsp.exec_cmd(power_button_command),
|
||||
{ locked = true, description = "Power button" })
|
||||
|
||||
-- The lid, as a switch rather than a key. Closing a docked lid turns the
|
||||
-- internal panel off so nothing renders inside a closed shell and no
|
||||
-- workspace strands on an invisible output; opening it turns the panel back
|
||||
-- on. panama-lid owns the decision -- undocked machines suspend via logind
|
||||
-- before this matters, and the guard's inhibitor handles staying awake.
|
||||
bind("switch:on:Lid Switch", hl.dsp.exec_cmd(lid("close")), { locked = true, description = "Lid closed" })
|
||||
bind("switch:off:Lid Switch", hl.dsp.exec_cmd(lid("open")), { locked = true, description = "Lid opened" })
|
||||
|
||||
category("Applications")
|
||||
-- Hardware keys GNOME mapped that have obvious equivalents.
|
||||
bind("XF86Tools", hl.dsp.exec_cmd(settings), { description = "Settings" })
|
||||
bind("XF86Calculator", hl.dsp.exec_cmd(calculator), { description = "Calculator" })
|
||||
@@ -281,4 +575,52 @@ bind("XF86WWW", hl.dsp.exec_cmd(browser), { description = "Browser" })
|
||||
bind("XF86Mail", hl.dsp.exec_cmd(mail), { description = "Mail" })
|
||||
bind("XF86Search", hl.dsp.exec_cmd(launcher), { description = "Launcher" })
|
||||
|
||||
-- ── Custom shortcuts ────────────────────────────────────────────────────────
|
||||
--
|
||||
-- Shortcuts the user invented, from `customBinds` in settings.json. Each entry
|
||||
-- is { chord, kind, target, label } -- data, never a command. actions.lua turns
|
||||
-- the kind/target pair into a dispatcher through whitelist tables; an entry it
|
||||
-- does not recognise resolves to nil and is silently not emitted.
|
||||
--
|
||||
-- Emitted LAST, and through hl.bind rather than the `bind` wrapper above. Two
|
||||
-- separate reasons, both about keeping the two rebinding mechanisms apart:
|
||||
--
|
||||
-- * `keybindOverrides` is keyed by a SHIPPED chord. A custom bind has no
|
||||
-- shipped chord -- it is rebound by rewriting its own entry -- so putting
|
||||
-- one through `bind` would let an override for some shipped key silently
|
||||
-- move a custom one that happened to share a chord.
|
||||
-- * last means a custom chord that collides with a shipped one loses, which
|
||||
-- is checked explicitly below rather than left to Hyprland's ordering.
|
||||
--
|
||||
-- Settings prevents a collision upstream; this is the second lock, because the
|
||||
-- file is hand-editable and losing a shipped key to a typo is not acceptable.
|
||||
category("Custom")
|
||||
|
||||
local function custom_bind(chord, dispatcher, label)
|
||||
categories[chord] = current_category
|
||||
return hl.bind(chord, dispatcher, { description = label })
|
||||
end
|
||||
|
||||
for _, entry in ipairs(prefs.get("customBinds", {})) do
|
||||
if type(entry) == "table" then
|
||||
local chord = entry.chord
|
||||
local label = entry.label
|
||||
|
||||
-- A description is not decoration: keybinds-contract fails a build
|
||||
-- with a description-less bind, and the cheatsheet and Shortcuts page
|
||||
-- both list what they find. A nameless shortcut is unfindable.
|
||||
if valid_chord(chord)
|
||||
and type(label) == "string" and label ~= ""
|
||||
and categories[chord] == nil
|
||||
then
|
||||
local dispatcher = actions.dispatcher(entry)
|
||||
if dispatcher ~= nil then
|
||||
custom_bind(chord, dispatcher, label)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
write_categories()
|
||||
|
||||
return true
|
||||
|
||||
+121
-18
@@ -1,8 +1,8 @@
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
-- Look and feel -- Tokyo Night Moon
|
||||
--
|
||||
-- Colours here must stay in sync with quickshell/config/Theme.qml.
|
||||
-- accent #82aaff borders / focus
|
||||
-- Colors here must stay in sync with quickshell/config/Theme.qml.
|
||||
-- accent user-selectable, see `accents` below -- blue (#82aaff) ships
|
||||
-- bg #222436 base
|
||||
--
|
||||
-- Performance note: every animation below is event-driven. Nothing uses the
|
||||
@@ -14,6 +14,76 @@
|
||||
|
||||
local prefs = require("prefs")
|
||||
|
||||
-- The eight accents, from config/palette.json -- the one place they are
|
||||
-- written down for everything outside QML. They used to be restated here,
|
||||
-- which made adding a ninth accent a five-file edit; the file that got
|
||||
-- forgotten was always the one that fails silently.
|
||||
--
|
||||
-- Falls back to the shipped blue pair if the palette cannot be read, for the
|
||||
-- same reason prefs never raises: a missing file costs the accent colour, not
|
||||
-- the compositor config.
|
||||
local palette = prefs.readJson(os.getenv("HOME") .. "/.config/quickshell/config/palette.json")
|
||||
local accents = palette.accents or {
|
||||
blue = { dark = "82aaff", darkSecondary = "b172b0", light = "2e7de9", lightSecondary = "9854f1" },
|
||||
}
|
||||
|
||||
-- The accent pair a fresh session or `hyprctl reload` starts from.
|
||||
-- services/ColorScheme.qml overwrites this live, from the same Theme.accents
|
||||
-- data, once the shell settles (~1.2s after startup -- see its `settle`
|
||||
-- Timer). This table exists only so the compositor is never observably blue
|
||||
-- for a non-blue accent during the gap before that first live apply.
|
||||
local accentScheme = prefs.get("colorScheme", "dark")
|
||||
local accentPair = accents[prefs.get("accentName", "blue")] or accents.blue
|
||||
local accentStart = accentScheme == "light" and accentPair.light or accentPair.dark
|
||||
local accentEnd = accentScheme == "light" and accentPair.lightSecondary or accentPair.darkSecondary
|
||||
|
||||
-- ── Color filters ───────────────────────────────────────────────────────────
|
||||
--
|
||||
-- Grayscale and three color-blindness corrections, as end-of-pipe screen
|
||||
-- shaders. Hyprland composites the desktop and then runs one fragment shader
|
||||
-- over the result, so a filter here covers every window, the shell, the cursor
|
||||
-- and video alike -- which is the only way a filter is honest.
|
||||
--
|
||||
-- The PREFERENCE is the enum, not the path. That split is deliberate: storing
|
||||
-- the path would put a filesystem location a person can edit into the value
|
||||
-- that becomes `decoration:screen_shader`, and it would make the stored value
|
||||
-- disagree with what hyprctl reports back (which is the path), failing the
|
||||
-- schema shape and write-sweep contracts. So PreferenceSchema's colorFilter
|
||||
-- entry carries no `hypr:` block, and the enum→path mapping is written twice
|
||||
-- on purpose: here, for reloads and for the moment before the shell starts,
|
||||
-- and in services/SystemSettings.qml's applyColorFilter for the live apply.
|
||||
-- The two lists have to be edited together.
|
||||
--
|
||||
-- "none" and anything unrecognised both produce the empty string, which is
|
||||
-- what Hyprland reads as "no shader" -- and is the value it needs to be given
|
||||
-- to turn one OFF, since there is no way to unset the option.
|
||||
local shaderDir = (function()
|
||||
local configHome = os.getenv("XDG_CONFIG_HOME")
|
||||
if configHome == nil or configHome == "" then
|
||||
local home = os.getenv("HOME")
|
||||
if home == nil or home == "" then
|
||||
return nil
|
||||
end
|
||||
configHome = home .. "/.config"
|
||||
end
|
||||
return configHome .. "/hypr/shaders"
|
||||
end)()
|
||||
|
||||
local colorFilters = {
|
||||
grayscale = "grayscale.frag",
|
||||
protanopia = "protanopia.frag",
|
||||
deuteranopia = "deuteranopia.frag",
|
||||
tritanopia = "tritanopia.frag",
|
||||
}
|
||||
|
||||
local colorFilterShader = ""
|
||||
do
|
||||
local file = colorFilters[prefs.get("colorFilter", "none")]
|
||||
if file ~= nil and shaderDir ~= nil then
|
||||
colorFilterShader = shaderDir .. "/" .. file
|
||||
end
|
||||
end
|
||||
|
||||
hl.config({
|
||||
general = {
|
||||
gaps_in = prefs.get("gapsIn", 5),
|
||||
@@ -22,16 +92,17 @@ hl.config({
|
||||
border_size = prefs.get("borderSize", 2),
|
||||
|
||||
col = {
|
||||
-- The prism: blue leads, orchid follows, on a diagonal so the pair
|
||||
-- is visible on both a tall and a wide window. Same two colours as
|
||||
-- the shell's hairline (quickshell/widgets/PrismEdge.qml) and the
|
||||
-- tmux theme this palette came from.
|
||||
active_border = { colors = { "rgba(82aaffee)", "rgba(b172b0ee)" }, angle = 115 },
|
||||
-- Unfocused windows get no colour at all. The gradient only means
|
||||
-- something if exactly one window on screen is wearing it.
|
||||
-- Follows the colour scheme: a dark neutral is invisible against a
|
||||
-- light desktop. services/ColorScheme.qml applies changes live;
|
||||
-- this is the value a fresh session starts from.
|
||||
-- The focused accent role, on a diagonal so the pair is visible
|
||||
-- on both a tall and a wide window. Driven by the chosen
|
||||
-- accentName (see the `accents` table above); services/
|
||||
-- ColorScheme.qml applies the same values live, and restates them
|
||||
-- from Theme.accent/Theme.accentSecondary on every scheme change
|
||||
-- too, since each accent carries a separate pair per scheme.
|
||||
active_border = { colors = { "rgba(" .. accentStart .. "ee)", "rgba(" .. accentEnd .. "ee)" }, angle = 115 },
|
||||
-- The neutral inactive role follows the color scheme because a
|
||||
-- dark neutral disappears against a light desktop.
|
||||
-- services/ColorScheme.qml applies the same values live; this is
|
||||
-- the value a fresh session starts from.
|
||||
inactive_border = prefs.get("colorScheme", "dark") == "light"
|
||||
and "rgba(a8aecb99)" or "rgba(3b426199)",
|
||||
},
|
||||
@@ -44,10 +115,10 @@ hl.config({
|
||||
-- Harmless on its own; tearing only happens where a rule opts in.
|
||||
allow_tearing = true,
|
||||
|
||||
layout = "dwindle",
|
||||
layout = prefs.get("windowLayout", "dwindle"),
|
||||
|
||||
snap = {
|
||||
enabled = true,
|
||||
enabled = prefs.get("windowSnapping", true),
|
||||
window_gap = prefs.getInt("snapWindowGap", 10),
|
||||
monitor_gap = prefs.getInt("snapMonitorGap", 10),
|
||||
respect_gaps = prefs.get("snapRespectGaps", false),
|
||||
@@ -64,6 +135,10 @@ hl.config({
|
||||
fullscreen_opacity = prefs.get("fullscreenOpacity", 1.0),
|
||||
inactive_opacity = prefs.get("inactiveOpacity", 1.0),
|
||||
|
||||
-- Accessibility: dim every window but the focused one.
|
||||
dim_inactive = prefs.get("dimInactive", false),
|
||||
dim_strength = prefs.get("dimStrength", 0.5),
|
||||
|
||||
blur = {
|
||||
enabled = prefs.get("blurEnabled", true),
|
||||
size = prefs.get("blurSize", 8),
|
||||
@@ -105,16 +180,23 @@ hl.config({
|
||||
-- New in 0.56. Kept deliberately faint: in this direction the gradient
|
||||
-- border is the signature, and a strong halo would compete with it.
|
||||
-- This is just enough to lift the focused window off the wallpaper.
|
||||
-- Derived from the same accent as active_border above, not hardcoded,
|
||||
-- so the halo never disagrees with the border it surrounds.
|
||||
glow = {
|
||||
enabled = prefs.get("glowEnabled", true),
|
||||
range = prefs.get("glowRange", 8),
|
||||
render_power = 2,
|
||||
color = "rgba(82aaff33)",
|
||||
color = "rgba(" .. accentStart .. "33)",
|
||||
color_inactive = "rgba(00000000)",
|
||||
},
|
||||
|
||||
-- Off: costs real frame time and reads as smeary on a 60Hz panel.
|
||||
motion_blur = { enabled = false },
|
||||
|
||||
-- The accessibility color filter, resolved above. Empty when off, and
|
||||
-- empty costs nothing: Hyprland skips the pass entirely rather than
|
||||
-- running an identity shader.
|
||||
screen_shader = colorFilterShader,
|
||||
},
|
||||
|
||||
animations = { enabled = prefs.get("animationsEnabled", true) },
|
||||
@@ -124,6 +206,7 @@ hl.config({
|
||||
-- to how the Forge extension behaved on GNOME.
|
||||
preserve_split = prefs.get("preserveSplit", true),
|
||||
smart_resizing = true,
|
||||
force_split = prefs.getInt("forceSplit", 0),
|
||||
},
|
||||
|
||||
-- Only in effect when the tiling layout is "master". Panama ships dwindle,
|
||||
@@ -166,12 +249,28 @@ hl.config({
|
||||
-- Let a crashed lock screen be recovered rather than stranding you.
|
||||
allow_session_lock_restore = true,
|
||||
|
||||
-- Don't let apps steal focus by shouting; matches GNOME's behaviour.
|
||||
focus_on_activate = false,
|
||||
-- Don't let apps steal focus by shouting; matches GNOME's behavior.
|
||||
focus_on_activate = prefs.get("focusOnActivate", false),
|
||||
|
||||
-- Whether pointing at another monitor is enough to move focus there,
|
||||
-- or it takes a click. GNOME moves on pointer; both are offered.
|
||||
mouse_move_focuses_monitor = prefs.get("mouseMoveFocusesMonitor", true),
|
||||
|
||||
-- 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 = {
|
||||
-- Colour management is stable and on by default in 0.56.
|
||||
-- Color management is stable and on by default in 0.56.
|
||||
cm_enabled = true,
|
||||
|
||||
-- 1 = automatically flip the monitor into HDR for fullscreen content
|
||||
@@ -199,6 +298,10 @@ hl.config({
|
||||
|
||||
-- Fade the cursor out after 4s of no movement, like GNOME does.
|
||||
inactive_timeout = prefs.get("cursorInactiveTimeout", 4),
|
||||
|
||||
-- The accessibility magnifier. 1.0 = off.
|
||||
zoom_factor = prefs.get("magnifierFactor", 1.0),
|
||||
zoom_rigid = prefs.get("magnifierRigid", false),
|
||||
},
|
||||
|
||||
ecosystem = {
|
||||
|
||||
+216
-39
@@ -1,7 +1,7 @@
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
-- Monitors
|
||||
--
|
||||
-- Kuycon P20 on DP-2: 4500x3000 @ 60Hz, 1.5x fractional scale.
|
||||
-- Kuycon P20 (matched by description): 4500x3000 @ 60Hz, 1.5x fractional scale.
|
||||
-- 4500/1.5 = 3000 and 3000/1.5 = 2000, both integers, so this is a "clean"
|
||||
-- fractional scale and Hyprland will not complain.
|
||||
--
|
||||
@@ -13,19 +13,32 @@
|
||||
-- than an HDR desktop, the desktop runs SDR at 10-bit and HDR is handed to
|
||||
-- fullscreen games only, via render.cm_auto_hdr in looks.lua.
|
||||
--
|
||||
-- To try full-time HDR anyway, set cm = "hdr" below (or override it in
|
||||
-- overrides.lua) and read the notes in that file first.
|
||||
-- To try full-time HDR anyway, set shipped_cm = "hdr" below (or pick HDR for
|
||||
-- this display in Settings, which saves it as the display's colorProfile and
|
||||
-- wins over the shipped value) and read the notes in overrides.lua first.
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
local prefs = require("prefs")
|
||||
|
||||
-- Per-output overrides written by Panama Settings, keyed by output name:
|
||||
-- { ["DP-2"] = { mode = "3840x2160@60", scale = 2, transform = 0 } }
|
||||
-- { ["DP-2"] = {
|
||||
-- mode = "3840x2160@60", scale = 2, transform = 0,
|
||||
-- x = 0, y = 0, primary = true,
|
||||
-- vrrMode = -1, colorProfile = "auto", bitdepth = 10,
|
||||
-- sdrBrightness = 1, sdrSaturation = 1, mirrorOf = "",
|
||||
-- } }
|
||||
--
|
||||
-- Only mode, scale, and transform are read. Colour management and bit depth
|
||||
-- stay here, because those are the settings with a documented reason attached
|
||||
-- (see the header) rather than preferences, and a settings page has no way to
|
||||
-- explain the screencopy tradeoff at the moment you would be changing it.
|
||||
-- The fields past `primary` are optional: records written before they existed
|
||||
-- carry none of them, and the shipped values below stand instead. That is the
|
||||
-- relationship in both directions -- what is written here is what a saved
|
||||
-- record inherits, and what Settings saves is what overrides it, so the two
|
||||
-- stop fighting over the same monitor rule.
|
||||
--
|
||||
-- Geometry and colour fail differently on purpose. A half-written position is
|
||||
-- refused outright (below), because guessing one can strand an output where
|
||||
-- nothing can reach it. An unreadable colour, VRR or mirror value is dropped
|
||||
-- on its own and the shipped default stands: the worst it costs is a wrong
|
||||
-- shade, and taking the whole record down with it would cost the arrangement.
|
||||
local displays = prefs.get("displays", {})
|
||||
if type(displays) ~= "table" then
|
||||
displays = {}
|
||||
@@ -71,6 +84,26 @@ local function valid_transform(transform)
|
||||
and transform <= 3
|
||||
end
|
||||
|
||||
local function valid_coordinate(value)
|
||||
return type(value) == "number"
|
||||
and value == value
|
||||
and value == math.floor(value)
|
||||
and value >= -100000
|
||||
and value <= 100000
|
||||
end
|
||||
|
||||
local function valid_position(entry)
|
||||
return valid_coordinate(entry.x) and valid_coordinate(entry.y)
|
||||
end
|
||||
|
||||
local function valid_primary(entry)
|
||||
return type(entry.primary) == "boolean"
|
||||
end
|
||||
|
||||
local function has_layout_fields(entry)
|
||||
return entry.x ~= nil or entry.y ~= nil or entry.primary ~= nil
|
||||
end
|
||||
|
||||
local function display_entry(output)
|
||||
if type(output) ~= "string" or output == ""
|
||||
or output:match("^[%w_.-]+$") == nil then
|
||||
@@ -85,47 +118,148 @@ local function display_entry(output)
|
||||
or not valid_transform(entry.transform) then
|
||||
return nil
|
||||
end
|
||||
-- Legacy records have none of the layout fields and keep automatic
|
||||
-- placement. A partially written extended record is unsafe: accepting its
|
||||
-- mode but guessing its position could overlap or strand another output.
|
||||
if has_layout_fields(entry)
|
||||
and (not valid_position(entry) or not valid_primary(entry)) then
|
||||
return nil
|
||||
end
|
||||
return entry
|
||||
end
|
||||
|
||||
local function display_position(entry, fallback)
|
||||
if entry ~= nil and has_layout_fields(entry) then
|
||||
return string.format("%dx%d", entry.x, entry.y)
|
||||
end
|
||||
return fallback
|
||||
end
|
||||
|
||||
local color_profiles = { auto = true, srgb = true, wide = true, hdr = true }
|
||||
|
||||
local function color_profile(entry, fallback)
|
||||
local value = entry ~= nil and entry.colorProfile or nil
|
||||
if type(value) == "string" and color_profiles[value] then
|
||||
return value
|
||||
end
|
||||
return fallback
|
||||
end
|
||||
|
||||
local function bitdepth_value(entry, fallback)
|
||||
local value = entry ~= nil and entry.bitdepth or nil
|
||||
if value == 8 or value == 10 then
|
||||
return value
|
||||
end
|
||||
return fallback
|
||||
end
|
||||
|
||||
-- -1 means the display follows the global misc.vrr policy, which is said by
|
||||
-- leaving the key out. 3 is the global policy's own value and not a per-display
|
||||
-- choice, so it is not accepted here either.
|
||||
local function vrr_value(entry)
|
||||
local value = entry ~= nil and entry.vrrMode or nil
|
||||
if type(value) ~= "number" or value ~= math.floor(value)
|
||||
or value < 0 or value > 2 then
|
||||
return nil
|
||||
end
|
||||
return value
|
||||
end
|
||||
|
||||
-- Neutral is 1.0, and the neutral value is left out rather than written: a rule
|
||||
-- that names it pins the display to it, which is not the same as leaving the
|
||||
-- trim alone.
|
||||
local function sdr_value(value, minimum, maximum)
|
||||
if type(value) ~= "number" or value ~= value
|
||||
or value < minimum or value > maximum
|
||||
or math.abs(value - 1) < 0.001 then
|
||||
return nil
|
||||
end
|
||||
return value
|
||||
end
|
||||
|
||||
-- A mirror needs a target that is not itself and not another mirror -- Hyprland
|
||||
-- has no chain to follow -- and the primary may not mirror at all, since the
|
||||
-- arrangement is anchored on it.
|
||||
local function mirror_value(entry, output)
|
||||
local value = entry ~= nil and entry.mirrorOf or nil
|
||||
if type(value) ~= "string" or value == "" or value == output
|
||||
or value:match("^[%w_.-]+$") == nil
|
||||
or entry.primary == true then
|
||||
return nil
|
||||
end
|
||||
local target = displays[value]
|
||||
if type(target) == "table" and type(target.mirrorOf) == "string"
|
||||
and target.mirrorOf ~= "" then
|
||||
return nil
|
||||
end
|
||||
return value
|
||||
end
|
||||
|
||||
-- Colour, VRR and mirroring layered onto a rule that already carries
|
||||
-- mode/position/scale/transform. A monitor rule replaces the previous rule for
|
||||
-- that output whole, so the shipped defaults are passed in here rather than
|
||||
-- written in a rule of their own. `connector` is the output name the record was
|
||||
-- saved under, which is not always the rule's own output: the Kuycon rule
|
||||
-- matches by description.
|
||||
local function with_display_fields(rule, entry, connector, default_bitdepth, default_cm)
|
||||
rule.bitdepth = bitdepth_value(entry, default_bitdepth)
|
||||
rule.cm = color_profile(entry, default_cm)
|
||||
rule.vrr = vrr_value(entry)
|
||||
rule.sdrbrightness = entry ~= nil and sdr_value(entry.sdrBrightness, 0.8, 2.0) or nil
|
||||
rule.sdrsaturation = entry ~= nil and sdr_value(entry.sdrSaturation, 0.8, 1.2) or nil
|
||||
|
||||
-- A mirror shows its target's picture in its target's place, so the saved
|
||||
-- position is not the compositor's to honour or ours to ask for.
|
||||
local mirror = mirror_value(entry, connector)
|
||||
if mirror ~= nil then
|
||||
rule.mirror = mirror
|
||||
rule.position = "auto"
|
||||
end
|
||||
return rule
|
||||
end
|
||||
|
||||
-- Every connected output uses the same validated per-output store. Automatic
|
||||
-- placement and the compositor's normal color policy unless the entry says
|
||||
-- otherwise.
|
||||
for output, _ in pairs(displays) do
|
||||
local entry = display_entry(output)
|
||||
if entry ~= nil then
|
||||
hl.monitor(with_display_fields({
|
||||
output = output,
|
||||
mode = entry.mode,
|
||||
position = display_position(entry, "auto"),
|
||||
scale = entry.scale,
|
||||
transform = entry.transform,
|
||||
}, entry, output, nil, nil))
|
||||
end
|
||||
end
|
||||
|
||||
-- The Kuycon P20, matched by what it is rather than where it is plugged in.
|
||||
-- This used to be a rule for connector DP-2 outright, which handed the panel's
|
||||
-- 4500x3000 mode and 1.5 scale to whatever monitor a stranger's machine had on
|
||||
-- its most common DisplayPort connector. Emitted after the prefs loop so a
|
||||
-- saved entry for its connector still carries the mode/scale/position, while
|
||||
-- this rule holds the shipped defaults and the panel-specific color policy.
|
||||
local shipped_mode = "4500x3000@60"
|
||||
local shipped_scale = 1.5
|
||||
local shipped_transform = 0
|
||||
local dp2 = display_entry("DP-2")
|
||||
|
||||
hl.monitor({
|
||||
output = "DP-2",
|
||||
mode = dp2 and dp2.mode or shipped_mode,
|
||||
position = "0x0",
|
||||
scale = dp2 and dp2.scale or shipped_scale,
|
||||
transform = dp2 and dp2.transform or shipped_transform,
|
||||
|
||||
-- 10-bit output. 4500x3000@60 at 10bpc is ~24 Gbps, right at the edge of
|
||||
-- DP 1.4 HBR3, so this relies on DSC. If the display fails to light up or
|
||||
-- falls back to a lower mode, drop this line first.
|
||||
bitdepth = 10,
|
||||
-- 10-bit output. 4500x3000@60 at 10bpc is ~24 Gbps, right at the edge of DP 1.4
|
||||
-- HBR3, so this relies on DSC. If the display fails to light up or falls back to
|
||||
-- a lower mode, drop this to 8 first.
|
||||
local shipped_bitdepth = 10
|
||||
|
||||
-- "auto" = sRGB at 8bpc, wide gamut at 10bpc. Not HDR; see header.
|
||||
cm = "auto",
|
||||
})
|
||||
local shipped_cm = "auto"
|
||||
|
||||
-- Other connected outputs use the same validated per-output store. They keep
|
||||
-- automatic placement and the compositor's normal colour policy; DP-2 alone
|
||||
-- carries the panel-specific 10-bit policy documented above.
|
||||
for output, _ in pairs(displays) do
|
||||
if output ~= "DP-2" then
|
||||
local entry = display_entry(output)
|
||||
if entry ~= nil then
|
||||
hl.monitor({
|
||||
output = output,
|
||||
mode = entry.mode,
|
||||
position = "auto",
|
||||
scale = entry.scale,
|
||||
transform = entry.transform,
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
local kuycon = display_entry("DP-2")
|
||||
hl.monitor(with_display_fields({
|
||||
output = "desc:GVT Kuycon P20",
|
||||
mode = kuycon and kuycon.mode or shipped_mode,
|
||||
position = display_position(kuycon, "0x0"),
|
||||
scale = kuycon and kuycon.scale or shipped_scale,
|
||||
transform = kuycon and kuycon.transform or shipped_transform,
|
||||
}, kuycon, "DP-2", shipped_bitdepth, shipped_cm))
|
||||
|
||||
-- Any monitor not named above: sane defaults rather than nothing.
|
||||
hl.monitor({
|
||||
@@ -135,4 +269,47 @@ hl.monitor({
|
||||
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
|
||||
-- Only a record display_entry accepts counts. A half-written entry is one
|
||||
-- the monitor rules above already refuse, so pinning ten workspaces to it on
|
||||
-- the strength of a `primary` flag nothing else trusts would put them on a
|
||||
-- screen that never got a rule of its own.
|
||||
local primaries = {}
|
||||
for output, _ in pairs(displays) do
|
||||
local entry = display_entry(output)
|
||||
if entry ~= nil and entry.primary == true then
|
||||
primaries[#primaries + 1] = output
|
||||
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. Two records
|
||||
-- both claiming primary is the same problem wearing a different hat: pairs()
|
||||
-- has no order, so picking one of them would pin the workspaces to a
|
||||
-- different screen from one reload to the next. Neither case guesses.
|
||||
if #primaries == 1 then
|
||||
local primary = primaries[1]
|
||||
for i = 1, 10 do
|
||||
hl.workspace_rule({ workspace = tostring(i), monitor = primary })
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return true
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
-- cannot drift apart.
|
||||
--
|
||||
-- Nothing here may raise. A missing, empty, truncated, or actively malformed
|
||||
-- file must cost the user nothing worse than their customisations; it must
|
||||
-- file must cost the user nothing worse than their customizations; it must
|
||||
-- never cost them a working compositor. Every failure path returns the caller's
|
||||
-- fallback.
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -244,6 +244,33 @@ function prefs.getInt(key, fallback)
|
||||
return math.floor(value + 0.5)
|
||||
end
|
||||
|
||||
-- Read and decode any JSON file, using the same never-raise parser the
|
||||
-- settings store uses. Returns an empty table for a file that is missing,
|
||||
-- empty, or malformed, so a caller can index the result without checking.
|
||||
--
|
||||
-- Exists so config/palette.json can be read by looks.lua rather than the eight
|
||||
-- accents being written out a second time in Lua. A bad palette costs the
|
||||
-- accent colours, never the compositor config.
|
||||
function prefs.readJson(path)
|
||||
if type(path) ~= "string" or path == "" then
|
||||
return {}
|
||||
end
|
||||
local file = io.open(path, "r")
|
||||
if not file then
|
||||
return {}
|
||||
end
|
||||
local text = file:read("*a")
|
||||
file:close()
|
||||
if not text or text:match("^%s*$") then
|
||||
return {}
|
||||
end
|
||||
local ok, parsed = pcall(decode, text)
|
||||
if not ok or type(parsed) ~= "table" then
|
||||
return {}
|
||||
end
|
||||
return parsed
|
||||
end
|
||||
|
||||
-- True when a settings file was actually read. Useful from overrides.lua.
|
||||
function prefs.loaded()
|
||||
return next(values) ~= nil
|
||||
|
||||
+112
-2
@@ -9,6 +9,8 @@
|
||||
-- to invert it.
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
local prefs = require("prefs")
|
||||
|
||||
-- ── Upstream sanity rules ───────────────────────────────────────────────────
|
||||
hl.window_rule({
|
||||
name = "suppress-maximize-events",
|
||||
@@ -69,7 +71,7 @@ hl.window_rule({
|
||||
float = true,
|
||||
})
|
||||
|
||||
-- Settings-style utility windows and pickers: float and centre, like GNOME did.
|
||||
-- Settings-style utility windows and pickers: float and center, like GNOME did.
|
||||
hl.window_rule({
|
||||
match = { class = "^(pavucontrol|org\\.pulseaudio\\.pavucontrol|nm-connection-editor|blueman-manager|org\\.gnome\\.Settings)$" },
|
||||
float = true,
|
||||
@@ -77,6 +79,18 @@ hl.window_rule({
|
||||
center = true,
|
||||
})
|
||||
|
||||
-- Quick Look. The GNOME previewer is what the file manager opens on space,
|
||||
-- and it is an overlay rather than a window someone manages: tiled, it shoves
|
||||
-- the file manager aside and has to be dismissed before the list is usable
|
||||
-- again. Sized generously because a preview that needs zooming is not a
|
||||
-- preview; it still gets a margin so the file underneath stays visible.
|
||||
hl.window_rule({
|
||||
match = { class = "^org\\.gnome\\.NautilusPreviewer$" },
|
||||
float = true,
|
||||
size = { "monitor_w * 0.7", "monitor_h * 0.8" },
|
||||
center = true,
|
||||
})
|
||||
|
||||
-- Portal dialogs (file chooser, screen share picker) should always float.
|
||||
hl.window_rule({
|
||||
match = { class = "^(xdg-desktop-portal-gtk|org\\.freedesktop\\.impl\\.portal\\.desktop\\.gtk|hyprland-share-picker)$" },
|
||||
@@ -124,6 +138,97 @@ hl.window_rule({
|
||||
no_dim = true,
|
||||
})
|
||||
|
||||
-- ── Per-application rules the user wrote ────────────────────────────────────
|
||||
--
|
||||
-- `windowRules` in settings.json, edited from Settings' Windows page. Each
|
||||
-- entry is data and nothing else:
|
||||
--
|
||||
-- { class, label, float, center, size = {w, h}, workspace, noAnim, game,
|
||||
-- noDim, pin }
|
||||
--
|
||||
-- `class` is matched LITERALLY. Hyprland matches with RE2, so a class typed
|
||||
-- into a text field is a regular expression unless something escapes it -- and
|
||||
-- "org.gnome.Files" as a pattern also matches "orgxgnomexFiles", while a
|
||||
-- half-typed "(" is a pattern error rather than a rule that matches nothing.
|
||||
-- Escaped and anchored here, so what the user typed is what gets matched.
|
||||
--
|
||||
-- Emitted AFTER the shipped rules and deliberately WITHOUT a name. Hyprland
|
||||
-- evaluates every named rule before every anonymous one, so a named user rule
|
||||
-- would silently outrank the anonymous shipped rules above it -- the opposite
|
||||
-- of the intended precedence. Anonymous, last, is what "the user's rule wins"
|
||||
-- actually means here.
|
||||
--
|
||||
-- An entry that fails any check is skipped whole rather than emitted with the
|
||||
-- bad field dropped: a rule that half-applies is harder to understand than one
|
||||
-- that is not there, and the settings page can see the same thing is wrong.
|
||||
|
||||
local function escape_regex(value)
|
||||
return (value:gsub("[%^%$%(%)%%%.%[%]%*%+%-%?%{%}%|\\]", "\\%0"))
|
||||
end
|
||||
|
||||
-- The shell's own surfaces are layers, not windows -- but Quickshell's helper
|
||||
-- windows are not, and a rule that floats or moves one of them would be a user
|
||||
-- breaking their own desktop from the Windows page. Refused at both ends; this
|
||||
-- is the end that matters, because the file is hand-editable.
|
||||
local function reserved_class(class)
|
||||
local lowered = class:lower()
|
||||
return lowered:match("^quickshell") ~= nil or lowered:match("^qs%-") ~= nil
|
||||
end
|
||||
|
||||
local function positive_integer(value, low, high)
|
||||
if type(value) ~= "number" or value ~= math.floor(value) then
|
||||
return nil
|
||||
end
|
||||
if value < low or value > high then
|
||||
return nil
|
||||
end
|
||||
return value
|
||||
end
|
||||
|
||||
for _, entry in ipairs(prefs.get("windowRules", {})) do
|
||||
if type(entry) == "table" and type(entry.class) == "string" then
|
||||
local class = entry.class
|
||||
local rule = nil
|
||||
|
||||
if #class >= 1 and #class <= 128 and not reserved_class(class) then
|
||||
rule = { match = { class = "^" .. escape_regex(class) .. "$" } }
|
||||
|
||||
if entry.float == true then rule.float = true end
|
||||
if entry.center == true then rule.center = true end
|
||||
if entry.noAnim == true then rule.no_anim = true end
|
||||
if entry.noDim == true then rule.no_dim = true end
|
||||
if entry.pin == true then rule.pin = true end
|
||||
-- The keystone the gaming rules above use: misc.vrr,
|
||||
-- render.direct_scanout and cursor.no_break_fs_vrr all key off it.
|
||||
if entry.game == true then rule.content = "game" end
|
||||
|
||||
if entry.size ~= nil then
|
||||
local size = entry.size
|
||||
local width = type(size) == "table" and positive_integer(size[1], 50, 10000) or nil
|
||||
local height = type(size) == "table" and positive_integer(size[2], 50, 10000) or nil
|
||||
if width == nil or height == nil then
|
||||
rule = nil
|
||||
else
|
||||
rule.size = { width, height }
|
||||
end
|
||||
end
|
||||
|
||||
if rule ~= nil and entry.workspace ~= nil then
|
||||
local workspace = positive_integer(entry.workspace, 1, 10)
|
||||
if workspace == nil then
|
||||
rule = nil
|
||||
else
|
||||
rule.workspace = workspace
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if rule ~= nil then
|
||||
hl.window_rule(rule)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- ── Workspace rules ─────────────────────────────────────────────────────────
|
||||
-- Deliberately NO "smart gaps".
|
||||
--
|
||||
@@ -147,10 +252,15 @@ hl.layer_rule({
|
||||
name = "qs-dock",
|
||||
match = { namespace = "^qs-dock$" },
|
||||
blur = true,
|
||||
-- The dock's context menu and its window previews are xdg-popups of this
|
||||
-- surface, not layers of their own, so they are only blurred if the rule
|
||||
-- says to blur the popups too. Without it they are flat panes over the
|
||||
-- wallpaper while everything else on the desktop is glass.
|
||||
blur_popups = true,
|
||||
ignore_alpha = 0.3,
|
||||
})
|
||||
|
||||
-- Popovers: calendar, quick settings, notification centre, tray menus.
|
||||
-- Popovers: calendar, quick settings, notification center, tray menus.
|
||||
hl.layer_rule({
|
||||
name = "qs-popover",
|
||||
match = { namespace = "^qs-popover" },
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// Deuteranopia -- green-blind.
|
||||
//
|
||||
// Panama's accessibility color filters. Selected by the `colorFilter`
|
||||
// preference; hypr/looks.lua maps the enum to this path at config time and
|
||||
// services/SystemSettings.qml does the same live.
|
||||
//
|
||||
// Same family of matrices as protanopia.frag, weighted for the missing green
|
||||
// cone instead of the red one. See that file for why this is a correction
|
||||
// rather than a simulation, and for the column-major note.
|
||||
|
||||
#version 300 es
|
||||
|
||||
precision mediump float;
|
||||
in vec2 v_texcoord;
|
||||
layout(location = 0) out vec4 fragColor;
|
||||
uniform sampler2D tex;
|
||||
|
||||
const mat3 deuteranopia = mat3(
|
||||
0.625, 0.700, 0.000,
|
||||
0.375, 0.300, 0.300,
|
||||
0.000, 0.000, 0.700
|
||||
);
|
||||
|
||||
void main() {
|
||||
vec4 pixColor = texture(tex, v_texcoord);
|
||||
|
||||
fragColor = vec4(clamp(deuteranopia * pixColor.rgb, 0.0, 1.0), pixColor.a);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// Grayscale.
|
||||
//
|
||||
// Panama's accessibility color filters. Selected by the `colorFilter`
|
||||
// preference; hypr/looks.lua maps the enum to this path at config time and
|
||||
// services/SystemSettings.qml does the same live.
|
||||
//
|
||||
// Hyprland runs one fragment shader over the finished frame, so this covers
|
||||
// every window, the shell, video and the cursor alike.
|
||||
//
|
||||
// Rec. 709 luminance weights -- the same ones an SVG <feColorMatrix
|
||||
// type="saturate" values="0"> uses. A flat average would make reds and blues
|
||||
// far too bright and greens far too dark, because the eye does not weigh the
|
||||
// channels equally.
|
||||
|
||||
#version 300 es
|
||||
|
||||
precision mediump float;
|
||||
in vec2 v_texcoord;
|
||||
layout(location = 0) out vec4 fragColor;
|
||||
uniform sampler2D tex;
|
||||
|
||||
void main() {
|
||||
vec4 pixColor = texture(tex, v_texcoord);
|
||||
|
||||
float luminance = dot(pixColor.rgb, vec3(0.2126, 0.7152, 0.0722));
|
||||
|
||||
fragColor = vec4(vec3(luminance), pixColor.a);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// Protanopia -- red-blind.
|
||||
//
|
||||
// Panama's accessibility color filters. Selected by the `colorFilter`
|
||||
// preference; hypr/looks.lua maps the enum to this path at config time and
|
||||
// services/SystemSettings.qml does the same live.
|
||||
//
|
||||
// The matrix is the feColorMatrix set the mock uses, which is the widely
|
||||
// carried HCIRN-derived one: it redistributes the red channel into the two the
|
||||
// eye can still separate, so a red/green pair that was one colour becomes two
|
||||
// distinguishable ones. It is a CORRECTION, not a simulation -- the point is to
|
||||
// make the screen readable, not to show what protanopia looks like.
|
||||
//
|
||||
// Row-major here, column-major to GLSL: mat3 takes its arguments column by
|
||||
// column, so the transpose below is the matrix as written in the SVG.
|
||||
|
||||
#version 300 es
|
||||
|
||||
precision mediump float;
|
||||
in vec2 v_texcoord;
|
||||
layout(location = 0) out vec4 fragColor;
|
||||
uniform sampler2D tex;
|
||||
|
||||
const mat3 protanopia = mat3(
|
||||
0.567, 0.558, 0.000,
|
||||
0.433, 0.442, 0.242,
|
||||
0.000, 0.000, 0.758
|
||||
);
|
||||
|
||||
void main() {
|
||||
vec4 pixColor = texture(tex, v_texcoord);
|
||||
|
||||
fragColor = vec4(clamp(protanopia * pixColor.rgb, 0.0, 1.0), pixColor.a);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// Tritanopia -- blue-blind.
|
||||
//
|
||||
// Panama's accessibility color filters. Selected by the `colorFilter`
|
||||
// preference; hypr/looks.lua maps the enum to this path at config time and
|
||||
// services/SystemSettings.qml does the same live.
|
||||
//
|
||||
// Same family of matrices as protanopia.frag, weighted for the missing blue
|
||||
// cone. See that file for why this is a correction rather than a simulation,
|
||||
// and for the column-major note.
|
||||
|
||||
#version 300 es
|
||||
|
||||
precision mediump float;
|
||||
in vec2 v_texcoord;
|
||||
layout(location = 0) out vec4 fragColor;
|
||||
uniform sampler2D tex;
|
||||
|
||||
const mat3 tritanopia = mat3(
|
||||
0.950, 0.000, 0.000,
|
||||
0.050, 0.433, 0.475,
|
||||
0.000, 0.567, 0.525
|
||||
);
|
||||
|
||||
void main() {
|
||||
vec4 pixColor = texture(tex, v_texcoord);
|
||||
|
||||
fragColor = vec4(clamp(tritanopia * pixColor.rgb, 0.0, 1.0), pixColor.a);
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
# Tokyo Night Moon — the dark theme Panama ships.
|
||||
#
|
||||
# Extracted from kitty.conf so the two schemes can be swapped. kitty.conf
|
||||
# includes current-theme.conf, which Panama generates from the colour scheme
|
||||
# setting; that generated file is gitignored because it is machine state.
|
||||
# Reference, not the source of the running colours. kitty.conf includes
|
||||
# current-theme.conf, which panama-theme-apps RENDERS from the active theme's
|
||||
# palette -- so any of the ten themes reaches the terminal, not only the two
|
||||
# Tokyo Nights. This file used to be copied into that one; it is kept because
|
||||
# it is a complete kitty theme somebody may want to include directly, and
|
||||
# because it is what Moon is supposed to look like.
|
||||
#
|
||||
# Live changes go through `kitty @ set-colors`, which is why kitty.conf enables
|
||||
# remote control. Without it a scheme change would only reach terminals opened
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
-- Hyprland config read.
|
||||
--
|
||||
-- Nothing here may raise. A missing or malformed settings file must cost the
|
||||
-- user their colour scheme preference and nothing else; editing text is more
|
||||
-- user their color scheme preference and nothing else; editing text is more
|
||||
-- important than matching the desktop.
|
||||
|
||||
local M = {}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
-- Tokyo Night, following the desktop's colour scheme.
|
||||
-- Tokyo Night, following the desktop's color scheme.
|
||||
--
|
||||
-- Moon when Panama is dark, Day when it is light. Same theme family either way,
|
||||
-- so the editor keeps the identity the rest of the desktop has rather than
|
||||
-- becoming a different-looking application when the scheme flips.
|
||||
--
|
||||
-- The readability fixes below are deliberately dark-only. They were written
|
||||
-- against Moon's palette -- a pale comment colour, a mid-grey gutter -- and
|
||||
-- applying them to Day would put light grey text on a light background, which
|
||||
-- against Moon's palette -- a pale comment color, a mid-gray gutter -- and
|
||||
-- applying them to Day would put light gray text on a light background, which
|
||||
-- is exactly the legibility problem they exist to solve, inverted.
|
||||
|
||||
local panama = require("config.panama")
|
||||
|
||||
@@ -22,7 +22,7 @@ console_title_template = '{{ .Shell }} in {{ .Folder }}'
|
||||
[[blocks.segments]]
|
||||
type = 'git'
|
||||
style = 'plain'
|
||||
foreground = 'p:grey'
|
||||
foreground = 'p:gray'
|
||||
background = 'transparent'
|
||||
template = ' {{ .HEAD }}{{ if or (.Working.Changed) (.Staging.Changed) }}*{{ end }} <cyan>{{ if gt .Behind 0 }}⇣{{ end }}{{ if gt .Ahead 0 }}⇡{{ end }}</>'
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# Runs after `panama migrate` has applied repairs, and only when it applied at
|
||||
# least one. Takes no arguments.
|
||||
#
|
||||
# Useful when a machine keeps local state that a repair might have invalidated.
|
||||
#
|
||||
# Copy to ~/.config/panama/hooks/post-migrate and make it executable.
|
||||
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
# Runs at the end of ./install, after every stage and after migrations.
|
||||
#
|
||||
# Takes no arguments. This is where per-machine setup goes that Panama should
|
||||
# not carry for everyone: a work laptop's VPN client, a private repository
|
||||
# somebody clones, a package only this machine wants.
|
||||
#
|
||||
# Copy to ~/.config/panama/hooks/post-upgrade and make it executable.
|
||||
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env bash
|
||||
# Runs whenever the colour scheme or accent changes.
|
||||
#
|
||||
# $1 scheme: "dark" or "light"
|
||||
# $2 accent: blue, orchid, teal, green, amber, orange, rose or slate
|
||||
#
|
||||
# Copy to ~/.config/panama/hooks/theme-set and make it executable. Anything in
|
||||
# theme-set.d/ runs too, in sorted order, so several things can react without
|
||||
# fighting over one file.
|
||||
#
|
||||
# A failure here is reported and stepped over: it will never cost you a theme
|
||||
# change.
|
||||
|
||||
scheme="$1"
|
||||
accent="$2"
|
||||
|
||||
# For example: repaint something Panama does not know about.
|
||||
# printf 'set-theme %s\n' "$scheme" | nc -U "$HOME/.local/share/some-app/socket"
|
||||
@@ -0,0 +1,80 @@
|
||||
// Headless theme editor, for accent-controls-contract.
|
||||
//
|
||||
// The two halves of the editor that write colour: the four wells
|
||||
// (ThemeEditorWells + ColorWell), which are the fast path and own hex
|
||||
// validation and the eyedropper, and the six HSV rows (AccentEditor), which
|
||||
// are the fine-tune behind a disclosure.
|
||||
//
|
||||
// AccentEditor commits on a debounce rather than per move, so `adjust` returns
|
||||
// what is *pending*, not what is stored -- the caller waits and reads `status`
|
||||
// again. That is the behaviour under test as much as the colour itself: a
|
||||
// slider that wrote on every move spent a whole drag in apply-and-verify round
|
||||
// trips and left the desktop repainting behind the pointer.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
|
||||
import qs.config
|
||||
import qs.modules.settings
|
||||
import qs.services
|
||||
|
||||
ShellRoot {
|
||||
Item {
|
||||
width: 680
|
||||
height: wells.implicitHeight + editor.implicitHeight
|
||||
|
||||
ThemeEditorWells {
|
||||
id: wells
|
||||
width: parent.width
|
||||
}
|
||||
|
||||
AccentEditor {
|
||||
id: editor
|
||||
y: wells.implicitHeight
|
||||
width: parent.width
|
||||
}
|
||||
}
|
||||
|
||||
IpcHandler {
|
||||
target: "accent-controls-test"
|
||||
|
||||
function status(): string {
|
||||
return JSON.stringify({
|
||||
id: ThemeProfiles.activeProfile.id,
|
||||
name: ThemeProfiles.activeProfile.name,
|
||||
scheme: ThemeProfiles.activeProfile.scheme,
|
||||
accent: String(ThemeProfiles.activeProfile.accent),
|
||||
secondary: String(ThemeProfiles.activeProfile.secondary),
|
||||
shipped: ThemeProfiles.activeProfile.shipped === true,
|
||||
accentName: String(DesktopPreferences.get("accentName")),
|
||||
bg: String(ThemeProfiles.activePalette.bg),
|
||||
fg: String(ThemeProfiles.activePalette.fg),
|
||||
fgDim: String(ThemeProfiles.activePalette.fgDim),
|
||||
pending: editor.pending !== null,
|
||||
wellError: wells.lastError
|
||||
});
|
||||
}
|
||||
|
||||
// Moves one HSV slider. The commit is debounced, so the answer is the
|
||||
// pair the sliders are showing, not the stored one.
|
||||
function adjust(target: string, channel: string, ratio: real): string {
|
||||
editor.changeChannel(target, channel, ratio);
|
||||
return JSON.stringify({
|
||||
shown: { accent: editor.shownAccent, secondary: editor.shownSecondary },
|
||||
pending: editor.pending !== null,
|
||||
stored: {
|
||||
accent: String(ThemeProfiles.activeProfile.accent),
|
||||
secondary: String(ThemeProfiles.activeProfile.secondary)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// The one path every well takes -- typed hex, colour wheel and
|
||||
// eyedropper all end here, so validation cannot differ between them.
|
||||
function well(which: string, hex: string): string {
|
||||
wells.apply(which, hex);
|
||||
return status();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import Quickshell.Services.Pipewire
|
||||
import QtQuick
|
||||
|
||||
import "services/AudioStreams.js" as AudioStreams
|
||||
import qs.services
|
||||
|
||||
ShellRoot {
|
||||
readonly property int audioOutStreamFlag: 4
|
||||
|
||||
property var fixtureNodes: [
|
||||
{
|
||||
id: 10,
|
||||
ready: true,
|
||||
type: audioOutStreamFlag,
|
||||
properties: {
|
||||
"application.id": "org.chromium.Chromium",
|
||||
"application.name": "Chromium",
|
||||
"application.icon_name": "chromium"
|
||||
},
|
||||
description: "Chromium audio",
|
||||
audio: { volume: 0.4, muted: false }
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
ready: true,
|
||||
type: audioOutStreamFlag,
|
||||
properties: {
|
||||
"application.id": "org.chromium.Chromium",
|
||||
"application.name": "Chromium",
|
||||
"application.icon_name": "chromium"
|
||||
},
|
||||
description: "Chromium audio",
|
||||
audio: { volume: 0.8, muted: true }
|
||||
},
|
||||
{
|
||||
id: 20,
|
||||
ready: true,
|
||||
type: audioOutStreamFlag,
|
||||
properties: {
|
||||
"application.process.binary": "spotify",
|
||||
"application.name": "Spotify"
|
||||
},
|
||||
description: "Spotify",
|
||||
audio: { volume: 0.25, muted: false }
|
||||
},
|
||||
{
|
||||
id: 30,
|
||||
ready: true,
|
||||
type: 2,
|
||||
properties: { "application.name": "Microphone capture" },
|
||||
description: "Input stream",
|
||||
audio: { volume: 0.5, muted: false }
|
||||
},
|
||||
{
|
||||
id: 40,
|
||||
ready: false,
|
||||
type: audioOutStreamFlag,
|
||||
properties: { "application.name": "Not ready" },
|
||||
description: "Unready stream",
|
||||
audio: { volume: 0.5, muted: false }
|
||||
},
|
||||
{
|
||||
id: 99,
|
||||
ready: true,
|
||||
type: audioOutStreamFlag,
|
||||
properties: {},
|
||||
description: "",
|
||||
audio: { volume: 1, muted: false }
|
||||
}
|
||||
]
|
||||
|
||||
function groups(): var {
|
||||
return AudioStreams.group(fixtureNodes, audioOutStreamFlag);
|
||||
}
|
||||
|
||||
// A throwaway two-stream group for the clamp cases. The shared fixture
|
||||
// above is mutated by the volume and mute tests, and a clamp assertion that
|
||||
// depended on which of those ran first would be worthless.
|
||||
function clampFixture(): var {
|
||||
return {
|
||||
key: "clamp",
|
||||
label: "Clamp",
|
||||
icon: "audio-x-generic-symbolic",
|
||||
nodes: [
|
||||
{ audio: { volume: 0.1, muted: true } },
|
||||
{ audio: { volume: 0.1, muted: true } }
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
IpcHandler {
|
||||
target: "application-volume-test"
|
||||
|
||||
function summary(): string {
|
||||
const applications = groups();
|
||||
const chromium = applications.find(application =>
|
||||
application.key === "org.chromium.Chromium");
|
||||
return JSON.stringify({
|
||||
groups: applications.map(application => ({
|
||||
key: application.key,
|
||||
label: application.label,
|
||||
icon: application.icon,
|
||||
count: application.nodes.length
|
||||
})),
|
||||
chromiumVolume: AudioStreams.volume(chromium),
|
||||
chromiumMuted: AudioStreams.muted(chromium)
|
||||
});
|
||||
}
|
||||
|
||||
function mutateVolume(): string {
|
||||
const chromium = groups().find(application =>
|
||||
application.key === "org.chromium.Chromium");
|
||||
const changed = AudioStreams.setVolume(chromium, 0.7);
|
||||
return JSON.stringify({
|
||||
changed,
|
||||
volumes: chromium.nodes.map(node => node.audio.volume),
|
||||
muted: chromium.nodes.map(node => node.audio.muted)
|
||||
});
|
||||
}
|
||||
|
||||
function mutateMute(): string {
|
||||
const chromium = groups().find(application =>
|
||||
application.key === "org.chromium.Chromium");
|
||||
const changed = AudioStreams.setMuted(chromium, true);
|
||||
return JSON.stringify({
|
||||
changed,
|
||||
muted: chromium.nodes.map(node => node.audio.muted)
|
||||
});
|
||||
}
|
||||
|
||||
// Over-amplification is a preference, so the ceiling is an argument
|
||||
// rather than a constant -- this file stays Settings-free on purpose.
|
||||
function clampVolume(): string {
|
||||
const overAmp = clampFixture();
|
||||
const overAmpChanged = AudioStreams.setVolume(overAmp, 1.4, 1.5);
|
||||
const ceiling = clampFixture();
|
||||
AudioStreams.setVolume(ceiling, 2.5, 1.5);
|
||||
const defaultMax = clampFixture();
|
||||
AudioStreams.setVolume(defaultMax, 1.4);
|
||||
const floor = clampFixture();
|
||||
AudioStreams.setVolume(floor, -0.5, 1.5);
|
||||
const nonNumeric = clampFixture();
|
||||
const nonNumericChanged = AudioStreams.setVolume(nonNumeric, "loud", 1.5);
|
||||
return JSON.stringify({
|
||||
overAmpChanged,
|
||||
overAmp: overAmp.nodes.map(node => node.audio.volume),
|
||||
overAmpMuted: overAmp.nodes.map(node => node.audio.muted),
|
||||
ceiling: ceiling.nodes.map(node => node.audio.volume),
|
||||
defaultMax: defaultMax.nodes.map(node => node.audio.volume),
|
||||
floor: floor.nodes.map(node => node.audio.volume),
|
||||
nonNumericChanged,
|
||||
nonNumeric: nonNumeric.nodes.map(node => node.audio.volume)
|
||||
});
|
||||
}
|
||||
|
||||
function serviceSummary(): string {
|
||||
const applications = AudioDevices.applications;
|
||||
return JSON.stringify({
|
||||
count: applications.length,
|
||||
validTypes: applications.every(application =>
|
||||
application.nodes.every(node =>
|
||||
(node.type & PwNodeType.AudioOutStream)
|
||||
=== PwNodeType.AudioOutStream))
|
||||
});
|
||||
}
|
||||
|
||||
function invalidMutations(): string {
|
||||
return JSON.stringify({
|
||||
nullVolume: AudioDevices.setApplicationVolume(null, 0.5),
|
||||
emptyMute: AudioDevices.setApplicationMuted({ nodes: [] }, true)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -57,7 +57,12 @@ Singleton {
|
||||
const coerced = PreferenceSchema.coerce(key, value);
|
||||
if (coerced === undefined)
|
||||
return false;
|
||||
if (root.values[key] === coerced)
|
||||
// A json value comes out of coerce() with a fresh identity every time,
|
||||
// so `===` never held for one and an identical write still bumped the
|
||||
// revision. The revision is what drives the video restore, the scheme
|
||||
// reconciliation and the theme coalesce timer, so re-storing the same
|
||||
// display map ran all three again for a change nobody made.
|
||||
if (root.sameStoredValue(key, coerced))
|
||||
return true;
|
||||
|
||||
// Reassign rather than mutate: QML does not notify on in-place changes
|
||||
@@ -70,6 +75,19 @@ Singleton {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Whether the store already holds `coerced` for `key`. Scalars compare by
|
||||
// value; json compares by serialization, which is the only comparison an
|
||||
// object or array has here. Two equal objects written in a different key
|
||||
// order serialize differently and are treated as a change -- that is the
|
||||
// old behaviour, so the comparison can only ever remove churn, never
|
||||
// swallow a real write.
|
||||
function sameStoredValue(key: string, coerced: var): bool {
|
||||
const stored = root.values[key];
|
||||
if (PreferenceSchema.spec(key)?.type === "json")
|
||||
return stored !== undefined && JSON.stringify(stored) === JSON.stringify(coerced);
|
||||
return stored === coerced;
|
||||
}
|
||||
|
||||
// Restores every schema default in one write. Complete by construction --
|
||||
// there is no hand-maintained list to fall out of sync with the schema.
|
||||
function resetDesktopDefaults(): void {
|
||||
@@ -101,7 +119,7 @@ Singleton {
|
||||
const raw = (parsed && typeof parsed === "object") ? parsed : {};
|
||||
|
||||
// Upgrade before anything reads a value. A stored key the current
|
||||
// schema no longer recognises is carried through untouched and silently
|
||||
// schema no longer recognizes is carried through untouched and silently
|
||||
// stops taking effect, so the conversion has to happen here rather than
|
||||
// being noticed later by whoever owns that setting.
|
||||
const result = Migrations.apply(raw);
|
||||
@@ -130,6 +148,14 @@ Singleton {
|
||||
printErrors: false
|
||||
atomicWrites: true
|
||||
|
||||
// Adopt writes made from outside the shell -- a hand edit, a script,
|
||||
// a restored snapshot -- instead of holding a stale copy in memory
|
||||
// and silently erasing them at the next save. A change made anywhere
|
||||
// must survive everywhere; the shell is the editor, not the owner.
|
||||
// The shell's own atomic writes land here too and reload as a no-op.
|
||||
watchChanges: true
|
||||
onFileChanged: this.reload()
|
||||
|
||||
onLoaded: root.load()
|
||||
// No file yet is the normal first-run case, not an error.
|
||||
onLoadFailed: root.load()
|
||||
@@ -145,7 +171,27 @@ Singleton {
|
||||
Timer {
|
||||
id: persistTimer
|
||||
interval: 0
|
||||
onTriggered: preferencesFile.setText(JSON.stringify(root.values, null, 2) + "\n")
|
||||
// Merge with what is on disk rather than overwriting it. This model
|
||||
// was loaded at startup; a key written to the file since then -- a
|
||||
// hand edit, a script, another shell instance during a session
|
||||
// handoff -- would otherwise be erased by the next unrelated save,
|
||||
// which is how a setting "changed itself back". Keys this shell has
|
||||
// set win; keys it has never seen survive.
|
||||
onTriggered: {
|
||||
let disk = {};
|
||||
try {
|
||||
const text = preferencesFile.text();
|
||||
if (text && text.trim().length > 0)
|
||||
disk = JSON.parse(text);
|
||||
} catch (error) {
|
||||
// An unreadable file loses the merge, never the write.
|
||||
}
|
||||
if (!disk || typeof disk !== "object")
|
||||
disk = {};
|
||||
const merged = Object.assign({}, disk, root.values);
|
||||
root.values = merged;
|
||||
preferencesFile.setText(JSON.stringify(merged, null, 2) + "\n");
|
||||
}
|
||||
}
|
||||
|
||||
// One-time move from the pre-Stage-1 location inside Quickshell's state
|
||||
|
||||
@@ -19,7 +19,7 @@ Singleton {
|
||||
printErrors: false
|
||||
atomicWrites: true
|
||||
onSaved: root.saveError = ""
|
||||
onSaveFailed: error => root.saveError = "Could not save Home favourites."
|
||||
onSaveFailed: error => root.saveError = "Could not save Home favorites."
|
||||
|
||||
JsonAdapter {
|
||||
id: values
|
||||
|
||||
@@ -5,7 +5,7 @@ pragma Singleton
|
||||
// The schema is the single source of truth for what a setting IS, but it cannot
|
||||
// describe what a setting USED to be. Renaming a key, changing its units, or
|
||||
// splitting one setting into two all leave a stored value that the new schema
|
||||
// does not recognise -- and an unrecognised key is silently carried through
|
||||
// does not recognize -- and an unrecognized key is silently carried through
|
||||
// untouched, so the user's choice simply stops taking effect with nothing to
|
||||
// say why. That is the failure this exists to prevent.
|
||||
//
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -19,6 +19,26 @@ Singleton {
|
||||
readonly property bool showSeconds: DesktopPreferences.get("showSeconds")
|
||||
readonly property bool showWeekday: DesktopPreferences.get("showWeekday")
|
||||
|
||||
// ── Bar ─────────────────────────────────────────────────────────────────
|
||||
// Legibility first: the bar sits on the wallpaper, so it may need a tone,
|
||||
// a shadow or a scrim the theme cannot know about. `barTextTone` itself is
|
||||
// read by Theme, which turns it into the barFg family the widgets bind to.
|
||||
readonly property bool barTextShadow: DesktopPreferences.get("barTextShadow")
|
||||
readonly property bool barBackdrop: DesktopPreferences.get("barBackdrop")
|
||||
|
||||
// Which widgets earn a place. Each is ANDed with the widget's own state
|
||||
// condition, so turning one on never conjures a pill with nothing in it.
|
||||
readonly property bool showWeatherWidget: DesktopPreferences.get("showWeatherWidget")
|
||||
readonly property bool showMediaWidget: DesktopPreferences.get("showMediaWidget")
|
||||
readonly property bool showClipboardButton: DesktopPreferences.get("showClipboardButton")
|
||||
readonly property bool showCalendarCountdown: DesktopPreferences.get("showCalendarCountdown")
|
||||
|
||||
// ── Control Center ──────────────────────────────────────────────────────
|
||||
// One bool per section of the quick settings panel that is worth hiding.
|
||||
readonly property bool ccShowFocus: DesktopPreferences.get("ccShowFocus")
|
||||
readonly property bool ccShowHome: DesktopPreferences.get("ccShowHome")
|
||||
readonly property bool ccShowPhone: DesktopPreferences.get("ccShowPhone")
|
||||
|
||||
// ── Weather ─────────────────────────────────────────────────────────────
|
||||
// Coordinates taken from the GNOME night-light setting, which had already
|
||||
// resolved the location. Uses Open-Meteo, which needs no API key.
|
||||
@@ -38,6 +58,26 @@ Singleton {
|
||||
readonly property bool showCpu: DesktopPreferences.get("showCpu")
|
||||
readonly property bool showMemory: DesktopPreferences.get("showMemory")
|
||||
readonly property bool showGpu: DesktopPreferences.get("showGpu")
|
||||
readonly property bool showBattery: DesktopPreferences.get("showBattery")
|
||||
readonly property bool showBatteryPercent: DesktopPreferences.get("showBatteryPercent")
|
||||
readonly property bool showAgentUsage: DesktopPreferences.get("showAgentUsage")
|
||||
|
||||
// ── Agents ──────────────────────────────────────────────────────────────
|
||||
// Who the desktop hands a failure to, what it is allowed to hand over, and
|
||||
// which usage collectors run. `showAgentUsage` stays with the vitals above:
|
||||
// it is the bar's switch, and the Agents page mirrors it.
|
||||
readonly property string preferredAgent: DesktopPreferences.get("preferredAgent")
|
||||
readonly property bool crashDiagnoseOffer: DesktopPreferences.get("crashDiagnoseOffer")
|
||||
readonly property bool reloadFailureOffer: DesktopPreferences.get("reloadFailureOffer")
|
||||
readonly property bool healthAgentHandoff: DesktopPreferences.get("healthAgentHandoff")
|
||||
readonly property bool agentAutoApprove: DesktopPreferences.get("agentAutoApprove")
|
||||
readonly property bool agentUsageClaude: DesktopPreferences.get("agentUsageClaude")
|
||||
readonly property bool agentUsageCodex: DesktopPreferences.get("agentUsageCodex")
|
||||
readonly property int agentUsageRefreshMinutes: DesktopPreferences.get("agentUsageRefreshMinutes")
|
||||
|
||||
// ── Battery ─────────────────────────────────────────────────────────────
|
||||
readonly property int batteryLowPercent: DesktopPreferences.get("batteryLowPercent")
|
||||
readonly property int batteryCriticalPercent: DesktopPreferences.get("batteryCriticalPercent")
|
||||
|
||||
// amdgpu exposes utilisation here. Verified present on this machine; the
|
||||
// widget hides itself if the path is missing rather than showing zeros.
|
||||
@@ -55,6 +95,28 @@ Singleton {
|
||||
readonly property int notificationTimeoutCriticalMs: DesktopPreferences.get("notificationTimeoutCriticalMs") // 0 = never auto-expire
|
||||
readonly property int notificationHistoryLimit: DesktopPreferences.get("notificationHistoryLimit")
|
||||
readonly property int maxVisibleToasts: DesktopPreferences.get("maxVisibleToasts")
|
||||
// The single exception to Do Not Disturb, read by the popup gate in
|
||||
// services/Notifs.qml. Off means Do Not Disturb is absolute.
|
||||
readonly property bool criticalBreaksThrough: DesktopPreferences.get("criticalBreaksThrough")
|
||||
|
||||
// ── Accessibility ───────────────────────────────────────────────────────
|
||||
// Reduce motion. Theme.qml turns this into the dur* tokens, so every
|
||||
// Behavior and NumberAnimation in the shell obeys it without knowing it
|
||||
// exists. Read through here rather than from the store directly because
|
||||
// Theme reads it on every animated property in the shell.
|
||||
readonly property bool animationsEnabled: DesktopPreferences.get("animationsEnabled")
|
||||
|
||||
// Read on every notification that would ring the bell, by the per-screen
|
||||
// VisualBell overlay. Lives here rather than being read from the store
|
||||
// directly, like every other value the shell consults at speed.
|
||||
readonly property bool visualAlerts: DesktopPreferences.get("visualAlerts")
|
||||
|
||||
// ── Sound ───────────────────────────────────────────────────────────────
|
||||
// Over-amplification is the clamp ceiling for output volume: off means 1.0,
|
||||
// on means 1.5. Every slider and the volume keys read the same switch, so
|
||||
// the ceiling cannot differ depending on where you changed the volume from.
|
||||
readonly property bool overAmplification: DesktopPreferences.get("overAmplification")
|
||||
readonly property bool volumeChangeBlip: DesktopPreferences.get("volumeChangeBlip")
|
||||
|
||||
// ── Focus ──────────────────────────────────────────────────────────────
|
||||
// One deliberate default rather than a preset picker: quick settings and
|
||||
@@ -62,7 +124,12 @@ Singleton {
|
||||
readonly property int focusDurationMinutes: DesktopPreferences.get("focusDurationMinutes")
|
||||
|
||||
// ── Dock ────────────────────────────────────────────────────────────────
|
||||
// Pinned apps, in order, taken from the GNOME dash favourites.
|
||||
// Pinned apps, in order, taken from the GNOME dash favorites.
|
||||
readonly property string dockPosition: DesktopPreferences.get("dockPosition")
|
||||
readonly property var dockScreens: {
|
||||
const stored = DesktopPreferences.get("dockScreens");
|
||||
return Array.isArray(stored) ? stored : [];
|
||||
}
|
||||
readonly property var dockPinned: DesktopPreferences.get("dockPinned")
|
||||
|
||||
// Dash-to-Dock was set to intellihide against all windows: the dock hides
|
||||
@@ -84,4 +151,5 @@ Singleton {
|
||||
// Passed to wf-recorder. Uses VAAPI on the AMD card so recording does not
|
||||
// cost CPU while gaming.
|
||||
readonly property string recorderArgs: DesktopPreferences.get("recorderArgs")
|
||||
readonly property string recorderAudio: DesktopPreferences.get("recorderAudio")
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
pragma Singleton
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Tokyo Night Moon — the single source of truth for every colour, radius,
|
||||
// Tokyo Night Moon — the single source of truth for every color, radius,
|
||||
// duration and font in the shell. Nothing below this file should hardcode a
|
||||
// colour; if you find yourself writing "#" outside this file, add a token here.
|
||||
// color; if you find yourself writing "#" outside this file, add a token here.
|
||||
//
|
||||
// The bar keeps GNOME's 36px information density but sits directly on the
|
||||
// desktop, edge to edge. The dock retains the former glass vocabulary: bottom,
|
||||
@@ -14,51 +14,117 @@ import Quickshell
|
||||
// QtQuick is required even though nothing visual is declared here: `color` is a
|
||||
// QtQuick value type, and Qt.rgba() lives in its JS namespace.
|
||||
import QtQuick
|
||||
import qs.services
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
// ── Colour scheme ───────────────────────────────────────────────────────
|
||||
// Tokyo Night ships an official light variant (Day), so light mode is that
|
||||
// rather than a palette invented to merely not be dark. The two share the
|
||||
// same hues at different lightness, which is what lets the Prism identity
|
||||
// survive the switch: blue still leads into orchid, it is simply a darker
|
||||
// blue on a lighter ground.
|
||||
// ── Color scheme ───────────────────────────────────────────────────────
|
||||
// The scheme still exists as a boolean because alphas and a handful of
|
||||
// mix directions depend on which side of the ground the theme sits — but
|
||||
// the palette itself now comes from the active theme record.
|
||||
//
|
||||
// Every token below is a binding on this, so flipping it repaints the whole
|
||||
// Every token below is a binding, so selecting a theme repaints the whole
|
||||
// shell without anything needing to know it happened.
|
||||
readonly property bool dark: DesktopPreferences.get("colorScheme") !== "light"
|
||||
|
||||
// ── Palette ─────────────────────────────────────────────────────────────
|
||||
// Canonical Tokyo Night Moon. `accent` matches the GNOME accent exactly.
|
||||
readonly property color bg: root.dark ? "#222436" : "#e1e2e7"
|
||||
readonly property color bgDark: root.dark ? "#1e2030" : "#d3d5de"
|
||||
readonly property color bgHighlight: root.dark ? "#2f334d" : "#c4c8da"
|
||||
readonly property color bgPanel: root.dark ? "#2e2f3d" : "#d9dae3" // dock glass surface
|
||||
readonly property color bgPopover: root.dark ? "#21212f" : "#eaeaee" // Openbar submenu background
|
||||
// The active theme's palette — ThemeProfiles resolves it and it is always
|
||||
// complete: a shipped theme carries its own, a custom theme without one
|
||||
// inherits its scheme's default (Tokyo Moon or Tokyo Day) from the
|
||||
// catalog. Nothing here needs a per-token fallback.
|
||||
readonly property var palette: ThemeProfiles.activePalette
|
||||
|
||||
readonly property color fg: root.dark ? "#c8d3f5" : "#3760bf"
|
||||
readonly property color fgDim: root.dark ? "#828bb8" : "#6172b0"
|
||||
readonly property color fgMuted: root.dark ? "#636da6" : "#848cb5"
|
||||
readonly property color gutter: root.dark ? "#3b4261" : "#a8aecb"
|
||||
readonly property color bg: root.palette.bg
|
||||
readonly property color bgDark: root.palette.bgDark
|
||||
readonly property color bgHighlight: root.palette.bgHighlight
|
||||
readonly property color bgPanel: root.palette.bgPanel // dock glass surface
|
||||
readonly property color bgPopover: root.palette.bgPopover // Openbar submenu background
|
||||
|
||||
// The pair. `accent` is the primary and carries every state meaning
|
||||
// (focused, active, on). `accentSecondary` is the orchid from the tmux
|
||||
// theme — it never appears alone, only as the far end of a gradient. That
|
||||
// restraint is the whole point: the two colours meeting is the signature,
|
||||
// so the pink stops being special the moment it's used as a flat fill.
|
||||
readonly property color accent: root.dark ? "#82aaff" : "#2e7de9" // blue
|
||||
readonly property color accentSecondary: root.dark ? "#b172b0" : "#9854f1" // orchid, from tmux
|
||||
readonly property color accentAlt: root.dark ? "#65bcff" : "#007197" // blue1, a lighter blue
|
||||
readonly property color cyan: root.dark ? "#86e1fc" : "#007197"
|
||||
readonly property color teal: root.dark ? "#4fd6be" : "#118c74"
|
||||
readonly property color green: root.dark ? "#c3e88d" : "#587539"
|
||||
readonly property color yellow: root.dark ? "#ffc777" : "#8c6c3e"
|
||||
readonly property color orange: root.dark ? "#ff966c" : "#b15c00"
|
||||
readonly property color red: root.dark ? "#ff757f" : "#f52a65"
|
||||
readonly property color redDeep: root.dark ? "#c53b53" : "#c64343"
|
||||
readonly property color magenta: root.dark ? "#c099ff" : "#9854f1"
|
||||
readonly property color pink: root.dark ? "#fca7ea" : "#d20065"
|
||||
readonly property color fg: root.palette.fg
|
||||
readonly property color fgDim: root.palette.fgDim
|
||||
readonly property color fgMuted: root.palette.fgMuted
|
||||
readonly property color gutter: root.palette.gutter
|
||||
|
||||
// ── Bar text ────────────────────────────────────────────────────────────
|
||||
// Every other surface in the shell draws on a ground the theme chose. The
|
||||
// bar draws on the wallpaper, which the theme has never seen — so a
|
||||
// photograph with a bright sky can leave the palette's own fg unreadable
|
||||
// exactly where the clock is.
|
||||
//
|
||||
// Hence a second neutral family, used by the bar and nowhere else. Left
|
||||
// alone it *is* the fg family, so nothing changes for anyone who never
|
||||
// asks. Forced light or dark, it becomes a run that survives whatever is
|
||||
// underneath it, and the two dims are mixed toward the opposite end rather
|
||||
// than picked by hand, so the three stay a family either way.
|
||||
readonly property string barTextTone: DesktopPreferences.get("barTextTone")
|
||||
|
||||
readonly property color barFg: {
|
||||
if (root.barTextTone === "light")
|
||||
return "#f4f6ff";
|
||||
if (root.barTextTone === "dark")
|
||||
return "#1b2130";
|
||||
return root.fg;
|
||||
}
|
||||
|
||||
readonly property color barFgDim: {
|
||||
if (root.barTextTone === "light")
|
||||
return root.mix(root.barFg, "#20242f", 0.35);
|
||||
if (root.barTextTone === "dark")
|
||||
return root.mix(root.barFg, "#ffffff", 0.35);
|
||||
return root.fgDim;
|
||||
}
|
||||
|
||||
readonly property color barFgMuted: {
|
||||
if (root.barTextTone === "light")
|
||||
return root.mix(root.barFg, "#20242f", 0.5);
|
||||
if (root.barTextTone === "dark")
|
||||
return root.mix(root.barFg, "#ffffff", 0.5);
|
||||
return root.fgMuted;
|
||||
}
|
||||
|
||||
// ── The accent ──────────────────────────────────────────────────────────
|
||||
//
|
||||
// `accent` is the primary and carries every state meaning (focused, active,
|
||||
// on). `accentSecondary` never appears alone, only as the far end of a
|
||||
// gradient. That restraint is the whole point: the two colors meeting is
|
||||
// the signature, so the second color stops being special the moment it is
|
||||
// used as a flat fill.
|
||||
//
|
||||
// NAMED accents rather than a free color. Each name carries a curated
|
||||
// triple per scheme, because an arbitrary hex cannot work in both: a color
|
||||
// legible on the Moon background is usually illegible on the Day one, and a
|
||||
// picker that lets someone choose an unreadable desktop is not a feature.
|
||||
// Every pair below measures at least 3:1 against the ground it sits on.
|
||||
// This is also GNOME's model, which is the parity being chased.
|
||||
//
|
||||
// Blue is the shipped Prism -- blue leading, orchid following -- and stays
|
||||
// the default.
|
||||
//
|
||||
// The table itself lives in services/ThemeProfileModel.js so a curated
|
||||
// accent and a custom profile are the same kind of record. Its `gnome`
|
||||
// member is the nearest name in GNOME's own accent-color enum, which is a
|
||||
// fixed list of nine we do not get to extend; it is what libadwaita
|
||||
// applications -- Files, Papers, Loupe -- are told to use, so choosing an
|
||||
// accent here recolors them too instead of leaving them in GNOME blue.
|
||||
readonly property var accents: ThemeProfiles.curatedAccents
|
||||
readonly property var activeProfile: ThemeProfiles.activeProfile
|
||||
|
||||
// ThemeProfiles validates every persisted record before it can become
|
||||
// active, so these bindings are both reactive and safe to expose as the
|
||||
// shell-wide colour roles.
|
||||
readonly property color accent: root.activeProfile.accent
|
||||
readonly property color accentSecondary: root.activeProfile.secondary
|
||||
readonly property color accentAlt: root.palette.accentAlt
|
||||
readonly property color cyan: root.palette.cyan
|
||||
readonly property color teal: root.palette.teal
|
||||
readonly property color green: root.palette.green
|
||||
readonly property color yellow: root.palette.yellow
|
||||
readonly property color orange: root.palette.orange
|
||||
readonly property color red: root.palette.red
|
||||
readonly property color redDeep: root.palette.redDeep
|
||||
readonly property color magenta: root.palette.magenta
|
||||
readonly property color pink: root.palette.pink
|
||||
|
||||
// Semantic aliases — prefer these in widgets so intent survives a repaint.
|
||||
readonly property color ok: green
|
||||
@@ -75,6 +141,13 @@ Singleton {
|
||||
// light one, and text on it stops being legible.
|
||||
readonly property real dockAlpha: root.dark ? 0.34 : 0.62
|
||||
readonly property real popoverAlpha: root.dark ? 0.92 : 0.97
|
||||
|
||||
// Toasts sit a little lighter than a popover you opened on purpose. A
|
||||
// notification arrives unbidden over whatever you were doing, and at full
|
||||
// popover weight it reads as a dialog demanding an answer -- but the 6%
|
||||
// foreground tint it used to have left the text competing with the desktop
|
||||
// behind it. This is the point between the two.
|
||||
readonly property real toastAlpha: root.dark ? 0.86 : 0.94
|
||||
readonly property real overlayAlpha: root.dark ? 0.55 : 0.40
|
||||
readonly property real hoverAlpha: root.dark ? 0.14 : 0.10
|
||||
readonly property real activeAlpha: root.dark ? 0.24 : 0.18
|
||||
@@ -84,7 +157,7 @@ Singleton {
|
||||
readonly property int barGap: 6 // breathing room below the bar for popovers
|
||||
readonly property int barSideMargin: 10 // inset for floating popovers
|
||||
|
||||
readonly property int dockIconSize: 48
|
||||
readonly property int dockIconSize: DesktopPreferences.get("dockIconSize")
|
||||
readonly property int dockPadding: 8
|
||||
readonly property int dockGap: 8
|
||||
readonly property int dockRadius: 20
|
||||
@@ -93,7 +166,6 @@ Singleton {
|
||||
readonly property int popoverPadding: 14
|
||||
readonly property int popoverWidth: 380
|
||||
readonly property int controlCenterWidth: 430
|
||||
readonly property int controlCenterTopGap: 2
|
||||
|
||||
readonly property int cardRadius: 12
|
||||
readonly property int pillRadius: 999
|
||||
@@ -108,7 +180,7 @@ Singleton {
|
||||
|
||||
// Nerd Font, used ONLY to draw icon glyphs — never for text. It is the
|
||||
// pragmatic alternative to freedesktop symbolic icons, which ship with a
|
||||
// hardcoded dark fill Qt will not recolour (see widgets/ThemedIcon.qml).
|
||||
// hardcoded dark fill Qt will not recolor (see widgets/ThemedIcon.qml).
|
||||
readonly property string fontMono: DesktopPreferences.get("iconFont")
|
||||
|
||||
// Apply to any text whose digits change in place — clocks, percentages,
|
||||
@@ -132,14 +204,21 @@ Singleton {
|
||||
// ── Motion ──────────────────────────────────────────────────────────────
|
||||
// Event-driven only. Nothing in this shell animates while idle — no pulse,
|
||||
// no shimmer, no spinners. These durations are used for open/close/hover.
|
||||
readonly property int durFast: 120
|
||||
readonly property int durNormal: 200
|
||||
readonly property int durSlow: 320
|
||||
//
|
||||
// All of them collapse to zero when Reduce motion is on: the Accessibility
|
||||
// toggle used to still the compositor's windows while the shell's own bar,
|
||||
// dock and panels kept moving, which made it a half-truth. A duration of 0
|
||||
// is a completed animation, so every Behavior and NumberAnimation in the
|
||||
// shell obeys the switch without knowing it exists.
|
||||
readonly property bool motionEnabled: Settings.animationsEnabled
|
||||
readonly property int durFast: motionEnabled ? 120 : 0
|
||||
readonly property int durNormal: motionEnabled ? 200 : 0
|
||||
readonly property int durSlow: motionEnabled ? 320 : 0
|
||||
|
||||
// The dock revealing is the one animation that answers a live pointer
|
||||
// movement, so it gets its own (much shorter) duration. Anything slower
|
||||
// reads as the desktop lagging behind the cursor rather than as motion.
|
||||
readonly property int durDockReveal: 90
|
||||
readonly property int durDockReveal: motionEnabled ? 90 : 0
|
||||
|
||||
// Matches the "snappy" spring curve defined in hypr/looks.lua.
|
||||
readonly property list<real> easeStandard: [0.05, 0.9, 0.1, 1.0]
|
||||
@@ -152,7 +231,7 @@ Singleton {
|
||||
}
|
||||
|
||||
// Blend two tokens — used for hover/pressed states so we never invent a
|
||||
// colour that isn't derived from the palette.
|
||||
// color that isn't derived from the palette.
|
||||
function mix(a: color, b: color, ratio: real): color {
|
||||
return Qt.rgba(a.r + (b.r - a.r) * ratio, a.g + (b.g - a.g) * ratio, a.b + (b.b - a.b) * ratio, a.a + (b.a - a.a) * ratio);
|
||||
}
|
||||
@@ -163,7 +242,7 @@ Singleton {
|
||||
// the active workspace pill and slider fills.
|
||||
//
|
||||
// Where the gradient runs left-to-right the blue leads, because the eye
|
||||
// reads left first and blue is the colour that carries meaning.
|
||||
// reads left first and blue is the color that carries meaning.
|
||||
readonly property real prismStart: 0.22
|
||||
readonly property real prismEnd: 0.78
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"_comment": "Generated-by-hand source of truth for every consumer outside QML. See config/dot/quickshell/config/PALETTE.md.",
|
||||
"default": "blue",
|
||||
"accents": {
|
||||
"blue": {
|
||||
"dark": "82aaff",
|
||||
"darkSecondary": "b172b0",
|
||||
"light": "2e7de9",
|
||||
"lightSecondary": "9854f1",
|
||||
"label": "Prism blue",
|
||||
"gnome": "blue"
|
||||
},
|
||||
"orchid": {
|
||||
"dark": "c099ff",
|
||||
"darkSecondary": "fca7ea",
|
||||
"light": "7847bd",
|
||||
"lightSecondary": "9854f1",
|
||||
"label": "Orchid",
|
||||
"gnome": "purple"
|
||||
},
|
||||
"teal": {
|
||||
"dark": "86e1fc",
|
||||
"darkSecondary": "82aaff",
|
||||
"light": "007197",
|
||||
"lightSecondary": "2e7de9",
|
||||
"label": "Teal",
|
||||
"gnome": "teal"
|
||||
},
|
||||
"green": {
|
||||
"dark": "c3e88d",
|
||||
"darkSecondary": "86e1fc",
|
||||
"light": "587539",
|
||||
"lightSecondary": "007197",
|
||||
"label": "Green",
|
||||
"gnome": "green"
|
||||
},
|
||||
"amber": {
|
||||
"dark": "ffc777",
|
||||
"darkSecondary": "ff966c",
|
||||
"light": "8c6c3e",
|
||||
"lightSecondary": "b15c00",
|
||||
"label": "Amber",
|
||||
"gnome": "yellow"
|
||||
},
|
||||
"orange": {
|
||||
"dark": "ff966c",
|
||||
"darkSecondary": "ff757f",
|
||||
"light": "b15c00",
|
||||
"lightSecondary": "c64343",
|
||||
"label": "Orange",
|
||||
"gnome": "orange"
|
||||
},
|
||||
"rose": {
|
||||
"dark": "ff757f",
|
||||
"darkSecondary": "c099ff",
|
||||
"light": "f52a65",
|
||||
"lightSecondary": "9854f1",
|
||||
"label": "Rose",
|
||||
"gnome": "red"
|
||||
},
|
||||
"slate": {
|
||||
"dark": "828bb8",
|
||||
"darkSecondary": "82aaff",
|
||||
"light": "6172b0",
|
||||
"lightSecondary": "2e7de9",
|
||||
"label": "Slate",
|
||||
"gnome": "slate"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
{
|
||||
"comment": "The shipped theme catalog — the one source of truth for full palettes. services/ThemeCatalog.qml reads it with FileView, scripts read it with jq, contracts read it with fs. The 19 palette keys are exactly Theme.qml's color tokens; ansi's 16 feed generated terminal themes. moon and day must stay byte-identical to the shell's pre-theme literals: theme-catalog-contract pins that.",
|
||||
"defaultDark": "moon",
|
||||
"defaultLight": "day",
|
||||
"themes": [
|
||||
{
|
||||
"id": "moon", "name": "Tokyo Moon", "scheme": "dark",
|
||||
"accent": "#82aaff", "secondary": "#b172b0",
|
||||
"palette": {
|
||||
"bg": "#222436", "bgDark": "#1e2030", "bgHighlight": "#2f334d",
|
||||
"bgPanel": "#2e2f3d", "bgPopover": "#21212f",
|
||||
"fg": "#c8d3f5", "fgDim": "#828bb8", "fgMuted": "#636da6",
|
||||
"gutter": "#3b4261", "accentAlt": "#65bcff",
|
||||
"cyan": "#86e1fc", "teal": "#4fd6be", "green": "#c3e88d",
|
||||
"yellow": "#ffc777", "orange": "#ff966c", "red": "#ff757f",
|
||||
"redDeep": "#c53b53", "magenta": "#c099ff", "pink": "#fca7ea"
|
||||
},
|
||||
"ansi": {
|
||||
"black": "#1b1d2b", "red": "#ff757f", "green": "#c3e88d",
|
||||
"yellow": "#ffc777", "blue": "#82aaff", "magenta": "#c099ff",
|
||||
"cyan": "#86e1fc", "white": "#828bb8",
|
||||
"brightBlack": "#444a73", "brightRed": "#ff8d94", "brightGreen": "#c7fb6d",
|
||||
"brightYellow": "#ffd8ab", "brightBlue": "#9ab8ff", "brightMagenta": "#caabff",
|
||||
"brightCyan": "#b2ebff", "brightWhite": "#c8d3f5"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "moon-rose", "name": "Moon Rose", "scheme": "dark",
|
||||
"accent": "#ff757f", "secondary": "#c099ff",
|
||||
"palette": {
|
||||
"bg": "#222436", "bgDark": "#1e2030", "bgHighlight": "#2f334d",
|
||||
"bgPanel": "#2e2f3d", "bgPopover": "#21212f",
|
||||
"fg": "#c8d3f5", "fgDim": "#828bb8", "fgMuted": "#636da6",
|
||||
"gutter": "#3b4261", "accentAlt": "#fca7ea",
|
||||
"cyan": "#86e1fc", "teal": "#4fd6be", "green": "#c3e88d",
|
||||
"yellow": "#ffc777", "orange": "#ff966c", "red": "#ff757f",
|
||||
"redDeep": "#c53b53", "magenta": "#c099ff", "pink": "#fca7ea"
|
||||
},
|
||||
"ansi": {
|
||||
"black": "#1b1d2b", "red": "#ff757f", "green": "#c3e88d",
|
||||
"yellow": "#ffc777", "blue": "#82aaff", "magenta": "#c099ff",
|
||||
"cyan": "#86e1fc", "white": "#828bb8",
|
||||
"brightBlack": "#444a73", "brightRed": "#ff8d94", "brightGreen": "#c7fb6d",
|
||||
"brightYellow": "#ffd8ab", "brightBlue": "#9ab8ff", "brightMagenta": "#caabff",
|
||||
"brightCyan": "#b2ebff", "brightWhite": "#c8d3f5"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "mocha", "name": "Catppuccin Mocha", "scheme": "dark",
|
||||
"accent": "#cba6f7", "secondary": "#f5c2e7",
|
||||
"palette": {
|
||||
"bg": "#1e1e2e", "bgDark": "#181825", "bgHighlight": "#313244",
|
||||
"bgPanel": "#27273a", "bgPopover": "#1b1b2a",
|
||||
"fg": "#cdd6f4", "fgDim": "#9399b2", "fgMuted": "#7f849c",
|
||||
"gutter": "#45475a", "accentAlt": "#89dceb",
|
||||
"cyan": "#89dceb", "teal": "#94e2d5", "green": "#a6e3a1",
|
||||
"yellow": "#f9e2af", "orange": "#fab387", "red": "#f38ba8",
|
||||
"redDeep": "#b04a5a", "magenta": "#cba6f7", "pink": "#f5c2e7"
|
||||
},
|
||||
"ansi": {
|
||||
"black": "#45475a", "red": "#f38ba8", "green": "#a6e3a1",
|
||||
"yellow": "#f9e2af", "blue": "#89b4fa", "magenta": "#f5c2e7",
|
||||
"cyan": "#94e2d5", "white": "#bac2de",
|
||||
"brightBlack": "#585b70", "brightRed": "#f38ba8", "brightGreen": "#a6e3a1",
|
||||
"brightYellow": "#f9e2af", "brightBlue": "#89b4fa", "brightMagenta": "#f5c2e7",
|
||||
"brightCyan": "#94e2d5", "brightWhite": "#a6adc8"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "nord", "name": "Nord", "scheme": "dark",
|
||||
"accent": "#88c0d0", "secondary": "#81a1c1",
|
||||
"palette": {
|
||||
"bg": "#2e3440", "bgDark": "#272c36", "bgHighlight": "#3b4252",
|
||||
"bgPanel": "#353c4a", "bgPopover": "#2a303c",
|
||||
"fg": "#d8dee9", "fgDim": "#9aa4b8", "fgMuted": "#7b8598",
|
||||
"gutter": "#4c566a", "accentAlt": "#8fbcbb",
|
||||
"cyan": "#88c0d0", "teal": "#8fbcbb", "green": "#a3be8c",
|
||||
"yellow": "#ebcb8b", "orange": "#d08770", "red": "#bf616a",
|
||||
"redDeep": "#a54e57", "magenta": "#b48ead", "pink": "#c9a3c0"
|
||||
},
|
||||
"ansi": {
|
||||
"black": "#3b4252", "red": "#bf616a", "green": "#a3be8c",
|
||||
"yellow": "#ebcb8b", "blue": "#81a1c1", "magenta": "#b48ead",
|
||||
"cyan": "#88c0d0", "white": "#e5e9f0",
|
||||
"brightBlack": "#4c566a", "brightRed": "#bf616a", "brightGreen": "#a3be8c",
|
||||
"brightYellow": "#ebcb8b", "brightBlue": "#81a1c1", "brightMagenta": "#b48ead",
|
||||
"brightCyan": "#8fbcbb", "brightWhite": "#eceff4"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "gruvbox", "name": "Gruvbox", "scheme": "dark",
|
||||
"accent": "#fabd2f", "secondary": "#fe8019",
|
||||
"palette": {
|
||||
"bg": "#282828", "bgDark": "#1d2021", "bgHighlight": "#3c3836",
|
||||
"bgPanel": "#32302f", "bgPopover": "#211f1e",
|
||||
"fg": "#ebdbb2", "fgDim": "#a89984", "fgMuted": "#928374",
|
||||
"gutter": "#504945", "accentAlt": "#83a598",
|
||||
"cyan": "#83a598", "teal": "#8ec07c", "green": "#b8bb26",
|
||||
"yellow": "#fabd2f", "orange": "#fe8019", "red": "#fb4934",
|
||||
"redDeep": "#cc241d", "magenta": "#d3869b", "pink": "#dfa7be"
|
||||
},
|
||||
"ansi": {
|
||||
"black": "#282828", "red": "#cc241d", "green": "#98971a",
|
||||
"yellow": "#d79921", "blue": "#458588", "magenta": "#b16286",
|
||||
"cyan": "#689d6a", "white": "#a89984",
|
||||
"brightBlack": "#928374", "brightRed": "#fb4934", "brightGreen": "#b8bb26",
|
||||
"brightYellow": "#fabd2f", "brightBlue": "#83a598", "brightMagenta": "#d3869b",
|
||||
"brightCyan": "#8ec07c", "brightWhite": "#ebdbb2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "everforest", "name": "Everforest", "scheme": "dark",
|
||||
"accent": "#a7c080", "secondary": "#e69875",
|
||||
"palette": {
|
||||
"bg": "#2d353b", "bgDark": "#272e33", "bgHighlight": "#3d484d",
|
||||
"bgPanel": "#343f44", "bgPopover": "#293136",
|
||||
"fg": "#d3c6aa", "fgDim": "#9da9a0", "fgMuted": "#7a8478",
|
||||
"gutter": "#475258", "accentAlt": "#7fbbb3",
|
||||
"cyan": "#7fbbb3", "teal": "#83c092", "green": "#a7c080",
|
||||
"yellow": "#dbbc7f", "orange": "#e69875", "red": "#e67e80",
|
||||
"redDeep": "#c05a5e", "magenta": "#d699b6", "pink": "#dfa7c1"
|
||||
},
|
||||
"ansi": {
|
||||
"black": "#3d484d", "red": "#e67e80", "green": "#a7c080",
|
||||
"yellow": "#dbbc7f", "blue": "#7fbbb3", "magenta": "#d699b6",
|
||||
"cyan": "#83c092", "white": "#d3c6aa",
|
||||
"brightBlack": "#475258", "brightRed": "#e67e80", "brightGreen": "#a7c080",
|
||||
"brightYellow": "#dbbc7f", "brightBlue": "#7fbbb3", "brightMagenta": "#d699b6",
|
||||
"brightCyan": "#83c092", "brightWhite": "#d3c6aa"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "day", "name": "Tokyo Day", "scheme": "light",
|
||||
"accent": "#2e7de9", "secondary": "#9854f1",
|
||||
"palette": {
|
||||
"bg": "#e1e2e7", "bgDark": "#d3d5de", "bgHighlight": "#c4c8da",
|
||||
"bgPanel": "#d9dae3", "bgPopover": "#eaeaee",
|
||||
"fg": "#3760bf", "fgDim": "#6172b0", "fgMuted": "#848cb5",
|
||||
"gutter": "#a8aecb", "accentAlt": "#007197",
|
||||
"cyan": "#007197", "teal": "#118c74", "green": "#587539",
|
||||
"yellow": "#8c6c3e", "orange": "#b15c00", "red": "#f52a65",
|
||||
"redDeep": "#c64343", "magenta": "#9854f1", "pink": "#d20065"
|
||||
},
|
||||
"ansi": {
|
||||
"black": "#b4b5b9", "red": "#f52a65", "green": "#587539",
|
||||
"yellow": "#8c6c3e", "blue": "#2e7de9", "magenta": "#9854f1",
|
||||
"cyan": "#007197", "white": "#6172b0",
|
||||
"brightBlack": "#a1a6c5", "brightRed": "#ff4774", "brightGreen": "#5c8524",
|
||||
"brightYellow": "#a27629", "brightBlue": "#358aff", "brightMagenta": "#a463ff",
|
||||
"brightCyan": "#007ea8", "brightWhite": "#3760bf"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "latte", "name": "Catppuccin Latte", "scheme": "light",
|
||||
"accent": "#8839ef", "secondary": "#ea76cb",
|
||||
"palette": {
|
||||
"bg": "#eff1f5", "bgDark": "#e6e9ef", "bgHighlight": "#ccd0da",
|
||||
"bgPanel": "#e2e6ee", "bgPopover": "#f5f7fa",
|
||||
"fg": "#4c4f69", "fgDim": "#6c6f85", "fgMuted": "#8c8fa1",
|
||||
"gutter": "#acb0be", "accentAlt": "#209fb5",
|
||||
"cyan": "#04a5e5", "teal": "#179299", "green": "#40a02b",
|
||||
"yellow": "#df8e1d", "orange": "#fe640b", "red": "#d20f39",
|
||||
"redDeep": "#e64553", "magenta": "#8839ef", "pink": "#ea76cb"
|
||||
},
|
||||
"ansi": {
|
||||
"black": "#5c5f77", "red": "#d20f39", "green": "#40a02b",
|
||||
"yellow": "#df8e1d", "blue": "#1e66f5", "magenta": "#ea76cb",
|
||||
"cyan": "#179299", "white": "#acb0be",
|
||||
"brightBlack": "#6c6f85", "brightRed": "#d20f39", "brightGreen": "#40a02b",
|
||||
"brightYellow": "#df8e1d", "brightBlue": "#1e66f5", "brightMagenta": "#ea76cb",
|
||||
"brightCyan": "#179299", "brightWhite": "#bcc0cc"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "gruvbox-light", "name": "Gruvbox Light", "scheme": "light",
|
||||
"accent": "#b57614", "secondary": "#af3a03",
|
||||
"palette": {
|
||||
"bg": "#fbf1c7", "bgDark": "#f2e5bc", "bgHighlight": "#ebdbb2",
|
||||
"bgPanel": "#f0e6c0", "bgPopover": "#fdf6d8",
|
||||
"fg": "#3c3836", "fgDim": "#665c54", "fgMuted": "#7c6f64",
|
||||
"gutter": "#bdae93", "accentAlt": "#076678",
|
||||
"cyan": "#076678", "teal": "#427b58", "green": "#79740e",
|
||||
"yellow": "#b57614", "orange": "#af3a03", "red": "#9d0006",
|
||||
"redDeep": "#cc241d", "magenta": "#8f3f71", "pink": "#b16286"
|
||||
},
|
||||
"ansi": {
|
||||
"black": "#fbf1c7", "red": "#cc241d", "green": "#98971a",
|
||||
"yellow": "#d79921", "blue": "#458588", "magenta": "#b16286",
|
||||
"cyan": "#689d6a", "white": "#7c6f64",
|
||||
"brightBlack": "#928374", "brightRed": "#9d0006", "brightGreen": "#79740e",
|
||||
"brightYellow": "#b57614", "brightBlue": "#076678", "brightMagenta": "#8f3f71",
|
||||
"brightCyan": "#427b58", "brightWhite": "#3c3836"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "everforest-light", "name": "Everforest Light", "scheme": "light",
|
||||
"accent": "#8da101", "secondary": "#f57d26",
|
||||
"palette": {
|
||||
"bg": "#fdf6e3", "bgDark": "#f4f0d9", "bgHighlight": "#e6e2cc",
|
||||
"bgPanel": "#efebd4", "bgPopover": "#faf3dc",
|
||||
"fg": "#5c6a72", "fgDim": "#829181", "fgMuted": "#939f91",
|
||||
"gutter": "#a6b0a0", "accentAlt": "#3a94c5",
|
||||
"cyan": "#3a94c5", "teal": "#35a77c", "green": "#8da101",
|
||||
"yellow": "#dfa000", "orange": "#f57d26", "red": "#f85552",
|
||||
"redDeep": "#e66868", "magenta": "#df69ba", "pink": "#df69ba"
|
||||
},
|
||||
"ansi": {
|
||||
"black": "#5c6a72", "red": "#f85552", "green": "#8da101",
|
||||
"yellow": "#dfa000", "blue": "#3a94c5", "magenta": "#df69ba",
|
||||
"cyan": "#35a77c", "white": "#e0dcc7",
|
||||
"brightBlack": "#829181", "brightRed": "#f85552", "brightGreen": "#8da101",
|
||||
"brightYellow": "#dfa000", "brightBlue": "#3a94c5", "brightMagenta": "#df69ba",
|
||||
"brightCyan": "#35a77c", "brightWhite": "#fdf6e3"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
|
||||
import qs.modules.settings
|
||||
import qs.services
|
||||
|
||||
ShellRoot {
|
||||
DisplayIdentify {}
|
||||
|
||||
QtObject {
|
||||
id: fixtureService
|
||||
|
||||
property var monitors: [
|
||||
{
|
||||
name: "DP-2", description: "Primary display", width: 4500, height: 3000,
|
||||
refreshRate: 60, mode: "[email protected]", scale: 1.5,
|
||||
transform: 0, x: 0, y: 0, primary: true
|
||||
},
|
||||
{
|
||||
name: "HDMI-A-1", description: "Second display", width: 2560, height: 1440,
|
||||
refreshRate: 60, mode: "[email protected]", scale: 1,
|
||||
transform: 0, x: 3000, y: 0, primary: false
|
||||
}
|
||||
]
|
||||
property var applied: []
|
||||
|
||||
function currentLayout(): var {
|
||||
return monitors.map(record => Object.assign({}, record));
|
||||
}
|
||||
|
||||
function applyLayout(layout: var): bool {
|
||||
applied = layout.map(record => Object.assign({}, record));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
DisplayArrangement {
|
||||
id: arrangement
|
||||
width: 800
|
||||
displayService: fixtureService
|
||||
selectedOutput: "HDMI-A-1"
|
||||
}
|
||||
|
||||
IpcHandler {
|
||||
target: "display-arrangement-test"
|
||||
|
||||
function status(width: int): string {
|
||||
arrangement.width = width;
|
||||
arrangement.resetDraft();
|
||||
return JSON.stringify(arrangement.canvasSnapshot());
|
||||
}
|
||||
|
||||
function dragFixture(): string {
|
||||
arrangement.resetDraft();
|
||||
arrangement.setDraftPosition("HDMI-A-1", 3016, 0, true);
|
||||
arrangement.applyDraft();
|
||||
return JSON.stringify(fixtureService.applied);
|
||||
}
|
||||
|
||||
function keyboardFixture(): string {
|
||||
arrangement.resetDraft();
|
||||
arrangement.nudge("HDMI-A-1", -10, 0);
|
||||
const afterArrow = arrangement.draftLayout.find(record => record.name === "HDMI-A-1").x;
|
||||
arrangement.nudge("HDMI-A-1", -100, 0);
|
||||
const afterShiftArrow = arrangement.draftLayout.find(record => record.name === "HDMI-A-1").x;
|
||||
return JSON.stringify({ afterArrow, afterShiftArrow });
|
||||
}
|
||||
|
||||
function primaryFixture(): string {
|
||||
arrangement.resetDraft();
|
||||
arrangement.makePrimary("HDMI-A-1");
|
||||
return JSON.stringify(fixtureService.applied.map(record => ({
|
||||
name: record.name, x: record.x, y: record.y, primary: record.primary
|
||||
})));
|
||||
}
|
||||
|
||||
// One display is the common case on a laptop, and the canvas is the
|
||||
// page's hero now: it renders that display rather than disappearing and
|
||||
// leaving a picker for a list of one. Only dragging goes away, because
|
||||
// there is nothing to arrange it against.
|
||||
function soloFixture(): string {
|
||||
const previous = fixtureService.monitors;
|
||||
fixtureService.monitors = [previous[0]];
|
||||
arrangement.resetDraft();
|
||||
const snapshot = arrangement.canvasSnapshot();
|
||||
fixtureService.monitors = previous;
|
||||
arrangement.resetDraft();
|
||||
return JSON.stringify(snapshot);
|
||||
}
|
||||
|
||||
// A mirrored display has no position of its own -- the compositor puts
|
||||
// it on top of its target -- so the canvas stacks it there and says so
|
||||
// rather than drawing it wherever its stale coordinates point.
|
||||
function mirrorFixture(): string {
|
||||
const previous = fixtureService.monitors;
|
||||
fixtureService.monitors = [
|
||||
previous[0],
|
||||
Object.assign({}, previous[1], { mirrorOf: "DP-2" })
|
||||
];
|
||||
arrangement.resetDraft();
|
||||
const snapshot = arrangement.canvasSnapshot();
|
||||
fixtureService.monitors = previous;
|
||||
arrangement.resetDraft();
|
||||
return JSON.stringify(snapshot);
|
||||
}
|
||||
|
||||
function identify(): void { Displays.identify(); }
|
||||
function identifying(): bool { return Displays.identifying; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
|
||||
import "services/DisplayLayout.js" as DisplayLayout
|
||||
|
||||
ShellRoot {
|
||||
readonly property var fixture: [
|
||||
{ name: "DP-2", width: 4500, height: 3000, scale: 1.5, transform: 0, x: 140, y: 80, primary: true },
|
||||
{ name: "HDMI-A-1", width: 2560, height: 1440, scale: 1, transform: 1, x: 3140, y: 80, primary: false }
|
||||
]
|
||||
|
||||
// The same two displays, with the second mirroring the first. A mirrored
|
||||
// display has no position of its own: the compositor puts it on top of its
|
||||
// target, so its stored coordinates are stale the moment mirroring is on.
|
||||
readonly property var mirrored: [
|
||||
{ name: "DP-2", width: 4500, height: 3000, scale: 1.5, transform: 0, x: 140, y: 80, primary: true, mirrorOf: "" },
|
||||
{ name: "HDMI-A-1", width: 2560, height: 1440, scale: 1, transform: 0, x: 3140, y: 80, primary: false, mirrorOf: "DP-2" }
|
||||
]
|
||||
|
||||
IpcHandler {
|
||||
target: "display-layout-test"
|
||||
|
||||
function mirror(): string {
|
||||
const normalized = DisplayLayout.normalize(mirrored);
|
||||
const canvas = DisplayLayout.canvasRects(normalized, 800, 500, 20);
|
||||
return JSON.stringify({
|
||||
valid: DisplayLayout.validate(mirrored),
|
||||
normalized: normalized.map(record => ({
|
||||
name: record.name, x: record.x, y: record.y,
|
||||
primary: record.primary, mirrorOf: record.mirrorOf ?? ""
|
||||
})),
|
||||
bounds: canvas.bounds,
|
||||
rects: canvas.rects
|
||||
});
|
||||
}
|
||||
|
||||
function invalidMirrors(): string {
|
||||
const base = mirrored.map(record => Object.assign({}, record));
|
||||
const cases = [];
|
||||
const add = layout => cases.push(DisplayLayout.validate(layout));
|
||||
// Mirroring itself.
|
||||
add([base[0], Object.assign({}, base[1], { mirrorOf: "HDMI-A-1" })]);
|
||||
// Mirroring an output that is not in the layout.
|
||||
add([base[0], Object.assign({}, base[1], { mirrorOf: "NOPE-1" })]);
|
||||
// The primary may not mirror: the desktop is anchored on it.
|
||||
add([Object.assign({}, base[0], { mirrorOf: "HDMI-A-1" }), base[1]]);
|
||||
// No chains. Hyprland resolves a mirror to one target, and a chain
|
||||
// is a question nobody can answer from the canvas.
|
||||
add([
|
||||
base[0],
|
||||
Object.assign({}, base[1], { mirrorOf: "DP-3" }),
|
||||
{ name: "DP-3", width: 1920, height: 1080, scale: 1, transform: 0, x: 6000, y: 0, primary: false, mirrorOf: "DP-2" }
|
||||
]);
|
||||
// Not a name at all.
|
||||
add([base[0], Object.assign({}, base[1], { mirrorOf: 5 })]);
|
||||
return JSON.stringify(cases);
|
||||
}
|
||||
|
||||
function status(): string {
|
||||
const normalized = DisplayLayout.normalize(fixture);
|
||||
const canvas = DisplayLayout.canvasRects(normalized, 800, 500, 20);
|
||||
const near = normalized.map(record => Object.assign({}, record));
|
||||
near[1].x = 3016;
|
||||
const far = normalized.map(record => Object.assign({}, record));
|
||||
far[1].x = 3017;
|
||||
return JSON.stringify({
|
||||
valid: DisplayLayout.validate(fixture),
|
||||
sizes: fixture.map(DisplayLayout.logicalSize),
|
||||
normalized: normalized.map(record => ({ name: record.name, x: record.x, y: record.y, primary: record.primary })),
|
||||
bounds: canvas.bounds,
|
||||
canvasScale: canvas.scale,
|
||||
canvasRects: canvas.rects,
|
||||
near: DisplayLayout.snap(near, "HDMI-A-1", 16).find(record => record.name === "HDMI-A-1").x,
|
||||
far: DisplayLayout.snap(far, "HDMI-A-1", 16).find(record => record.name === "HDMI-A-1").x
|
||||
});
|
||||
}
|
||||
|
||||
function invalid(): string {
|
||||
const base = fixture.map(record => Object.assign({}, record));
|
||||
const cases = [];
|
||||
const add = layout => cases.push(DisplayLayout.validate(layout));
|
||||
add([base[0], Object.assign({}, base[1], { name: "DP-2" })]);
|
||||
add(base.map(record => Object.assign({}, record, { primary: false })));
|
||||
add(base.map(record => Object.assign({}, record, { primary: true })));
|
||||
add([Object.assign({}, base[0], { x: 0.5 }), base[1]]);
|
||||
add([Object.assign({}, base[0], { scale: 0 }), base[1]]);
|
||||
add([Object.assign({}, base[0], { transform: 4 }), base[1]]);
|
||||
add([Object.assign({}, base[0], { width: Infinity }), base[1]]);
|
||||
add([Object.assign({}, base[0], { width: 0 }), base[1]]);
|
||||
return JSON.stringify(cases);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,10 @@ import qs.config
|
||||
import qs.services
|
||||
|
||||
ShellRoot {
|
||||
// The isolated screen model begins with both fixture outputs so changing
|
||||
// it below exercises the same reactive topology path as a real hotplug.
|
||||
Component.onCompleted: Displays.screenOverride = ["DP-2", "HDMI-A-1"]
|
||||
|
||||
IpcHandler {
|
||||
target: "displays-test"
|
||||
|
||||
@@ -20,12 +24,22 @@ ShellRoot {
|
||||
mode: monitor ? monitor.mode : "",
|
||||
scale: monitor ? monitor.scale : 0,
|
||||
transform: monitor ? monitor.transform : -1,
|
||||
x: monitor ? monitor.x : 0,
|
||||
y: monitor ? monitor.y : 0,
|
||||
primary: monitor ? monitor.primary : false,
|
||||
modes: monitor ? monitor.modes.length : 0,
|
||||
awaiting: Displays.awaitingConfirmation,
|
||||
canConfirm: Displays.canConfirm,
|
||||
secondsLeft: Displays.secondsLeft,
|
||||
lastError: Displays.lastError,
|
||||
overridden: monitor ? Displays.isOverridden(monitor.name) : false
|
||||
overridden: monitor ? Displays.isOverridden(monitor.name) : false,
|
||||
// The compositor being visually restored is not the service
|
||||
// being done: revert verification keeps its own readback
|
||||
// running for a few ticks, and busy blocks a new apply until
|
||||
// it settles. A caller that only watched `awaiting` raced
|
||||
// this and got a refusal with no error text.
|
||||
settled: !Displays.busy && !Displays.revertVerificationActive
|
||||
&& !Displays.awaitingConfirmation
|
||||
});
|
||||
}
|
||||
|
||||
@@ -36,8 +50,102 @@ ShellRoot {
|
||||
return Displays.apply(monitor.name, mode, scale, monitor.transform);
|
||||
}
|
||||
|
||||
function transactionStatus(): string {
|
||||
return JSON.stringify({
|
||||
layout: Displays.currentLayout(),
|
||||
primaryFirst: Displays.primaryFirstMonitors.map(monitor => monitor.name),
|
||||
pending: Displays.pendingRequestedLayout,
|
||||
previous: Displays.pendingPreviousLayout,
|
||||
reverting: Displays.revertExpectedLayout,
|
||||
awaiting: Displays.awaitingConfirmation,
|
||||
canConfirm: Displays.canConfirm,
|
||||
busy: Displays.busy,
|
||||
generation: Displays.operationGeneration,
|
||||
revertGeneration: Displays.revertGeneration,
|
||||
lastError: Displays.lastError
|
||||
});
|
||||
}
|
||||
|
||||
function applyLayoutFixture(secondX: int, secondY: int): bool {
|
||||
const layout = Displays.currentLayout();
|
||||
if (layout.length !== 2) return false;
|
||||
layout[0].x = 0;
|
||||
layout[0].y = 0;
|
||||
layout[0].primary = true;
|
||||
layout[1].x = secondX;
|
||||
layout[1].y = secondY;
|
||||
layout[1].primary = false;
|
||||
return Displays.applyLayout(layout);
|
||||
}
|
||||
|
||||
function makePrimaryFixture(output: string): bool {
|
||||
return Displays.makePrimary(output);
|
||||
}
|
||||
|
||||
function injectReadback(text: string, generation: int): void {
|
||||
Displays.parse(text, generation);
|
||||
}
|
||||
|
||||
// The restore-on-reconnect decision, without applying anything. The
|
||||
// caller injects a topology with injectReadback first, sets the stored
|
||||
// arrangement here, and reads back what Panama would do about it.
|
||||
// Both arguments are base64. `qs ipc call` splits an argument that
|
||||
// looks like a JSON array of several objects into one argument per
|
||||
// object, so a two-monitor fixture arrives as two arguments and the
|
||||
// call is rejected for arity. Encoding sidesteps the parsing entirely.
|
||||
//
|
||||
// Topology and stored arrangement are set in one call on purpose: a
|
||||
// real compositor query landing between two calls would replace the
|
||||
// injected topology, and the answer would be about this machine's
|
||||
// actual monitor instead of the fixture.
|
||||
function restorePlan(readbackB64: string, storedB64: string): string {
|
||||
Displays.parse(Qt.atob(readbackB64), 0);
|
||||
DesktopPreferences.set("displays", JSON.parse(Qt.atob(storedB64)));
|
||||
const plan = Displays.plannedRestore();
|
||||
return JSON.stringify({
|
||||
action: plan.action,
|
||||
layout: (plan.layout ?? []).map(record => ({
|
||||
name: record.name, mode: record.mode, scale: record.scale,
|
||||
transform: record.transform, x: record.x, y: record.y,
|
||||
primary: record.primary
|
||||
}))
|
||||
});
|
||||
}
|
||||
|
||||
// The extended record — vrr override, colour profile, bit depth, SDR
|
||||
// trim, mirroring — merged one field at a time, which is how every
|
||||
// control on the page changes it. Base64 for the reason restorePlan
|
||||
// documents above: `qs ipc call` splits JSON that looks like an array
|
||||
// of objects into one argument per object.
|
||||
function applyRecordFixture(output: string, patchB64: string): bool {
|
||||
return Displays.applyRecord(output, JSON.parse(Qt.atob(patchB64)));
|
||||
}
|
||||
|
||||
// matchesLayout as a pure function. The readback carve-outs — a
|
||||
// mirrored output's position, a framebuffer format Panama does not
|
||||
// recognise — are decisions about what NOT to assert, and proving them
|
||||
// through a compositor would mean owning a compositor that mirrors.
|
||||
function layoutMatch(monitorsB64: string, layoutB64: string): bool {
|
||||
return Displays.matchesLayout(JSON.parse(Qt.atob(monitorsB64)),
|
||||
JSON.parse(Qt.atob(layoutB64)));
|
||||
}
|
||||
|
||||
// A settings.json written before any of the new fields existed is the
|
||||
// common case on every machine that has this installed today.
|
||||
function persistedEntryValid(entryB64: string): bool {
|
||||
return Displays.isPersistedLayoutEntry(JSON.parse(Qt.atob(entryB64)));
|
||||
}
|
||||
|
||||
function expireApplyVerification(): void {
|
||||
Displays.verificationTimedOut();
|
||||
}
|
||||
|
||||
function expireRevertVerification(): void {
|
||||
Displays.revertVerificationTimedOut();
|
||||
}
|
||||
|
||||
function refreshIdentityFixture(): string {
|
||||
const modes = Displays.normaliseModes([
|
||||
const modes = Displays.normalizeModes([
|
||||
"[email protected]",
|
||||
"[email protected]"
|
||||
]);
|
||||
@@ -49,6 +157,30 @@ ShellRoot {
|
||||
});
|
||||
}
|
||||
|
||||
function positionFixture(): string {
|
||||
const previous = Displays.monitors;
|
||||
Displays.parse(JSON.stringify([
|
||||
{
|
||||
name: "DP-2", description: "Primary", width: 4500, height: 3000,
|
||||
refreshRate: 60, scale: 1.5, transform: 0, x: 140, y: 80,
|
||||
availableModes: ["[email protected]"]
|
||||
},
|
||||
{
|
||||
name: "HDMI-A-1", description: "Second", width: 2560, height: 1440,
|
||||
refreshRate: 60, scale: 1, transform: 0, x: 3140, y: 80,
|
||||
availableModes: ["[email protected]"]
|
||||
}
|
||||
]), Displays.operationGeneration);
|
||||
const result = JSON.stringify(Displays.monitors.map(monitor => ({
|
||||
name: monitor.name,
|
||||
x: monitor.x,
|
||||
y: monitor.y,
|
||||
primary: monitor.primary
|
||||
})));
|
||||
Displays.monitors = previous;
|
||||
return result;
|
||||
}
|
||||
|
||||
function applyBad(kind: string): bool {
|
||||
const monitor = Displays.monitors[0];
|
||||
if (!monitor) return false;
|
||||
@@ -71,5 +203,8 @@ ShellRoot {
|
||||
if (monitor) Displays.forget(monitor.name);
|
||||
}
|
||||
function refresh(): void { Displays.refresh(); }
|
||||
function setScreenModel(names: string): void {
|
||||
Displays.screenOverride = JSON.parse(names);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,5 +19,9 @@ ShellRoot {
|
||||
function repair(id: string): bool { return Health.repair(id, false); }
|
||||
function report(): string { return JSON.stringify(Health.snapshot, null, 2); }
|
||||
function copy(): bool { return Health.copyReport(); }
|
||||
// Both take an explicit destination or subject, so nothing here writes
|
||||
// to the real home or rescans anything the fixture did not ask for.
|
||||
function save(path: string): bool { return Health.saveReport(path); }
|
||||
function recheck(id: string): bool { return Health.refreshCheck(id); }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
ShellRoot {
|
||||
IpcHandler {
|
||||
target: "lock-screen-test"
|
||||
|
||||
function status(): string {
|
||||
return JSON.stringify({
|
||||
generated: LockScreen.generated,
|
||||
path: LockScreen.path,
|
||||
fallback: LockScreen.fallback,
|
||||
lastError: LockScreen.lastError,
|
||||
busy: LockScreen.busy
|
||||
});
|
||||
}
|
||||
|
||||
function burst(): void {
|
||||
DesktopPreferences.set("lockBlurLevel", 1);
|
||||
DesktopPreferences.set("lockBlurLevel", 2);
|
||||
DesktopPreferences.set("lockBlurLevel", 4);
|
||||
}
|
||||
|
||||
function refresh(): void {
|
||||
LockScreen.refresh();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
|
||||
import qs.modules.settings
|
||||
|
||||
ShellRoot {
|
||||
id: root
|
||||
|
||||
readonly property string fixtureWallpaper: `${Quickshell.env("HOME")}/Pictures/Wallpapers/faroe_islands.jpg`
|
||||
|
||||
LockScreenPreview {
|
||||
id: screenshotPreview
|
||||
width: 620
|
||||
backgroundMode: "screenshot"
|
||||
blurLevel: 4
|
||||
showClock: true
|
||||
showDate: true
|
||||
showUser: true
|
||||
fadeOnEmpty: false
|
||||
wallpaperPath: root.fixtureWallpaper
|
||||
}
|
||||
|
||||
LockScreenPreview {
|
||||
id: wallpaperPreview
|
||||
width: 620
|
||||
backgroundMode: "wallpaper"
|
||||
blurLevel: 2
|
||||
showClock: false
|
||||
showDate: true
|
||||
showUser: false
|
||||
fadeOnEmpty: true
|
||||
wallpaperPath: root.fixtureWallpaper
|
||||
}
|
||||
|
||||
LockScreenPreview {
|
||||
id: solidPreview
|
||||
width: 620
|
||||
backgroundMode: "solid"
|
||||
blurLevel: 0
|
||||
showClock: true
|
||||
showDate: false
|
||||
showUser: true
|
||||
fadeOnEmpty: false
|
||||
wallpaperPath: root.fixtureWallpaper
|
||||
}
|
||||
|
||||
IpcHandler {
|
||||
target: "lock-screen-settings-test"
|
||||
|
||||
function status(): string {
|
||||
return JSON.stringify({
|
||||
screenshot: {
|
||||
mode: screenshotPreview.previewMode,
|
||||
wallpaperVisible: screenshotPreview.wallpaperVisible,
|
||||
blurStrength: screenshotPreview.blurStrength,
|
||||
clockVisible: screenshotPreview.clockVisible,
|
||||
dateVisible: screenshotPreview.dateVisible,
|
||||
userVisible: screenshotPreview.userVisible,
|
||||
passwordVisible: screenshotPreview.passwordVisible
|
||||
},
|
||||
wallpaper: {
|
||||
mode: wallpaperPreview.previewMode,
|
||||
wallpaperVisible: wallpaperPreview.wallpaperVisible,
|
||||
blurStrength: wallpaperPreview.blurStrength,
|
||||
clockVisible: wallpaperPreview.clockVisible,
|
||||
dateVisible: wallpaperPreview.dateVisible,
|
||||
userVisible: wallpaperPreview.userVisible,
|
||||
passwordVisible: wallpaperPreview.passwordVisible
|
||||
},
|
||||
solid: {
|
||||
mode: solidPreview.previewMode,
|
||||
wallpaperVisible: solidPreview.wallpaperVisible,
|
||||
blurStrength: solidPreview.blurStrength,
|
||||
clockVisible: solidPreview.clockVisible,
|
||||
dateVisible: solidPreview.dateVisible,
|
||||
userVisible: solidPreview.userVisible,
|
||||
passwordVisible: solidPreview.passwordVisible
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
# Coming from another desktop
|
||||
|
||||
Panama is Hyprland with a shell built to replace a GNOME session, so most of
|
||||
what you know still applies. The differences worth knowing on day one are
|
||||
below. Nothing here is a preference you have to accept: every one of them is
|
||||
adjustable in Settings.
|
||||
|
||||
## Windows arrange themselves
|
||||
|
||||
The biggest change. You do not drag windows into position. Open a second
|
||||
window and the screen splits; open a third and it splits again. This is
|
||||
called tiling, and after a day of it, dragging windows feels like making
|
||||
your own bed in a hotel.
|
||||
|
||||
Two things follow from it:
|
||||
|
||||
- **There is no minimise.** A minimised window is a window you have hidden
|
||||
from a layout that was managing it for you. Use `Super + Shift + X` to
|
||||
push a window to the scratchpad and `Super + X` to bring it back, which is
|
||||
the same idea without the layout losing track of it.
|
||||
- **Floating still exists.** `Super + Ctrl + C` makes a window float above
|
||||
the layout, for the dialog that was never meant to be tiled.
|
||||
|
||||
## Workspaces replace the taskbar
|
||||
|
||||
Instead of many windows on one screen, use many screens. `Alt + 1` through
|
||||
`Alt + 0` jump straight to a workspace; `Alt + Shift + 1` sends the window
|
||||
you are looking at to workspace one. `Super + backtick` shows all of them at
|
||||
once.
|
||||
|
||||
If you came from macOS, this is Spaces with the friction removed. If you came
|
||||
from Windows, it is virtual desktops that are actually worth using because
|
||||
switching is one key rather than three.
|
||||
|
||||
## The launcher is the menu
|
||||
|
||||
There is no applications menu to browse. `Super + Space` opens a search box
|
||||
that finds applications, files, clipboard history, emoji, and does
|
||||
arithmetic. Type three letters of what you want.
|
||||
|
||||
This replaces the Start menu, Spotlight, the GNOME overview's app grid, and
|
||||
the run dialog, and it is faster than all of them because it never asks you
|
||||
to aim at anything.
|
||||
|
||||
## What stayed the same
|
||||
|
||||
`Super + Tab` still switches windows. The bar is still along the top with a
|
||||
clock in the middle and status on the right. There is still a dock. Volume
|
||||
and brightness keys still work, and still work on the lock screen. Copy and
|
||||
paste are unchanged. Your files are where you left them.
|
||||
|
||||
## Where the settings went
|
||||
|
||||
`Super + I` opens Panama's own Settings. The sidebar is where GNOME's panels
|
||||
ended up, and it is worth one read so you never have to hunt:
|
||||
|
||||
| If you are looking for | It is under |
|
||||
|---|---|
|
||||
| Themes, wallpaper, fonts, text size, titlebars | Appearance |
|
||||
| The bar, the dock, Control Center, tiling, workspaces | Shell |
|
||||
| Resolution, scale, arrangement, night light | Displays |
|
||||
| Output and input devices, per-application volume | Sound |
|
||||
| Do Not Disturb, per-application rules, focus modes | Notifications & Focus |
|
||||
| Shortcuts, mouse and touchpad, dictation | Input |
|
||||
| Wi-Fi and VPN, the firewall, sharing, printers | Network & Sharing |
|
||||
| Default applications, autostart, gaming, Screen Intelligence | Applications |
|
||||
| The accounts on this machine, and online ones | Users & Accounts |
|
||||
| Permissions, saved passwords, what the machine remembers, SSH keys | Privacy & Security |
|
||||
| Battery, the lid, the power button, idle and lock timings | Power & Lock |
|
||||
| Magnifier, color filters, reduced motion, screen reader | Accessibility |
|
||||
| Updates, storage, snapshots, date and time, backups | System |
|
||||
|
||||
Home is the page `Super + I` lands on. The search box above the sidebar
|
||||
searches the settings themselves rather than the page names, so typing what you
|
||||
want to change is usually faster than remembering which of those it lives in.
|
||||
|
||||
GNOME Settings is still installed, and Panama's Settings opens it in exactly
|
||||
two places: adding an online account, which has to go through the provider's
|
||||
own dialog, and Digital wellbeing, which is the one panel of GNOME's that does
|
||||
something Panama does not.
|
||||
@@ -0,0 +1,66 @@
|
||||
# The keyboard
|
||||
|
||||
Press `Super + /` at any time to see every shortcut this machine has. That
|
||||
list is generated from the live keymap, so it is never out of date, and it
|
||||
includes anything you have rebound.
|
||||
|
||||
This chapter is the shape behind that list, which is the part worth learning
|
||||
once.
|
||||
|
||||
## Three modifiers, three jobs
|
||||
|
||||
| Modifier | Acts on |
|
||||
|---|---|
|
||||
| `Super` | The window you are looking at |
|
||||
| `Alt` | Workspaces |
|
||||
| `Super + Ctrl` | The layout itself: splitting, floating, swapping |
|
||||
| `Super + Alt` | The application you already have open |
|
||||
|
||||
Almost every shortcut follows from this. `Super + H` moves focus left.
|
||||
`Alt + H` moves to the workspace on the left. `Super + Ctrl + H` swaps the
|
||||
window leftward in the layout.
|
||||
|
||||
## Directions
|
||||
|
||||
`H`, `J`, `K`, `L` mean left, down, up, right, as they do in vim. The arrow
|
||||
keys work everywhere the letters do, so you never have to learn them if you
|
||||
would rather not.
|
||||
|
||||
Add `Shift` to move the window rather than the focus. `Super + Shift + L`
|
||||
takes the current window and moves it to the right.
|
||||
|
||||
## Opening versus going to
|
||||
|
||||
`Super + T` opens a terminal. It opens another one every time, which on a
|
||||
tiling desktop is the normal way to work rather than an accident.
|
||||
|
||||
`Super + Alt + T` goes to a terminal you already have, wherever it is, and
|
||||
opens one only if there is none. The same pair works for the editor, browser,
|
||||
files, calculator and mail.
|
||||
|
||||
So the plain key means "give me one" and adding `Alt` means "take me to the
|
||||
one I have".
|
||||
|
||||
## The handful worth memorising first
|
||||
|
||||
| Keys | What it does |
|
||||
|---|---|
|
||||
| `Super + Space` | Find and open anything |
|
||||
| `Super + T` | Terminal (another one) |
|
||||
| `Super + Alt + T` | The terminal you already have |
|
||||
| `Super + Q` | Close the window |
|
||||
| `Super + backtick` | Every window, every workspace |
|
||||
| `Super + I` | Settings |
|
||||
| `Super + /` | Every other shortcut |
|
||||
|
||||
## Changing them
|
||||
|
||||
Settings has a [Keyboard page](panama://settings/shortcuts), under Input, listing every bind, each of which
|
||||
can be reassigned. A rebind moves the shortcut and cannot change what it does, so
|
||||
there is no way to make a key do something unexpected by editing it.
|
||||
|
||||
Shortcuts you invent live in the same list, in a **Custom** group above the
|
||||
rest. One of those names an application, a shell action or a window move rather
|
||||
than storing a command — for the same reason: what you are choosing is which
|
||||
key, not what it runs. A chord something already holds is refused rather than
|
||||
stolen, so a new shortcut can never quietly cost you an old one.
|
||||
@@ -0,0 +1,77 @@
|
||||
# Windows and workspaces
|
||||
|
||||
## How the split is chosen
|
||||
|
||||
A new window splits the space of the window that had focus. Whether it splits
|
||||
beside or below depends on which way that space is longer, which usually
|
||||
matches what you were about to do anyway.
|
||||
|
||||
When it does not, `Super + Ctrl + G` flips the split of the focused window,
|
||||
and `Super + Ctrl + Z` and `Super + Ctrl + V` decide the direction the *next*
|
||||
window will take before you open it.
|
||||
|
||||
## Resizing
|
||||
|
||||
`Super + Shift` plus `Y`, `O`, `B`, `M` adjusts width; `I`, `U`, `N` and
|
||||
comma adjust height. These are inverted from what their letters suggest, on
|
||||
purpose, and the reason is written in `config/dot/hypr/keybinds.lua` next to
|
||||
the binds.
|
||||
|
||||
For anything finer, hold `Super` and drag with the right mouse button.
|
||||
|
||||
## Fullscreen and floating
|
||||
|
||||
`Super + U` makes the focused window fullscreen. `Super + Ctrl + C` lifts it
|
||||
out of the layout to float, and `Super + Ctrl + Shift + C` pins it so it
|
||||
follows you between workspaces. A floating window can be moved by holding
|
||||
`Super` and dragging with the left button.
|
||||
|
||||
## Workspaces
|
||||
|
||||
Ten you can jump straight to, on `Alt + 1` through `Alt + 0`, though they are
|
||||
created and destroyed as you use them rather than sitting there empty. `Alt + H`
|
||||
and `Alt + L` step between neighbours — right past the last one makes a new one,
|
||||
left from the first stays put — and `Alt + Shift` plus a number sends the
|
||||
current window somewhere without following it.
|
||||
|
||||
On a machine with more than one display, workspaces belong to whichever
|
||||
screen has focus. If you would rather pin workspaces one through ten to your
|
||||
main display and give the second screen its own, that is a switch on the
|
||||
Displays settings page.
|
||||
|
||||
How windows share the space — the layout, the master area, gaps and window
|
||||
edges — is Shell › Tiling. The workspace switches themselves, and the projects
|
||||
below, are Shell › Workspaces.
|
||||
|
||||
## The touchpad
|
||||
|
||||
Three fingers sideways moves between workspaces and follows your fingers as it
|
||||
goes; three fingers up opens the overview and three down closes it. Those are
|
||||
GNOME's gestures, reproduced, and they do not move.
|
||||
|
||||
Four fingers are yours. Each of the four directions can be given a job — a shell
|
||||
action, a window or workspace move — from the same list your own shortcuts pick
|
||||
from, on Input › Mouse & Touchpad. Assigning one reloads the compositor, so
|
||||
expect a beat of black before it works. Unassigned directions are left alone
|
||||
rather than bound to nothing.
|
||||
|
||||
## Rules for one application
|
||||
|
||||
Some windows want the same treatment every time: the password manager should
|
||||
float, chat should open on workspace four, a picture viewer should not be
|
||||
resized to fill half the screen. **Application window rules** on Shell › Tiling is where that
|
||||
is written down. You pick an application and tick what should happen to it —
|
||||
float, center it, pin it to every workspace, give it a fixed size or a
|
||||
workspace to open on — rather than writing a match pattern. Panama's own
|
||||
surfaces cannot be targeted, and a rule with nothing ticked is refused rather
|
||||
than saved as a row you would wonder about later.
|
||||
|
||||
## Projects
|
||||
|
||||
A project is the set of windows you open together: which applications, which
|
||||
workspace each was on, and for a terminal, which directory it was in.
|
||||
|
||||
Arrange things the way you want them, then run **Save Layout as Project**
|
||||
from the launcher. **Open Project** lays it out again on free workspaces, so
|
||||
it never lands on top of what you are already doing. An application that
|
||||
refuses to open twice is moved into place rather than launched again.
|
||||
@@ -0,0 +1,83 @@
|
||||
# When something breaks
|
||||
|
||||
## Start here
|
||||
|
||||
Run **Check System Health** from the launcher, or open
|
||||
[System Health](panama://settings/services). It reports what is actually running rather than what was
|
||||
installed, and it can repair several things itself.
|
||||
|
||||
From a terminal, the same check is `panama doctor`.
|
||||
|
||||
## The shell is gone
|
||||
|
||||
If the bar and dock disappear, the Quickshell process died. **Restart Desktop
|
||||
Shell** from the launcher brings it back. If the launcher is also gone, press
|
||||
`Super + Shift + R` for the fallback launcher, which depends on nothing but
|
||||
itself and exists for exactly this.
|
||||
|
||||
From a terminal: `qs -d` starts the shell again.
|
||||
|
||||
## A shortcut stopped working
|
||||
|
||||
Check `Super + /` first: if the shortcut shows a different chord than you
|
||||
expect, something rebound it. The Keyboard page, under Input in Settings, can
|
||||
reset any bind, or all of them at once.
|
||||
|
||||
If the whole keymap is missing from that list, the compositor config failed
|
||||
to load. `Hyprland --verify-config` says why without touching your session.
|
||||
|
||||
## The screen resolution is wrong
|
||||
|
||||
Settings has a [Displays page](panama://settings/displays). It opens on a picture of what is connected —
|
||||
one display or several — and everything under that picture belongs to
|
||||
whichever one you have selected. Every change there reverts itself after
|
||||
fifteen seconds unless you confirm it, so a mode your monitor cannot show
|
||||
cannot strand you, and that covers color, bit depth and mirroring as well as
|
||||
resolution, scale and rotation. If you are already stranded, `hyprctl
|
||||
monitors` from a terminal shows what is applied.
|
||||
|
||||
## Something asked for a password and I do not know why
|
||||
|
||||
Panama's password prompt names the action that asked. If it was started by an
|
||||
agent or a script through `panama-sudo`, it also shows a stated reason,
|
||||
labelled as an unverified claim, beside the real action.
|
||||
|
||||
If you did not expect it at all, cancel. Nothing that needs privileges is
|
||||
harmed by being refused.
|
||||
|
||||
## A button did nothing except change its wording
|
||||
|
||||
That is the button working. Anything in Settings that removes something takes
|
||||
two presses: the first arms it, and the row then shows what is about to happen
|
||||
beside a **Keep** that backs out. The second press is the one that does it.
|
||||
|
||||
Only one button in Settings is armed at a time, so arming a second one disarms
|
||||
the first, wherever it was.
|
||||
|
||||
## The network I need is not in the list
|
||||
|
||||
The Wi-Fi list is what is in range. **Saved networks**, further down
|
||||
[Connections](panama://settings/connectivity), is what this machine remembers,
|
||||
including the ones nowhere near you — which is where to tidy up a profile you
|
||||
want rid of, rather than waiting until you are standing next to it again. A
|
||||
network that broadcasts no name is joined with **Join a hidden network…**, at
|
||||
the bottom of the list.
|
||||
|
||||
Opening a connection shows what it actually got — address, gateway, DNS — and
|
||||
is where you give it a fixed address instead when what it got does not work,
|
||||
or mark it metered so updates and large downloads wait until you are somewhere
|
||||
else.
|
||||
|
||||
## After an update, something is not right
|
||||
|
||||
`panama migrate` applies repairs this machine has not had yet. It is safe to
|
||||
run at any time and does nothing when there is nothing to do.
|
||||
|
||||
If an update went badly, [Snapshots](panama://settings/snapshots) under System can roll
|
||||
the system back. That tab is only there where btrfs snapshots are configured,
|
||||
so if you cannot see it, this machine has none to roll back to.
|
||||
|
||||
## Reporting it
|
||||
|
||||
`panama doctor` produces a redacted summary suitable for pasting into an
|
||||
issue. It deliberately omits tokens, hostnames and account details.
|
||||
@@ -0,0 +1,161 @@
|
||||
# Making it yours
|
||||
|
||||
## Settings
|
||||
|
||||
`Super + I`. Fourteen categories down the side, covering appearance, displays,
|
||||
sound, input, network, power, accounts and the rest. A category with more than
|
||||
one subject in it opens a row of tabs above the page, which is where the
|
||||
narrower topics live: Printers is a tab of Network & Sharing, Dictation a tab
|
||||
of Input, and About, Software Update, Storage, Snapshots, Sync & Backup and
|
||||
this manual are tabs of System.
|
||||
|
||||
Home is the first of them, and the page `Super + I` lands on. Its Overview tab
|
||||
is the one you already know; My Home lists your Home Assistant lights by room
|
||||
alongside the accessories Control Center shows, and Phone holds the iPhone
|
||||
handoffs — battery, ring it, send it the clipboard or a file, and Messages.
|
||||
|
||||
The search box at the top searches the settings themselves rather than page
|
||||
names, and each result says where it will land, so if you know what you want
|
||||
to change you can type it and never touch the sidebar.
|
||||
|
||||
## Appearance
|
||||
|
||||
The Appearance page opens on **Themes**. At the top are light and dark, side by
|
||||
side, each showing the theme it will land on; below them are two galleries —
|
||||
six dark themes and four light ones, including Catppuccin, Nord, Gruvbox and
|
||||
Everforest alongside Panama's own Tokyo Night Moon and Day.
|
||||
|
||||
Light and dark remember separately. Pick Everforest for the dark side and
|
||||
Catppuccin Latte for the light one, and flipping between them lands on each
|
||||
rather than resetting to the shipped pair.
|
||||
|
||||
Choosing a theme repaints the shell, the terminal, GTK applications, the lock
|
||||
screen and the system monitor at once — not just the shell's own windows.
|
||||
|
||||
### Build your own
|
||||
|
||||
The **Theme editor** tab is four colors: primary, secondary, background and
|
||||
foreground. Each has a swatch, a hex field you can type into, a color wheel
|
||||
and an eyedropper for sampling anything on screen. Background and foreground
|
||||
are not single colors — the panels, popovers, dividers and dimmed text are all
|
||||
mixed from them, so moving the background moves the whole family with it.
|
||||
|
||||
Under that, a saturation slider for the whole palette at once, a fine-tune for
|
||||
the exact hue of each end of the accent, and the effects — corners, blur,
|
||||
shadows, glow and motion. Save it with a name and it appears in the galleries
|
||||
above beside the shipped ones, effects included. Editing a shipped theme does
|
||||
not damage it: it makes you a copy.
|
||||
|
||||
### Wallpapers
|
||||
|
||||
The wallpaper is per-monitor if you want it to be, and it can be a video.
|
||||
|
||||
Videos loop muted and decode on the GPU. They pause themselves while a game is
|
||||
running, and on battery unless you say otherwise; while one is playing a pill
|
||||
appears in the bar, and clicking it pauses immediately — for a call, a
|
||||
recording, or just quiet. The lock screen shows a still frame from the video,
|
||||
because it cannot play motion.
|
||||
|
||||
### Titlebars
|
||||
|
||||
There is no minimize button, on Panama's windows or on GNOME applications.
|
||||
Hyprland has no minimize, so the button would do nothing; maximize means very
|
||||
little in a tiler. You get close, on whichever side you prefer. Panama's own
|
||||
titlebar can be turned off entirely, which leaves the Settings window bare:
|
||||
`Super + Q` closes it, `Super + drag` moves it, `Escape` still works.
|
||||
|
||||
## Shell
|
||||
|
||||
Everything Panama draws on the screen has a tab under **Shell**: the bar, the
|
||||
dock, Control Center, tiling and workspaces. Appearance decides the colors;
|
||||
this decides what is there at all.
|
||||
|
||||
### The bar
|
||||
|
||||
The bar has no background of its own — it floats on your wallpaper. That is
|
||||
fine over most images and hopeless over a few, so the **Bar** tab opens on the
|
||||
three controls that fix it. **Bar text** follows your theme by default, or you
|
||||
can force it light or dark for the wallpaper you actually use. **Bar text
|
||||
shadow** puts a soft halo under every glyph at once. **Bar backdrop** fades a
|
||||
thin scrim down from the top edge. All three land on the real bar above the
|
||||
window as you change them, so you can watch rather than guess.
|
||||
|
||||
Under that, one switch per widget: weather, media, the clipboard button, the
|
||||
calendar countdown, processor, memory, graphics, battery and agent usage. A
|
||||
switch only ever removes something — turning weather on does not make a
|
||||
forecast appear before one has been fetched. The indicators that come and go on
|
||||
their own — system health, microphone and camera, focus, a paused video
|
||||
wallpaper — have no switches, because they are already absent whenever they
|
||||
have nothing to say.
|
||||
|
||||
Seconds and the weekday are here too. **24-hour time** is not: it also changes
|
||||
the date menu, your notification timestamps and the lock screen, so it lives on
|
||||
System › Date & Time with the rest of the clock.
|
||||
|
||||
### The dock
|
||||
|
||||
**Pinned applications** shows the dock as the dock shows it. Drag an icon to
|
||||
move it, hover one for the × that unpins it, and search underneath to add
|
||||
something. The order here is the order on screen.
|
||||
|
||||
The dock itself takes the same gestures now. Drag an icon along the dock to
|
||||
reorder it without opening Settings at all. Right-click one for its menu: every
|
||||
open window of that application listed by title, whatever shortcuts the
|
||||
application itself offers, then a new window, pin or unpin, quit, and **Dock
|
||||
settings** if you want the rest. Scroll on an icon to step through that
|
||||
application's windows. Hover one that has windows open and a preview of each
|
||||
appears after a moment.
|
||||
|
||||
For pinning something that is not running, the launcher has **Add App to
|
||||
Dock** — type it into `Super + Space`, pick the application, done.
|
||||
|
||||
### Control Center
|
||||
|
||||
The panel that opens from the status cluster at the right of the bar. The
|
||||
**Control Center** tab turns its sections on and off: Focus, Home and Phone.
|
||||
Hiding one only hides it from the panel; the settings page behind it still
|
||||
works.
|
||||
|
||||
### Tiling and workspaces
|
||||
|
||||
The last two tabs hold what used to be spread across the old Desktop page.
|
||||
**Tiling** is the window layout, the master area, gaps and window edges, and
|
||||
the Hyprland notices Panama keeps quiet. **Workspaces** is the workspace
|
||||
behaviour, how long a focus session runs, and your saved projects.
|
||||
|
||||
## Carrying settings between machines
|
||||
|
||||
System › **[Sync & Backup](panama://settings/sync)**. Export writes your settings to a file; importing
|
||||
one shows you exactly what would change before anything does, and you decide
|
||||
then. Below it, **Settings backups** keeps dated copies you can restore from —
|
||||
these are your settings, not btrfs Snapshots, which is a different tab and
|
||||
covers the whole filesystem. **Restore defaults** is at the bottom: it resets
|
||||
appearance, the dock and the applications pinned to it, clock, focus and
|
||||
display policy, removes any themes you saved, and clears your Home accessory
|
||||
arrangement. Your files, paired devices and settings backups are left alone.
|
||||
|
||||
## Applications
|
||||
|
||||
`panama apps` in a terminal offers the optional application categories the
|
||||
installer asked about, so a machine can acquire Slack in March without having
|
||||
wanted Discord in January.
|
||||
|
||||
`panama app` builds the handful of applications no repository carries. It is
|
||||
deliberately not part of installing, because a source build is slow and
|
||||
depends on an upstream that moves.
|
||||
|
||||
## Autostart and defaults
|
||||
|
||||
The Applications settings page controls which application opens which kind of
|
||||
file, and what starts when you log in.
|
||||
|
||||
## Going further
|
||||
|
||||
The compositor is configured in Lua under `~/.config/hypr`, and every file
|
||||
there explains what it does and why. `overrides.lua` is loaded last and is
|
||||
the intended place for per-machine changes, so you can adjust anything
|
||||
without editing a file the repository will update.
|
||||
|
||||
The shell is QML under `~/.config/quickshell`. Both directories are symlinks
|
||||
into the Panama repository, so an edit is a change to your checkout and
|
||||
`panama sync` will offer to commit it.
|
||||
@@ -1,9 +1,9 @@
|
||||
// Exercises Migrations.applyWith against fixture steps and prints a verdict per
|
||||
// case. Run by tests/quickshell/migrations-contract.sh.
|
||||
// case. Run by tests/quickshell/migrations-contract.
|
||||
//
|
||||
// Fixture steps rather than the real list: the real one is empty until the
|
||||
// first breaking schema change, and a mechanism that has never been run against
|
||||
// a failing step is not one to discover the behaviour of during an upgrade.
|
||||
// a failing step is not one to discover the behavior of during an upgrade.
|
||||
|
||||
import Quickshell
|
||||
import QtQuick
|
||||
|
||||
@@ -13,6 +13,9 @@ Pill {
|
||||
horizontalPadding: 8
|
||||
onActivated: ShellState.toggle("activity")
|
||||
|
||||
// Right-click opens the settings that govern this widget. Camera, microphone and screen-sharing state is a privacy readout.
|
||||
onSecondaryActivated: ShellState.openSettings("privacy")
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: {
|
||||
@@ -33,7 +36,7 @@ Pill {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: PrivacyState.activeKinds.length > 1
|
||||
text: String(PrivacyState.activeKinds.length)
|
||||
color: Theme.fg
|
||||
color: Theme.barFg
|
||||
font.family: Theme.fontFamily
|
||||
font.features: Theme.tabularFigures
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
|
||||
@@ -15,7 +15,11 @@ PanelWindow {
|
||||
color: "transparent"
|
||||
anchors.top: true
|
||||
anchors.right: true
|
||||
margins.top: Theme.barHeight + Theme.barGap * 2
|
||||
// The gap ALONE, not the bar height plus the gap. exclusiveZone 0 means
|
||||
// "reserve nothing, but respect what others reserved", so this surface
|
||||
// already begins below the bar's zone -- adding the bar height here counted
|
||||
// it twice and left the surface floating 48px under the bar instead of 12.
|
||||
margins.top: Theme.barGap * 2
|
||||
margins.right: Theme.barSideMargin
|
||||
exclusiveZone: 0
|
||||
implicitWidth: 350
|
||||
@@ -25,10 +29,18 @@ PanelWindow {
|
||||
WlrLayershell.layer: WlrLayer.Overlay
|
||||
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
|
||||
|
||||
// Deliberately does not read Capture.recordingSeconds (or anything else
|
||||
// that ticks once a second): this array is a plain JS array, not an
|
||||
// identity-preserving model, so any dependency that changes every second
|
||||
// would make the whole thing re-derive every second, and the Repeater
|
||||
// below would destroy and recreate every row -- including one the user
|
||||
// might be hovering or about to click. The set of activities should only
|
||||
// change when an activity actually starts or stops. Elapsed time is
|
||||
// rendered by each row's own Text binding instead, further down.
|
||||
readonly property var activities: {
|
||||
const result = [];
|
||||
if (PrivacyState.recordingActive)
|
||||
result.push({ kind: "recording", glyph: "\u{F044A}", label: "Screen recording", detail: "Panama · " + root.elapsed(), tone: "danger", stoppable: true });
|
||||
result.push({ kind: "recording", glyph: "\u{F044A}", label: "Screen recording", detail: "Panama", tone: "danger", stoppable: true });
|
||||
if (PrivacyState.screenSharingActive)
|
||||
result.push({ kind: "screen", glyph: "\u{F0379}", label: "Screen sharing", detail: PrivacyState.screenSharingApp || "Managed by the application", tone: "warn", stoppable: false });
|
||||
if (PrivacyState.cameraActive)
|
||||
@@ -38,11 +50,12 @@ PanelWindow {
|
||||
return result;
|
||||
}
|
||||
|
||||
function elapsed(): string {
|
||||
const total = Capture.recordingSeconds;
|
||||
const seconds = String(total % 60).padStart(2, "0");
|
||||
const minutes = Math.floor(total / 60) % 60;
|
||||
const hours = Math.floor(total / 3600);
|
||||
// Pure formatter, no ticking property read here -- callers decide what
|
||||
// seconds value to pass, and only they take on the per-second dependency.
|
||||
function formatElapsed(totalSeconds: int): string {
|
||||
const seconds = String(totalSeconds % 60).padStart(2, "0");
|
||||
const minutes = Math.floor(totalSeconds / 60) % 60;
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
return hours > 0 ? `${hours}:${String(minutes).padStart(2, "0")}:${seconds}` : `${minutes}:${seconds}`;
|
||||
}
|
||||
|
||||
@@ -164,7 +177,11 @@ PanelWindow {
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: activityRow.modelData.detail
|
||||
// Only this Text re-evaluates every second while
|
||||
// recording -- Capture.recordingSeconds is read
|
||||
// here, not in the parent `activities` array, so
|
||||
// the row itself is never torn down for a tick.
|
||||
text: activityRow.modelData.kind === "recording" ? activityRow.modelData.detail + " · " + root.formatElapsed(Capture.recordingSeconds) : activityRow.modelData.detail
|
||||
color: Theme.fgDim
|
||||
elide: Text.ElideRight
|
||||
font.family: Theme.fontFamily
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user