diff --git a/bin/omarchy-plymouth-set b/bin/omarchy-plymouth-set index 88e59b0a..1fd70d50 100755 --- a/bin/omarchy-plymouth-set +++ b/bin/omarchy-plymouth-set @@ -34,6 +34,15 @@ if [[ ! -f $logo_path ]]; then exit 1 fi +# omarchy-plymouth-set-by-theme passes a theme's unlock.png straight from +# ~/.config/omarchy/themes, where an installed theme can make it a symlink to +# anything. The copies below land in world-readable /usr/share, so following one +# would republish whatever it points at. +if [[ -L $logo_path ]]; then + echo "Logo file is a symlink, which is not accepted: $logo_path" >&2 + exit 1 +fi + bg_r=$(awk -v n=$((16#${bg_hex:0:2})) 'BEGIN{printf "%.3f", n/255}') bg_g=$(awk -v n=$((16#${bg_hex:2:2})) 'BEGIN{printf "%.3f", n/255}') bg_b=$(awk -v n=$((16#${bg_hex:4:2})) 'BEGIN{printf "%.3f", n/255}') @@ -72,7 +81,7 @@ sed \ -e "s/#ffffff/#$text_hex/g" \ "$sddm_template" | sudo tee "$sddm_dir/Main.qml" >/dev/null -sudo cp "$logo_path" "$sddm_dir/logo.png" +sudo cp "$staging_dir/logo.png" "$sddm_dir/logo.png" for asset in bullet.png entry.png lock.png; do sudo cp "$staging_dir/$asset" "$sddm_dir/$asset" done diff --git a/bin/omarchy-theme-install b/bin/omarchy-theme-install index cf6a9df6..286611e2 100755 --- a/bin/omarchy-theme-install +++ b/bin/omarchy-theme-install @@ -16,24 +16,42 @@ if [[ -z $REPO_URL ]]; then exit 1 fi +# git reads a leading dash as an option, and `::
` as a remote +# helper to run. The helper name is a bare word at the very start, which is what +# this matches; an scp-style IPv6 host such as git@[2001:db8::1]:org/repo.git +# carries `::` too and must still clone. +if [[ $REPO_URL == -* || $REPO_URL =~ ^[A-Za-z0-9][A-Za-z0-9+.-]*:: ]]; then + echo "Error: '$REPO_URL' names a git option or transport helper, not a repository." + exit 1 +fi + THEMES_DIR="$HOME/.config/omarchy/themes" # Strip user@host: prefix from scp-style SSH URLs so basename sees just the path REPO_PATH="$REPO_URL" [[ $REPO_PATH != *"://"* && $REPO_PATH == *:*/* ]] && REPO_PATH="${REPO_PATH#*:}" -THEME_NAME=$(basename "$REPO_PATH" .git | sed -E 's/^omarchy-//; s/-theme$//' | tr '[:upper:]' '[:lower:]') +THEME_NAME=$(basename -- "$REPO_PATH" .git | sed -E 's/^omarchy-//; s/-theme$//' | tr '[:upper:]' '[:lower:]') THEME_PATH="$THEMES_DIR/$THEME_NAME" +# The name comes from the URL and is joined into a path that is about to be +# removed, so a repo called `..` would take ~/.config/omarchy with it. A leading +# dot is refused with it: `host:-s/foo.git` leaves basename with `.git`. +if [[ -z $THEME_NAME || $THEME_NAME == .* || $THEME_NAME == */* ]]; then + echo "Error: '$REPO_URL' does not give a usable theme name." + exit 1 +fi + # Remove existing theme if present if [[ -d $THEME_PATH ]]; then rm -rf "$THEME_PATH" fi # Clone the repo directly to ~/.config/omarchy/themes -if ! git clone "$REPO_URL" "$THEME_PATH"; then +if ! git clone -- "$REPO_URL" "$THEME_PATH"; then echo "Error: Failed to clone theme repo." exit 1 fi -# Apply the new theme with omarchy-theme-set -omarchy-theme-set $THEME_NAME +# Apply the new theme with omarchy-theme-set, which stages only the files an +# extra theme is allowed to contribute and names anything it dropped. +omarchy-theme-set "$THEME_NAME" diff --git a/bin/omarchy-theme-remove b/bin/omarchy-theme-remove index 56ccddf7..7e58289f 100755 --- a/bin/omarchy-theme-remove +++ b/bin/omarchy-theme-remove @@ -21,8 +21,9 @@ fi THEMES_DIR="$HOME/.config/omarchy/themes" THEME_PATH="$THEMES_DIR/$THEME_NAME" -# Ensure a theme was set -if [[ -z $THEME_NAME ]]; then +# Ensure a theme was set, and that the name cannot climb out of THEMES_DIR +# on its way into the rm below. +if [[ -z $THEME_NAME || $THEME_NAME == .* || $THEME_NAME == */* ]]; then exit 1 fi diff --git a/bin/omarchy-theme-set b/bin/omarchy-theme-set index d987d97a..b0c1cda4 100755 --- a/bin/omarchy-theme-set +++ b/bin/omarchy-theme-set @@ -17,6 +17,19 @@ THEME_SET_LOCK="${XDG_RUNTIME_DIR:-/tmp}/omarchy-theme-set.lock" USER_THEMES_PATH="$HOME/.config/omarchy/themes" OMARCHY_THEMES_PATH="$OMARCHY_PATH/themes" +# What a theme installed from a git repo may not ship, because these run code. +# Hyprland requires a theme's hyprland.lua and gum_env.lua at login and Neovim +# loads its neovim.lua at startup, so no .lua from such a theme is staged at all. +# Each terminal config names the program the terminal launches, and vscode.json +# names an extension omarchy-theme-set-vscode installs, a VS Code extension being +# arbitrary JavaScript. Everything else a theme ships is colour and is kept. +# +# Adding a template for another terminal, or for another editor that loads Lua, +# means adding it here. test/shell.d/theme-staging-test.sh fails on a generated +# theme file that is neither denied here nor recorded there as colour-only. +INSTALLED_THEME_DENIED=(alacritty.toml foot.ini ghostty.conf kitty.conf vscode.json) +IGNORED_THEME_FILES=() + run_parallel() { local pid local pids=() @@ -122,12 +135,121 @@ set_theme_background() { ln -nsf "$new_background" "$CURRENT_BACKGROUND_LINK" } +is_denied_installed_file() { + local name="$1" + local denied + + [[ $name == *.lua ]] && return 0 + + for denied in "${INSTALLED_THEME_DENIED[@]}"; do + [[ $name == "$denied" ]] && return 0 + done + + return 1 +} + +# Themes older than colors.toml still get their palette, but their +# alacritty.toml never reaches the staged theme: an Alacritty config names the +# program the terminal launches. +stage_installed_colors_from_alacritty() { + local source="$1" + local scratch + + if [[ -f $NEXT_THEME_PATH/colors.toml ]]; then + return + fi + + if [[ ! -f $source/alacritty.toml || -L $source/alacritty.toml ]]; then + return + fi + + scratch=$(mktemp -d) + cp "$source/alacritty.toml" "$scratch/alacritty.toml" + omarchy-theme-colors-from-alacritty "$scratch" + + if [[ -f $scratch/colors.toml ]]; then + cp "$scratch/colors.toml" "$NEXT_THEME_PATH/colors.toml" + fi + + rm -rf "$scratch" +} + +# Copies a directory without ever following a symlink: in an installed theme one +# points wherever the theme author chose, which is how an unlock.png becomes a +# copy of any file the session can read. +stage_installed_dir() { + local source="$1" + local dest="$2" + local entry name + + mkdir -p "$dest" + + for entry in "$source"/*; do + [[ -e $entry && ! -L $entry ]] || continue + name=${entry##*/} + + if [[ -d $entry ]]; then + stage_installed_dir "$entry" "$dest/$name" + else + cp "$entry" "$dest/$name" + fi + done +} + +# `omarchy theme install` clones into ~/.config/omarchy/themes, so a .git +# directory there means the contents came from a stranger and are held to the +# list above. A directory the user wrote themselves, and a symlink to their own +# working copy, are theirs to fill however they like -- the same distinction +# omarchy-theme-extras draws when it decides which themes it may pull. +theme_came_from_a_repo() { + local source="$1" + + [[ ! -L $source && -d $source/.git ]] +} + +stage_installed_theme() { + local source="$1" + local entry name + + [[ -d $source ]] || return 0 + + for entry in "$source"/*; do + [[ -e $entry ]] || continue + name=${entry##*/} + + if [[ -L $entry ]] || is_denied_installed_file "$name"; then + case "${name,,}" in + readme* | license* | changelog* | *.md | *.txt) ;; + *) IGNORED_THEME_FILES+=("$name") ;; + esac + elif [[ -d $entry ]]; then + stage_installed_dir "$entry" "$NEXT_THEME_PATH/$name" + else + cp "$entry" "$NEXT_THEME_PATH/$name" + fi + done + + stage_installed_colors_from_alacritty "$source" +} + +report_ignored_theme_files() { + (( ${#IGNORED_THEME_FILES[@]} > 0 )) || return 0 + + echo "Ignored in $USER_THEMES_PATH/$THEME_NAME: ${IGNORED_THEME_FILES[*]}" >&2 + echo "A theme installed from a git repo cannot supply Lua, a terminal config, or vscode.json." >&2 +} + THEME_NAME=$(echo "$1" | sed -E 's/<[^>]+>//g' | tr '[:upper:]' '[:lower:]' | tr ' ' '-') THEME_HEADLESS=0 if [[ ${OMARCHY_THEME_HEADLESS:-} == "1" || ${OMARCHY_THEME_OFFLINE:-} == "1" ]]; then THEME_HEADLESS=1 fi +if [[ -z $THEME_NAME || $THEME_NAME == .* || $THEME_NAME == */* ]]; then + echo "Invalid theme name: $1" + exit 1 +fi + if [[ ! -d $OMARCHY_THEMES_PATH/$THEME_NAME ]] && [[ ! -d $USER_THEMES_PATH/$THEME_NAME ]]; then echo "Theme '$THEME_NAME' does not exist" exit 1 @@ -143,9 +265,15 @@ flock 9 rm -rf "$NEXT_THEME_PATH" mkdir -p "$NEXT_THEME_PATH" -# Copy official theme first, then overlay user customizations on top +# Copy official theme first, then overlay the user's theme on top cp -r "$OMARCHY_THEMES_PATH/$THEME_NAME/"* "$NEXT_THEME_PATH/" 2>/dev/null -cp -r "$USER_THEMES_PATH/$THEME_NAME/"* "$NEXT_THEME_PATH/" 2>/dev/null + +if theme_came_from_a_repo "$USER_THEMES_PATH/$THEME_NAME"; then + stage_installed_theme "$USER_THEMES_PATH/$THEME_NAME" + report_ignored_theme_files +else + cp -r "$USER_THEMES_PATH/$THEME_NAME/"* "$NEXT_THEME_PATH/" 2>/dev/null +fi # Generate colors.toml from alacritty.toml if theme is missing colors.toml if [[ ! -f $NEXT_THEME_PATH/colors.toml && -f $NEXT_THEME_PATH/alacritty.toml ]]; then diff --git a/default/agents/skills/omarchy/theming.md b/default/agents/skills/omarchy/theming.md index fcd19ab6..5212aa15 100644 --- a/default/agents/skills/omarchy/theming.md +++ b/default/agents/skills/omarchy/theming.md @@ -22,11 +22,34 @@ omarchy theme install # Install from git repo Additional user backgrounds for any theme (stock or custom) go in `~/.config/omarchy/backgrounds//`. +## What a Theme Installed From a Repo May Not Contain + +A theme the user wrote by hand in `~/.config/omarchy/themes` is unrestricted, as +are Omarchy's own themes. From a theme cloned by `omarchy theme install`, Omarchy +drops only what runs code: any `*.lua` (Hyprland requires a theme's +`hyprland.lua` and `gum_env.lua` at login, Neovim loads `neovim.lua` at startup), +the terminal configs `alacritty.toml`, `foot.ini`, `ghostty.conf` and +`kitty.conf` (each names the program the terminal launches), and `vscode.json` +(names a VS Code extension to install). Those are regenerated from `colors.toml` +through `$OMARCHY_PATH/default/themed/*.tpl`, and named on stderr. + +Everything else a cloned theme ships is kept, including `btop.theme`, +`chromium.theme`, `helix.toml`, `icons.theme`, `keyboard.rgb` and `shell.toml`. +Omarchy tells a cloned theme from the user's own by the `.git` directory a clone +leaves behind. + +To change how Omarchy themes an app for every theme, write the template rather +than the theme: `~/.config/omarchy/themed/.tpl` overrides the +built-in one. See `docs/theming.md` in the Omarchy repo. + ## Customizing a Stock Theme Never edit stock themes under `/usr/share/omarchy/themes/` — changes are lost on update. Two safe options: +Both write into `~/.config/omarchy/themes`, where a theme the user wrote is +unrestricted — the list above applies only to a theme cloned from a repo. + **Overlay (preferred for small tweaks):** create a user theme directory with the SAME slug containing only the files you want to change. When the theme is applied, the stock theme is copied first and your files win on top: diff --git a/docs/theming.md b/docs/theming.md index fb270ce0..2399a7b0 100644 --- a/docs/theming.md +++ b/docs/theming.md @@ -6,20 +6,22 @@ Omarchy themes live under `themes//` in the source tree (installed at `colors.toml`; Omarchy generates the active theme files from `default/themed/*.tpl` when `omarchy-theme-set ` runs. -Beyond `colors.toml` and hand-written config overrides, a theme can ship -`backgrounds/` (users overlay their own via +Beyond `colors.toml` and hand-written config overrides, a first-party theme can +ship `backgrounds/` (users overlay their own via `~/.config/omarchy/backgrounds//`; the active image is the `~/.local/state/omarchy/current/background` symlink), `preview.png` and `preview-unlock.png` for the theme switcher, `icons.theme`, `keyboard.rgb`, `unlock.png`, and a `light.mode` marker file. +A theme installed from a git repo is held to a much shorter list; see [What an installed theme may not ship](#what-an-installed-theme-may-not-ship). + ## Theme activation flow `omarchy-theme-set ` builds a clean staging directory at `~/.local/state/omarchy/current/next-theme`: 1. Copy the first-party theme from `themes//`. -2. Overlay any user theme files from `~/.config/omarchy/themes//`. +2. Overlay `~/.config/omarchy/themes//`, in full when the user wrote it and filtered when it came from a git repo, naming anything it dropped on stderr. 3. If needed, generate `colors.toml` from `alacritty.toml`. 4. Run `omarchy-theme-set-templates` to render templates into the staging theme. @@ -43,6 +45,26 @@ Making a new app follow theme changes means adding its restart/retint command to that list. Runs serialize on a `flock`, so scripted theme changes queue instead of racing. +## What an installed theme may not ship + +`themes//` in this repo is Omarchy's own code and is trusted. So is a theme the user wrote by hand in `~/.config/omarchy/themes//`: it is their machine and their file, and both stage in full. + +`omarchy theme install ` is different. It clones a stranger's git repo straight into that same directory, so the contents are whatever the theme author pushed. `omarchy-theme-set` tells the two apart the way `omarchy-theme-extras` already does — a `.git` directory means it was cloned, while a plain directory or a symlink to a working copy is the user's own — and from a cloned one it drops only what can run code: + +- any `*.lua` — Hyprland `require`s a theme's `hyprland.lua` and `gum_env.lua` at login, and Neovim loads its `neovim.lua` at startup +- `alacritty.toml`, `foot.ini`, `ghostty.conf`, `kitty.conf` — each names the program the terminal launches +- `vscode.json` — names the extension `omarchy-theme-set-vscode` installs, and a VS Code extension is arbitrary JavaScript + +Symlinks are dropped with them, at any depth; in a cloned theme they point wherever the theme author chose. Everything a cloned theme ships that is colour is kept, including files Omarchy would otherwise have generated — `btop.theme`, `chromium.theme`, `helix.toml`, `shell.toml`, `icons.theme`, `keyboard.rgb` and the rest — so a theme can still say exactly how it wants each app to look. What is dropped gets generated from `default/themed/*.tpl` instead, and is named on stderr. + +A denylist is only right while it is maintained. Adding a template for another terminal, or for another editor that loads Lua, means adding it to `INSTALLED_THEME_DENIED` in `bin/omarchy-theme-set`; `test/shell.d/theme-staging-test.sh` fails on any `default/themed/*.tpl` whose output is recorded as neither code nor colour, so a new template cannot be added without that decision being made. + +A theme predating `colors.toml` is not left without a palette: its `alacritty.toml` is read through `omarchy-theme-colors-from-alacritty` into a scratch directory and only the resulting `colors.toml` is staged, so the colors survive and the terminal config does not. + +The restriction lives in `omarchy-theme-set` rather than in `omarchy-theme-install` on purpose. Filtering at staging also covers themes installed before the rule existed and files a theme gains later through `omarchy theme update`. + +What this does not cover: a theme distributed as an archive rather than a git repo, extracted into `~/.config/omarchy/themes/` by hand, is indistinguishable from one the user wrote and stages in full. `omarchy theme install` only takes git URLs, so the supported path is always filtered, but the check is a statement about where a theme came from and not a sandbox. + ## `colors.toml` `colors.toml` provides the palette keys used by templates. Keys are grouped @@ -352,6 +374,7 @@ local active_border_color = { colors = { "rgba(33ccffee)", "rgba(00ff99ee)" }, a ## Adding or overriding theme files - Add palette values to `themes//colors.toml`. +- Hand-written overrides work everywhere except a `.lua`, a terminal config or a `vscode.json` in a theme cloned from a git repo; see [What an installed theme may not ship](#what-an-installed-theme-may-not-ship). - Prefer generated files when the theme can be expressed with templates. - Add a hand-written file in `themes//` only when that theme needs to override the generated output entirely. diff --git a/manual/43-making-your-own-theme.md b/manual/43-making-your-own-theme.md index 2563f7c3..ed6aee82 100644 --- a/manual/43-making-your-own-theme.md +++ b/manual/43-making-your-own-theme.md @@ -6,6 +6,16 @@ The main file you have to tweak is `colors.toml`. That defines the color set tha You can also use the included Aether application to create a new theme using a lovely GUI interface to play with colors and search for backgrounds. Just start it via the apps menu on `Super + Alt + Space`. +### What an installed theme can contain + +A theme you write yourself in `~/.config/omarchy/themes` can contain whatever you like — it's your machine and your file, and Omarchy applies all of it. + +A theme you install from someone else's repo with `omarchy theme install` keeps everything that's colour, and loses the handful of files that would run code on your machine: any `.lua` file, the terminal configs (`alacritty.toml`, `foot.ini`, `ghostty.conf`, `kitty.conf`), and `vscode.json`. A theme's `hyprland.lua` is Lua your compositor runs at login, a terminal config names the program your terminal starts, and `vscode.json` names a VSCode extension to install. Installing someone's theme should change what your desktop looks like, never what it runs. + +Everything else still works exactly as the theme author wrote it — `btop.theme`, `chromium.theme`, `helix.toml`, `icons.theme`, `shell.toml`, the backgrounds and the previews are all kept. Only what was dropped gets regenerated from `colors.toml` on your machine. + +Omarchy tells the two apart by whether the theme has its own git repo inside it, which is what `omarchy theme install` leaves behind when it clones. So a theme you wrote stays yours, and one you pulled off the internet stays colours. + ### Light mode If you're making a light mode theme, set `mode = "light"` at the top of your `colors.toml`. Then it'll automatically be paired with light mode for all the apps. (The old way of dropping an empty file called `light.mode` in the root of your theme still works too.) @@ -28,4 +38,6 @@ There's a fully commented `alacritty.toml.tpl.sample` in that folder to copy fro If you want to distribute your theme so others can use it, you need to put it on a public git server, like GitHub. Then people can install it using _Install > Style > Theme_ in the Omarchy menu using that URL. It's recommended that you follow the naming convention of `omarchy-[themename]-theme`, as the theme will show correctly as just `[themename]` in the theme selection menu after installation. +Remember that once it's installed from a repo, any `.lua`, terminal config or `vscode.json` it ships is dropped, so don't build the theme around those. + You can have your theme added to [the extra themes page](https://omarchy.org/themes/) by sending a pull request to [the omarchy-site repo](https://github.com/omacom-io/omarchy-site). diff --git a/migrations/1787481315.sh b/migrations/1787481315.sh new file mode 100644 index 00000000..3aa19dea --- /dev/null +++ b/migrations/1787481315.sh @@ -0,0 +1,25 @@ +echo "Re-stage the current theme so an installed theme's code is dropped" + +# A theme installed from a repo could ship hyprland.lua, gum_env.lua, neovim.lua +# and terminal configs, and they were copied straight into the staged theme that +# Hyprland requires at login and the terminals include at launch. Staging drops +# them now, but an install that already applied such a theme keeps the staged +# copies until something changes the theme, which may be never. Re-stage once so +# the fix reaches the themes already in place rather than only the next one. +theme_name_path="$HOME/.local/state/omarchy/current/theme.name" + +[[ -s $theme_name_path ]] || exit 0 + +theme_name=$(<"$theme_name_path") + +# A theme removed while it was still current leaves theme.name naming it and the +# staged copy behind, so there is nothing left to re-stage from and those staged +# files are exactly the ones this is here to drop. Seed the default instead, +# which is where a fresh install starts and what the removal should have left. +if [[ ! -d $OMARCHY_PATH/themes/$theme_name && ! -d $HOME/.config/omarchy/themes/$theme_name ]]; then + echo "Theme '$theme_name' no longer exists; applying the default instead" + omarchy-theme-set "Tokyo Night" + exit 0 +fi + +omarchy-theme-refresh diff --git a/test/shell.d/plymouth-set-test.sh b/test/shell.d/plymouth-set-test.sh index bbc8b6de..2cbc95be 100755 --- a/test/shell.d/plymouth-set-test.sh +++ b/test/shell.d/plymouth-set-test.sh @@ -23,3 +23,21 @@ grep -Fq \ fail "omarchy-plymouth-set avoids copying staging directory ownership and mode" pass "Plymouth asset copy preserves the package-owned directory metadata" + +# omarchy-plymouth-set-by-theme hands over a theme's unlock.png from +# ~/.config/omarchy/themes, and both copies below land in world-readable +# /usr/share, so a symlink there would republish whatever it points at. +secret="$test_tmp/secret" +printf 'not yours\n' >"$secret" +ln -s "$secret" "$test_tmp/logo-link.png" + +output=$(OMARCHY_PATH="$ROOT" bash "$ROOT/bin/omarchy-plymouth-set" '#1d2021' '#ebdbb2' "$test_tmp/logo-link.png" 2>&1) +status=$? + +(( status != 0 )) || fail "omarchy-plymouth-set refuses a symlinked logo" +[[ $output == *"symlink"* ]] || fail "omarchy-plymouth-set says why it refused the logo" "$output" + +grep -Fq 'sudo cp "$staging_dir/logo.png" "$sddm_dir/logo.png"' "$ROOT/bin/omarchy-plymouth-set" || + fail "omarchy-plymouth-set copies the staged logo to SDDM rather than rereading the caller's path as root" + +pass "a themed logo cannot republish a file it merely points at" diff --git a/test/shell.d/theme-install-guards-test.sh b/test/shell.d/theme-install-guards-test.sh new file mode 100755 index 00000000..be2d41f5 --- /dev/null +++ b/test/shell.d/theme-install-guards-test.sh @@ -0,0 +1,105 @@ +#!/bin/bash + +set -euo pipefail + +# omarchy-theme-install feeds a pasted URL to git and a name derived from it to +# rm, and omarchy-theme-remove feeds its argument to rm. Both are exercised here +# with git and the themes directory stubbed, so a guard that stopped working +# shows up as a clone or a removal that should never have been reached. + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +mock_bin="$test_tmp/bin" +mkdir -p "$mock_bin" + +cat >"$mock_bin/git" <<'SH' +#!/bin/bash +printf '%s\n' "$*" >>"$OMARCHY_TEST_GIT_CALLS" +[[ $1 == "clone" ]] && mkdir -p "${*: -1}" +exit 0 +SH + +cat >"$mock_bin/gum" <<'SH' +#!/bin/bash +exit 1 +SH + +for command in omarchy-theme-set omarchy-notification-send omarchy-menu-select; do + printf '#!/bin/bash\nprintf "%%s\\n" "$*" >>"$OMARCHY_TEST_THEME_CALLS"\nexit 0\n' >"$mock_bin/$command" +done + +chmod +x "$mock_bin"/* + +git_calls="$test_tmp/git-calls" +theme_calls="$test_tmp/theme-calls" + +install_theme() { + : >"$git_calls" + : >"$theme_calls" + + HOME="$test_tmp/home" PATH="$mock_bin:$PATH" \ + OMARCHY_TEST_GIT_CALLS="$git_calls" OMARCHY_TEST_THEME_CALLS="$theme_calls" \ + bash "$ROOT/bin/omarchy-theme-install" "$1" >"$test_tmp/out" 2>&1 || return $? +} + +mkdir -p "$test_tmp/home/.config/omarchy/themes" + +# A URL git would read as an option or as a remote helper to run. +for url in "-x" "--upload-pack=touch /tmp/pwned" "ext::sh -c id" "fd::0,1"; do + if install_theme "$url"; then + fail "omarchy-theme-install refuses the URL '$url'" + fi + + [[ ! -s $git_calls ]] || fail "omarchy-theme-install refuses '$url' before running git" "$(cat "$git_calls")" +done + +pass "a URL that names a git option or a transport helper never reaches git" + +# A URL whose derived name would escape the themes directory. +for url in "https://example.com/..git" "https://example.com/.git"; do + if install_theme "$url"; then + fail "omarchy-theme-install refuses the derived name from '$url'" + fi + + [[ ! -s $git_calls ]] || fail "omarchy-theme-install refuses '$url' before running git" "$(cat "$git_calls")" +done + +pass "a URL whose name would climb out of the themes directory never reaches git" + +# basename reads a leading dash as an option once the scp-style prefix is gone. +install_theme "host:-s/foo.git" || fail "omarchy-theme-install accepts a normal scp-style URL" +grep -Fq -- "-- host:-s/foo.git" "$git_calls" || fail "omarchy-theme-install passes the URL after --" "$(cat "$git_calls")" +grep -Fq "/themes/foo" "$git_calls" || fail "omarchy-theme-install derives 'foo', not '.git'" "$(cat "$git_calls")" + +pass "a dash inside the path does not become a basename option" + +# And the ordinary case still works. +install_theme "https://github.com/example/omarchy-cool-theme.git" || fail "omarchy-theme-install clones a normal URL" +grep -Fq "/themes/cool" "$git_calls" || fail "omarchy-theme-install derives the theme name" "$(cat "$git_calls")" +grep -Fxq "cool" "$theme_calls" || fail "omarchy-theme-install applies the theme it installed" "$(cat "$theme_calls")" + +pass "an ordinary theme URL still clones and applies" + +# omarchy-theme-remove joins its argument into the path it deletes. +remove_theme() { + : >"$theme_calls" + + HOME="$test_tmp/home" PATH="$mock_bin:$PATH" OMARCHY_TEST_THEME_CALLS="$theme_calls" \ + bash "$ROOT/bin/omarchy-theme-remove" "$1" >"$test_tmp/out" 2>&1 || return $? +} + +canary="$test_tmp/home/.config/omarchy/canary" +printf 'still here\n' >"$canary" + +for name in ".." "." "../../evil" ".git"; do + if remove_theme "$name"; then + fail "omarchy-theme-remove refuses the theme name '$name'" + fi + + [[ -f $canary ]] || fail "omarchy-theme-remove refuses '$name' before removing anything" +done + +pass "a theme name cannot climb out of the themes directory on the way to rm" diff --git a/test/shell.d/theme-staging-test.sh b/test/shell.d/theme-staging-test.sh new file mode 100755 index 00000000..cc749a9e --- /dev/null +++ b/test/shell.d/theme-staging-test.sh @@ -0,0 +1,228 @@ +#!/bin/bash + +set -euo pipefail + +# A theme installed with `omarchy theme install` is a stranger's git repo, so +# omarchy-theme-set drops the files that would run its code -- Lua, terminal +# configs, vscode.json -- and keeps the colour. A theme the user wrote themselves +# is not filtered at all. + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +home="$test_tmp/home" +state="$home/.local/state/omarchy/current" +themes="$home/.config/omarchy/themes" +mkdir -p "$state" "$themes" + +marker="omarchy-theme-staging-marker" + +set_theme() { + HOME="$home" OMARCHY_PATH="$ROOT" PATH="$ROOT/bin:$PATH" \ + OMARCHY_THEME_HEADLESS=1 OMARCHY_THEME_SKIP_BACKGROUND=1 \ + XDG_RUNTIME_DIR="$test_tmp" \ + bash "$ROOT/bin/omarchy-theme-set" "$1" 2>"$test_tmp/stderr" || return $? +} + +staged() { + printf '%s' "$state/theme/$1" +} + +assert_staged() { + [[ -f $(staged "$1") ]] || fail "$2" +} + +assert_not_staged() { + [[ ! -e $(staged "$1") ]] || fail "$2" +} + +assert_no_marker() { + ! grep -q "$marker" "$(staged "$1")" || fail "$2" +} + +write_colors() { + cat >"$1" <"$hostile/hyprland.lua" +printf 'vim.cmd("%s")\n' "$marker" >"$hostile/neovim.lua" +printf 'shell %s\n' "$marker" >"$hostile/kitty.conf" +printf '[terminal.shell]\nprogram = "%s"\n' "$marker" >"$hostile/alacritty.toml" +printf 'shell = "%s"\n' "$marker" >"$hostile/foot.ini" +printf 'command = "%s"\n' "$marker" >"$hostile/ghostty.conf" +printf 'hl.env("GUM_INPUT_PROMPT", "%s")\n' "$marker" >"$hostile/gum_env.lua" +printf '[bar]\nbackground = "#%s"\n' "000000" >"$hostile/shell.toml" +printf '{}\n' >"$hostile/vscode.json" +printf 'Yaru-red\n' >"$hostile/icons.theme" +printf 'theme[main_bg]="#000000"\n' >"$hostile/btop.theme" +printf 'png\n' >"$hostile/preview.png" +printf 'png\n' >"$hostile/backgrounds/1-real.png" +printf '%s\n' "$marker" >"$hostile/backgrounds/payload.sh" +printf '# notes\n' >"$hostile/README.md" +ln -s /etc/hostname "$hostile/unlock.png" + +set_theme hostile || fail "omarchy-theme-set applies a theme that ships disallowed files" + +assert_staged colors.toml "the theme's colors.toml is staged" +grep -q '#7aa2f7' "$(staged colors.toml)" || fail "the staged colors.toml is the theme's palette" +assert_staged light.mode "the light mode marker is staged" +assert_staged preview.png "the theme's preview image is staged" +assert_staged backgrounds/1-real.png "an image in backgrounds/ is staged" + +assert_not_staged unlock.png "a symlink is not followed out of the theme" +assert_not_staged vscode.json "vscode.json names an extension to install and is not staged" + +assert_staged icons.theme "the theme's icon set name is staged" +grep -q 'Yaru-red' "$(staged icons.theme)" || fail "the staged icons.theme is the theme's" +assert_staged btop.theme "the theme's btop colours are staged" +grep -q 'main_bg' "$(staged btop.theme)" || fail "the staged btop.theme is the theme's" +assert_not_staged .git "the clone's own git directory is never staged" + +# These run code, so the theme's versions must lose to Omarchy's generated ones +# rather than merely be absent. +for generated in hyprland.lua neovim.lua gum_env.lua kitty.conf alacritty.toml foot.ini ghostty.conf; do + assert_staged "$generated" "$generated is generated from Omarchy's template" + assert_no_marker "$generated" "an installed theme cannot supply $generated" +done + +# Colour is kept, including a file Omarchy would otherwise have generated. +assert_staged shell.toml "shell.toml is staged" +grep -q '000000' "$(staged shell.toml)" || fail "an installed theme's shell.toml colours are kept" + +grep -q 'hyprland.lua' "$test_tmp/stderr" || fail "omarchy-theme-set names the files it ignored" +! grep -q 'README.md' "$test_tmp/stderr" || fail "omarchy-theme-set does not report a theme's documentation" + +pass "an installed theme keeps its colour and loses everything that runs code" + +# icons.theme is staged verbatim and handed to gsettings, so a symlinked one +# would stage a copy of whatever it points at. +linked="$themes/linked" +mkdir -p "$linked/.git" +write_colors "$linked/colors.toml" +ln -s /etc/hostname "$linked/icons.theme" + +set_theme linked || fail "omarchy-theme-set applies a theme whose icons.theme is a symlink" +assert_not_staged icons.theme "a symlinked icons.theme is not followed" + +pass "a symlinked icon set name is refused like any other symlink" + +# A theme predating colors.toml still gets a palette, without its alacritty.toml +# reaching the staged theme. +legacy="$themes/legacy" +mkdir -p "$legacy/.git" +cat >"$legacy/alacritty.toml" <"$themes/tokyo-night/hyprland.lua" + +set_theme "Tokyo Night" || fail "omarchy-theme-set applies a stock theme with a user overlay" +grep -q '#abcdef' "$(staged colors.toml)" || fail "a user overlay still replaces the stock palette" +assert_no_marker hyprland.lua "a user overlay cannot add Lua to a stock theme" + +pass "an overlay on a stock theme repaints it without adding code" + +# A theme the user wrote themselves has no git repo behind it and is theirs. +mine="$themes/mine" +mkdir -p "$mine" +write_colors "$mine/colors.toml" +printf 'os.execute("%s")\n' "$marker" >"$mine/hyprland.lua" +printf '{"name":"Mine","extension":"pub.ext"}\n' >"$mine/vscode.json" + +set_theme mine || fail "omarchy-theme-set applies a theme the user wrote" +grep -q "$marker" "$(staged hyprland.lua)" || fail "a theme the user wrote keeps its own hyprland.lua" +assert_staged vscode.json "a theme the user wrote keeps every file it ships" +[[ ! -s $test_tmp/stderr ]] || fail "a theme the user wrote reports nothing ignored" "$(cat "$test_tmp/stderr")" + +pass "a theme the user wrote is not held to the installed-theme list" + +# A working copy symlinked into the themes folder is the user's own too. +ln -s "$mine" "$themes/mine-link" +set_theme mine-link || fail "omarchy-theme-set applies a symlinked working copy" +grep -q "$marker" "$(staged hyprland.lua)" || fail "a symlinked working copy is the user's own" + +pass "a symlinked working copy is the user's own" + +# The name is joined into paths that get removed and copied into. +for name in .. . "../../evil"; do + if set_theme "$name" >/dev/null; then + fail "omarchy-theme-set rejects the theme name '$name'" + fi +done + +pass "a theme name cannot climb out of the theme directories" + +# A denylist is only correct while someone adding a template classifies what it +# generates. Every generated theme file is either denied to an installed theme or +# recorded here as carrying colour, so a new template fails until it is placed. +denied=(alacritty.toml foot.ini ghostty.conf kitty.conf gum_env.lua hyprland.lua neovim.lua vscode.json) +colour_only=(btop.theme chromium.theme claude.json helix.toml hyprland-preview-share-picker.css keyboard.rgb obsidian.css pi.json shell.toml vscode-theme.json) + +for tpl in "$ROOT"/default/themed/*.tpl; do + generated=$(basename "$tpl" .tpl) + classified="" + + for name in "${denied[@]}" "${colour_only[@]}"; do + if [[ $generated == "$name" ]]; then + classified=1 + break + fi + done + + [[ -n $classified ]] || + fail "every generated theme file is classified as code or colour" \ + "$generated has a template but is in neither list in $(basename "$0"); decide whether an installed theme may ship it" +done + +pass "every file Omarchy generates is classified as code or colour"