Stop an installed theme from running code (#7884)

* Stop an installed theme from shipping code

`omarchy theme install <url>` clones a stranger's git repository into ~/.config/omarchy/themes, and omarchy-theme-set then copied that whole directory into the staged theme. Most of the files in a staged theme are code rather than colour: Hyprland requires hyprland.lua and gum_env.lua from it at login, Neovim loads neovim.lua at startup, and alacritty.toml, kitty.conf, foot.ini and ghostty.conf each name the program the terminal launches. Installing a theme was the same act as running its author's code, and nothing on disk distinguishes an installed theme from one the user wrote.

Stage only what a theme needs in order to be a theme: colors.toml, light.mode, the preview and unlock images, and image files under backgrounds/. Everything else is ignored, named on stderr, and generated from default/themed/*.tpl instead. Symlinks are never followed, because in an untrusted theme they point wherever the author chose. A theme older than colors.toml keeps its palette: its alacritty.toml is read for colours in a scratch directory and only the resulting colors.toml is staged, so the terminal config never lands.

The filter belongs in omarchy-theme-set rather than in omarchy-theme-install because staging is the choke point. It also covers themes installed before this change, themes copied in by hand, and files a theme gains later through `omarchy theme update`.

First-party themes under $OMARCHY_PATH/themes are unaffected. Per-theme overrides of a generated file are no longer available to user themes; the template at ~/.config/omarchy/themed/<file>.tpl replaces that, and icons.theme is the one setting with no replacement.

🤖 Generated by Opus 5 in Claude Code.

* Stop a theme URL or name being read as an option or a path

Three paths in the theme commands took an attacker-shaped string straight into git, into basename, or into rm.

`git clone "$REPO_URL"` passes the URL as the first positional argument, so a URL beginning with a dash is parsed as an option instead and the destination path becomes what git tries to clone. Pass `--` before the URL so a URL is always a URL. git also treats `<helper>::<address>` as a remote helper to run; git's own protocol.allow default already refuses `ext::`, so rejecting that shape here is a second line rather than the fix, and it keeps holding if that default ever moves. The helper name is a bare word at the very start of the URL, which is what the guard matches: an scp-style IPv6 host such as git@[2001:db8::1]:org/repo.git carries `::` of its own and still clones.

`basename "$REPO_PATH" .git` has the same problem one step later, after the scp-style prefix has been stripped: `host:-s/foo.git` leaves basename reading `-s` as an option and returning `.git` as the theme name. Take the name with `--`.

That name is then joined into a path that is about to be `rm -rf`'d, so a repo whose basename came out as `..` would take ~/.config/omarchy with it. omarchy-theme-remove had the same shape from its own argument, and omarchy-theme-set's sed/tr normalization does not stop a name containing a slash. Reject empty, anything starting with a dot, and anything containing `/` in all three, before the name reaches a path.

🤖 Generated by Opus 5 in Claude Code. Reviewed by Codex XHigh.

Co-Authored-By: Codex XHigh <codex@openai.com>

* Re-stage the current theme for installs that already applied one

Dropping a theme's code at staging time only takes effect the next time a theme is staged. An install that already applied an extra theme keeps that theme's hyprland.lua, gum_env.lua, neovim.lua and terminal configs in ~/.local/state/omarchy/current/theme, which Hyprland requires at login and the terminals include at launch, and nothing forces a theme change — so for those installs the fix would arrive whenever the user next happened to switch themes, which may be never.

Re-stage once through omarchy-theme-refresh. First-party themes stage identically, so the cost for everyone else is a single retint during an update they are already running.

🤖 Generated by Opus 5 in Claude Code. Reviewed by Codex XHigh.

Co-Authored-By: Codex XHigh <codex@openai.com>

* Stop a theme's unlock image republishing a file it points at

omarchy-plymouth-set-by-theme reads unlock.png straight out of ~/.config/omarchy/themes, which is an installed theme's own directory and outside the staging filter, and hands the path to omarchy-plymouth-set. That path was copied twice into world-readable /usr/share — once by the user into the Plymouth theme, and once by `sudo cp` into the SDDM theme. A symlink there was followed both times, so a theme could name a file it cannot read and have root publish it.

Refuse a symlinked logo, and copy the staged logo to SDDM instead of rereading the caller's path as root. The staged copy is made by the user, so nothing privileged opens a path the caller chose.

🤖 Generated by Opus 5 in Claude Code. Reviewed by Codex XHigh.

Co-Authored-By: Codex XHigh <codex@openai.com>

* Limit only what an installed theme could run

Two corrections to the rule this branch introduced, both narrowing it to what it was actually for.

It applied to every theme under ~/.config/omarchy/themes, which swept up themes the user wrote themselves. Their machine, their file: a theme they wrote is theirs to fill however they like, and Omarchy's own themes were never in scope. Only a theme that came from someone else needs limiting, and the repo already knows which those are — omarchy-theme-extras calls a theme with a `.git` directory an extra and a symlink someone's working copy, because that is what `omarchy theme install` leaves behind when it clones. Use the same test.

It was also an allowlist, which dropped files that carry nothing but colour and left theme authors worse off for no gain. Drop only what can run: any `*.lua`, since Hyprland requires a theme's hyprland.lua and gum_env.lua at login and Neovim loads neovim.lua at startup; the four terminal configs, since each names the program the terminal launches; and vscode.json, whose extension field reaches `code --install-extension` and a VS Code extension is arbitrary JavaScript. Everything else an installed theme ships is kept, so btop.theme, chromium.theme, helix.toml, icons.theme, keyboard.rgb and shell.toml go back to being the theme's to set.

Symlinks are still dropped, now at any depth rather than only where an allowlist happened to look.

A denylist is wrong the moment someone adds a template and does not think about it, so the decision is forced rather than remembered: the test fails on any default/themed/*.tpl whose output is recorded as neither code nor colour, and a new terminal or a new Lua-loading editor cannot be added without classifying it.

What this does not cover, and is written down in docs/theming.md rather than implied: a theme shipped as an archive and unpacked by hand looks exactly like one the user wrote. `omarchy theme install` only takes git URLs, so the supported path is always filtered, but this marks where a theme came from and is not a sandbox.

🤖 Generated by Opus 5 in Claude Code.

* Fix what the review found

Four things, all confirmed against the source before changing anything.

The migration failed permanently when the active theme had been removed. `omarchy theme remove` deletes the directory without repointing theme.name, so the name survives, the staged copy survives, and omarchy-theme-refresh exits 1 because neither source directory exists — leaving the migration pending forever and the stale staged Lua exactly where it was, which is the one thing it existed to remove. Seed the default theme in that case: there is nothing to re-stage from, and the removal should have left a working theme behind anyway.

The staging test skipped the strict-mode header that docs/testing.md makes the contract for every shell test. Adding it means the patterns that fail on purpose have to stop being bare `cmd && fail` compounds, which errexit reads as the script itself failing; the mutations were re-run afterwards to confirm the assertions still fire rather than the run dying early and looking like something else.

The guards in omarchy-theme-install and omarchy-theme-remove had no coverage — they were checked by hand and left that way. theme-install-guards-test.sh stubs git and the themes directory and proves an option-shaped URL, a transport helper, and a name that would climb out all stop before git or rm runs, that a dash inside the path no longer becomes a basename option, and that an ordinary URL still clones and applies.

The new docs/theming.md prose was hard-wrapped, which AGENTS.md forbids for docs/. Unwrapped. The rest of that file is wrapped from before and is left alone rather than churned through this change.

🤖 Generated by Opus 5 in Claude Code. Reviewed by Codex XHigh and Copilot.

---------

Co-authored-by: Codex XHigh <codex@openai.com>
This commit is contained in:
Omarchybot
2026-08-23 16:43:31 +02:00
committed by GitHub
co-authored by Codex XHigh
parent b71c60fe30
commit ef6d9e6605
11 changed files with 602 additions and 12 deletions
+10 -1
View File
@@ -34,6 +34,15 @@ if [[ ! -f $logo_path ]]; then
exit 1 exit 1
fi 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_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_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}') 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" \ -e "s/#ffffff/#$text_hex/g" \
"$sddm_template" | sudo tee "$sddm_dir/Main.qml" >/dev/null "$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 for asset in bullet.png entry.png lock.png; do
sudo cp "$staging_dir/$asset" "$sddm_dir/$asset" sudo cp "$staging_dir/$asset" "$sddm_dir/$asset"
done done
+22 -4
View File
@@ -16,24 +16,42 @@ if [[ -z $REPO_URL ]]; then
exit 1 exit 1
fi fi
# git reads a leading dash as an option, and `<helper>::<address>` 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" THEMES_DIR="$HOME/.config/omarchy/themes"
# Strip user@host: prefix from scp-style SSH URLs so basename sees just the path # Strip user@host: prefix from scp-style SSH URLs so basename sees just the path
REPO_PATH="$REPO_URL" REPO_PATH="$REPO_URL"
[[ $REPO_PATH != *"://"* && $REPO_PATH == *:*/* ]] && REPO_PATH="${REPO_PATH#*:}" [[ $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" 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 # Remove existing theme if present
if [[ -d $THEME_PATH ]]; then if [[ -d $THEME_PATH ]]; then
rm -rf "$THEME_PATH" rm -rf "$THEME_PATH"
fi fi
# Clone the repo directly to ~/.config/omarchy/themes # 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." echo "Error: Failed to clone theme repo."
exit 1 exit 1
fi fi
# Apply the new theme with omarchy-theme-set # Apply the new theme with omarchy-theme-set, which stages only the files an
omarchy-theme-set $THEME_NAME # extra theme is allowed to contribute and names anything it dropped.
omarchy-theme-set "$THEME_NAME"
+3 -2
View File
@@ -21,8 +21,9 @@ fi
THEMES_DIR="$HOME/.config/omarchy/themes" THEMES_DIR="$HOME/.config/omarchy/themes"
THEME_PATH="$THEMES_DIR/$THEME_NAME" THEME_PATH="$THEMES_DIR/$THEME_NAME"
# Ensure a theme was set # Ensure a theme was set, and that the name cannot climb out of THEMES_DIR
if [[ -z $THEME_NAME ]]; then # on its way into the rm below.
if [[ -z $THEME_NAME || $THEME_NAME == .* || $THEME_NAME == */* ]]; then
exit 1 exit 1
fi fi
+130 -2
View File
@@ -17,6 +17,19 @@ THEME_SET_LOCK="${XDG_RUNTIME_DIR:-/tmp}/omarchy-theme-set.lock"
USER_THEMES_PATH="$HOME/.config/omarchy/themes" USER_THEMES_PATH="$HOME/.config/omarchy/themes"
OMARCHY_THEMES_PATH="$OMARCHY_PATH/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() { run_parallel() {
local pid local pid
local pids=() local pids=()
@@ -122,12 +135,121 @@ set_theme_background() {
ln -nsf "$new_background" "$CURRENT_BACKGROUND_LINK" 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_NAME=$(echo "$1" | sed -E 's/<[^>]+>//g' | tr '[:upper:]' '[:lower:]' | tr ' ' '-')
THEME_HEADLESS=0 THEME_HEADLESS=0
if [[ ${OMARCHY_THEME_HEADLESS:-} == "1" || ${OMARCHY_THEME_OFFLINE:-} == "1" ]]; then if [[ ${OMARCHY_THEME_HEADLESS:-} == "1" || ${OMARCHY_THEME_OFFLINE:-} == "1" ]]; then
THEME_HEADLESS=1 THEME_HEADLESS=1
fi 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 if [[ ! -d $OMARCHY_THEMES_PATH/$THEME_NAME ]] && [[ ! -d $USER_THEMES_PATH/$THEME_NAME ]]; then
echo "Theme '$THEME_NAME' does not exist" echo "Theme '$THEME_NAME' does not exist"
exit 1 exit 1
@@ -143,9 +265,15 @@ flock 9
rm -rf "$NEXT_THEME_PATH" rm -rf "$NEXT_THEME_PATH"
mkdir -p "$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 "$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 # 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 if [[ ! -f $NEXT_THEME_PATH/colors.toml && -f $NEXT_THEME_PATH/alacritty.toml ]]; then
+23
View File
@@ -22,11 +22,34 @@ omarchy theme install <url> # Install from git repo
Additional user backgrounds for any theme (stock or custom) go in Additional user backgrounds for any theme (stock or custom) go in
`~/.config/omarchy/backgrounds/<theme-slug>/`. `~/.config/omarchy/backgrounds/<theme-slug>/`.
## 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/<config-name>.tpl` overrides the
built-in one. See `docs/theming.md` in the Omarchy repo.
## Customizing a Stock Theme ## Customizing a Stock Theme
Never edit stock themes under `/usr/share/omarchy/themes/` — changes are lost Never edit stock themes under `/usr/share/omarchy/themes/` — changes are lost
on update. Two safe options: 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 **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 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: applied, the stock theme is copied first and your files win on top:
+26 -3
View File
@@ -6,20 +6,22 @@ Omarchy themes live under `themes/<name>/` in the source tree (installed at
`colors.toml`; Omarchy generates the active theme files from `colors.toml`; Omarchy generates the active theme files from
`default/themed/*.tpl` when `omarchy-theme-set <name>` runs. `default/themed/*.tpl` when `omarchy-theme-set <name>` runs.
Beyond `colors.toml` and hand-written config overrides, a theme can ship Beyond `colors.toml` and hand-written config overrides, a first-party theme can
`backgrounds/` (users overlay their own via ship `backgrounds/` (users overlay their own via
`~/.config/omarchy/backgrounds/<name>/`; the active image is the `~/.config/omarchy/backgrounds/<name>/`; the active image is the
`~/.local/state/omarchy/current/background` symlink), `preview.png` and `~/.local/state/omarchy/current/background` symlink), `preview.png` and
`preview-unlock.png` for the theme switcher, `icons.theme`, `keyboard.rgb`, `preview-unlock.png` for the theme switcher, `icons.theme`, `keyboard.rgb`,
`unlock.png`, and a `light.mode` marker file. `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 ## Theme activation flow
`omarchy-theme-set <name>` builds a clean staging directory at `omarchy-theme-set <name>` builds a clean staging directory at
`~/.local/state/omarchy/current/next-theme`: `~/.local/state/omarchy/current/next-theme`:
1. Copy the first-party theme from `themes/<name>/`. 1. Copy the first-party theme from `themes/<name>/`.
2. Overlay any user theme files from `~/.config/omarchy/themes/<name>/`. 2. Overlay `~/.config/omarchy/themes/<name>/`, 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`. 3. If needed, generate `colors.toml` from `alacritty.toml`.
4. Run `omarchy-theme-set-templates` to render templates into the staging 4. Run `omarchy-theme-set-templates` to render templates into the staging
theme. 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 to that list. Runs serialize on a `flock`, so scripted theme changes queue
instead of racing. instead of racing.
## What an installed theme may not ship
`themes/<name>/` in this repo is Omarchy's own code and is trusted. So is a theme the user wrote by hand in `~/.config/omarchy/themes/<name>/`: it is their machine and their file, and both stage in full.
`omarchy theme install <url>` 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`
`colors.toml` provides the palette keys used by templates. Keys are grouped `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 ## Adding or overriding theme files
- Add palette values to `themes/<name>/colors.toml`. - Add palette values to `themes/<name>/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. - Prefer generated files when the theme can be expressed with templates.
- Add a hand-written file in `themes/<name>/` only when that theme needs to - Add a hand-written file in `themes/<name>/` only when that theme needs to
override the generated output entirely. override the generated output entirely.
+12
View File
@@ -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`. 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 ### 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.) 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. 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). 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).
+25
View File
@@ -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
+18
View File
@@ -23,3 +23,21 @@ grep -Fq \
fail "omarchy-plymouth-set avoids copying staging directory ownership and mode" fail "omarchy-plymouth-set avoids copying staging directory ownership and mode"
pass "Plymouth asset copy preserves the package-owned directory metadata" 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"
+105
View File
@@ -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"
+228
View File
@@ -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" <<TOML
mode = "light"
accent = "#7aa2f7"
selection = "#292e42"
muted = "#414868"
background = "#1a1b26"
foreground = "#a9b1d6"
color0 = "#1a1b26"
color1 = "#f7768e"
color2 = "#9ece6a"
color3 = "#e0af68"
color4 = "#7aa2f7"
color5 = "#bb9af7"
color6 = "#7dcfff"
color7 = "#a9b1d6"
TOML
}
# A theme that ships everything it is not allowed to ship.
hostile="$themes/hostile"
mkdir -p "$hostile/backgrounds" "$hostile/.git"
write_colors "$hostile/colors.toml"
touch "$hostile/light.mode"
printf 'os.execute("%s")\n' "$marker" >"$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" <<TOML
[terminal.shell]
program = "$marker"
[colors.primary]
background = "#102030"
foreground = "#a0b0c0"
[colors.normal]
black = "#102030"
red = "#ff0000"
green = "#00ff00"
yellow = "#ffff00"
blue = "#0000ff"
magenta = "#ff00ff"
cyan = "#00ffff"
white = "#a0b0c0"
TOML
set_theme legacy || fail "omarchy-theme-set applies a theme that only ships alacritty.toml"
assert_staged colors.toml "a legacy theme's palette is recovered from its alacritty.toml"
grep -q '#102030' "$(staged colors.toml)" || fail "the recovered palette is the theme's"
assert_no_marker alacritty.toml "a legacy theme's alacritty.toml is not staged"
pass "a theme older than colors.toml keeps its palette and loses its terminal config"
# An overlay on a stock theme still repaints it, and still cannot add code.
mkdir -p "$themes/tokyo-night/.git"
write_colors "$themes/tokyo-night/colors.toml"
sed -i 's/#7aa2f7/#abcdef/' "$themes/tokyo-night/colors.toml"
printf 'os.execute("%s")\n' "$marker" >"$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"