From 96a144b7e68de067aef803b4935b9ecd88d2879e Mon Sep 17 00:00:00 2001 From: Gberfield <89108862+Gberfield@users.noreply.github.com> Date: Wed, 7 Oct 2026 19:33:27 -0400 Subject: [PATCH 1/2] feat(linux): add durable Omarchy theme synchronization --- contrib/omarchy-theme/.gitignore | 6 + contrib/omarchy-theme/README.md | 78 +++++++ contrib/omarchy-theme/install.sh | 125 ++++++++++ .../tests/fixtures/dark/colors.toml | 31 +++ .../tests/fixtures/light/colors.toml | 31 +++ contrib/omarchy-theme/tests/test_sync.py | 219 ++++++++++++++++++ .../theme/95-television-omarchy-theme | 5 + contrib/omarchy-theme/theme/README.md | 82 +++++++ contrib/omarchy-theme/theme/manifest.json | 6 + .../theme/omarchy-television-theme.service | 11 + .../theme/omarchy-television-theme.timer | 12 + contrib/omarchy-theme/theme/repair.py | 46 ++++ contrib/omarchy-theme/theme/sync.py | 165 +++++++++++++ packages/desktop/linux/README.md | 6 + packages/desktop/test/omarchy-theme.test.ts | 16 ++ 15 files changed, 839 insertions(+) create mode 100644 contrib/omarchy-theme/.gitignore create mode 100644 contrib/omarchy-theme/README.md create mode 100644 contrib/omarchy-theme/install.sh create mode 100644 contrib/omarchy-theme/tests/fixtures/dark/colors.toml create mode 100644 contrib/omarchy-theme/tests/fixtures/light/colors.toml create mode 100644 contrib/omarchy-theme/tests/test_sync.py create mode 100644 contrib/omarchy-theme/theme/95-television-omarchy-theme create mode 100644 contrib/omarchy-theme/theme/README.md create mode 100644 contrib/omarchy-theme/theme/manifest.json create mode 100644 contrib/omarchy-theme/theme/omarchy-television-theme.service create mode 100644 contrib/omarchy-theme/theme/omarchy-television-theme.timer create mode 100644 contrib/omarchy-theme/theme/repair.py create mode 100644 contrib/omarchy-theme/theme/sync.py create mode 100644 packages/desktop/test/omarchy-theme.test.ts diff --git a/contrib/omarchy-theme/.gitignore b/contrib/omarchy-theme/.gitignore new file mode 100644 index 00000000..2318598f --- /dev/null +++ b/contrib/omarchy-theme/.gitignore @@ -0,0 +1,6 @@ +__pycache__/ +*.pyc +theme/theme.css +theme/sync-state.json +theme/.sync.lock +theme/.omarchy-television-theme diff --git a/contrib/omarchy-theme/README.md b/contrib/omarchy-theme/README.md new file mode 100644 index 00000000..180d066f --- /dev/null +++ b/contrib/omarchy-theme/README.md @@ -0,0 +1,78 @@ +# Follow the Omarchy theme + +This optional integration keeps Television's colors and effective light/dark +appearance matched to Omarchy's active palette. It runs on the Omarchy machine +that hosts the Television server and uses Television's supported theme interface. +It is installed separately from the Linux desktop client; it is not a bundled +default theme and does not change the desktop client's installer. + +Requirements: Omarchy, Python 3.11 or later, Bash, ripgrep, user systemd, and a +working local `tv` command/server. No root privileges or new Python packages are +needed. + +The service retains the installation shell's PATH so it can find mise-managed +Node and `tv` installations. Only PATH is saved; account environment variables +and credentials are not collected. Rerun the permanent installer if the CLI's +installation location changes. + +From a checkout of this fork: + +```bash +bash contrib/omarchy-theme/install.sh +``` + +The installer saves a permanent copy under +`~/.local/share/omarchy-television-theme`, creates the `Omarchy` theme in the home +reported by `tv themes-path`, and activates it. Theme-change, login, and update +hooks sync immediately. A user timer checks again every five minutes, recovering +missed hooks and temporary server outages. An unchanged check does not rewrite +the stylesheet or refresh the theme registry. No desktop or Television restart +is needed, and channels, artifacts, account tokens, and connection settings are +not copied or changed. + +The theme follows the active **palette**, including local Omarchy overrides. +It uses solid backgrounds, preserves Television's layout and fonts, and adjusts +supporting text for contrast. Live canonical artifacts receive the same semantic +colors. Frozen canonical v1 and external websites retain their own appearance. + +Manual repair and refresh: + +```bash +omarchy-television-theme +``` + +Reinstall the integration from its permanent copy: + +```bash +bash ~/.local/share/omarchy-television-theme/install.sh +``` + +The installer backs up existing owned files under +`~/.local/state/omarchy-television-theme/backups/` and refuses unrelated existing +files. `--adopt-existing` is only for the earlier version of this exact integration; +it verifies that version's authoring record before taking ownership. It is not a +general override. The repair tool recreates a missing theme folder and repairs +damaged runtime files, while preserving someone else's replacement theme folder. + +To stop automatic following, disable the timer and remove the three hooks: + +```bash +systemctl --user disable --now omarchy-television-theme.timer +rm ~/.config/omarchy/hooks/{theme-set,post-boot,post-update}.d/95-television-omarchy-theme +``` + +You can then select another Television theme. Otherwise, the next hook or timer +check restores the Omarchy selection. Inspect failures with +`journalctl --user -u omarchy-television-theme.service`. + +Tests run within the repository's `unit:desktop` surface. They use committed +dark/light palette fixtures and temporary files. When stock Omarchy palettes are +present, the same suite checks them too. Offline activation and ownership tests +substitute the `tv` command and do not stop the live server or any desktop. +The original host verification covered all 22 stock palettes, actual hook and +user-service execution, enabled timer state, a second installation, and served +CSS matching the active theme. It did not include a GUI visual review or an +actual operating-system update. + +The [theme authoring record](theme/README.md) explains the color mapping, +generated state, ownership marker, and maintenance decisions. diff --git a/contrib/omarchy-theme/install.sh b/contrib/omarchy-theme/install.sh new file mode 100644 index 00000000..2769cca9 --- /dev/null +++ b/contrib/omarchy-theme/install.sh @@ -0,0 +1,125 @@ +#!/usr/bin/env bash +# Managed by omarchy-television-theme. Safe to rerun from the permanent copy. +set -euo pipefail +export PATH="$HOME/.local/bin:$PATH" +export PYTHONDONTWRITEBYTECODE=1 +base=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +source_dir="$base/omarchy" +[[ -d "$source_dir" ]] || source_dir="$base/theme" +adopt=0 +if [[ ${1:-} == --adopt-existing ]]; then + adopt=1 +elif [[ $# != 0 ]]; then + echo 'Usage: install.sh [--adopt-existing]' >&2 + exit 1 +fi +themes=$(tv themes-path) +themes_dir=$(python -c 'import json,sys; print(json.loads(sys.argv[1])["themesPath"])' "$themes") +target="$themes_dir/omarchy" +permanent="$HOME/.local/share/omarchy-television-theme" +units="$HOME/.config/systemd/user" +launcher="$HOME/.local/bin/omarchy-television-theme" +config_dir="$HOME/.config/omarchy-television-theme" +owner='omarchy-television-theme:v1' +if [[ -e "$permanent" && $(cat "$permanent/theme/.omarchy-television-theme" 2>/dev/null || true) != "$owner" ]]; then + echo "Refusing to overwrite unowned integration: $permanent" >&2 + exit 1 +fi +if [[ -L "$target" || -L "$permanent" ]]; then + echo 'Refusing to replace symlinked installation directories' >&2 + exit 1 +fi +if [[ -e "$target" && $(cat "$target/.omarchy-television-theme" 2>/dev/null || true) != "$owner" ]]; then + if [[ "$adopt" != 1 ]]; then + echo "Refusing to overwrite unowned theme: $target" >&2 + exit 1 + fi + python - "$target" <<'PY' +import json, sys +from pathlib import Path +p = Path(sys.argv[1]) +try: + owned = ( + json.loads((p/'manifest.json').read_text())['name'] == 'Omarchy' + and (p/'README.md').read_text().startswith('# Omarchy Television theme\n\nMatches Television') + and (p/'sync.py').read_text().startswith('#!/usr/bin/env python3\n"""Map the active Omarchy palette') + ) +except (OSError, ValueError, KeyError, TypeError): + owned = False +if not owned: + raise SystemExit('Refusing to adopt an unrelated theme') +PY +fi +paths=("$target" "$permanent" "$launcher" "$units/omarchy-television-theme.service" "$units/omarchy-television-theme.timer" "$config_dir/environment") +for event in theme-set post-boot post-update; do + paths+=("$HOME/.config/omarchy/hooks/$event.d/95-television-omarchy-theme") +done +for path in "${paths[@]:2}"; do + if [[ -e "$path" ]] && ! rg -q -F 'Managed by omarchy-television-theme' "$path"; then + if [[ "$adopt" == 1 && "$path" == */95-television-omarchy-theme ]] && rg -q -F '/omarchy/sync.py' "$path"; then + continue + fi + echo "Refusing to overwrite an unowned file: $path" >&2 + exit 1 + fi +done +backup_dir="$HOME/.local/state/omarchy-television-theme/backups" +mkdir -p "$backup_dir" +backup="$backup_dir/$(TZ=America/New_York date +%Y%m%dT%H%M%S%Z)-$$.tar.gz" +python - "$backup" "${paths[@]}" <<'PY' +import sys, tarfile +from pathlib import Path +with tarfile.open(sys.argv[1], 'w:gz') as archive: + for value in sys.argv[2:]: + path = Path(value) + if path.exists(): + archive.add(path, arcname=str(path).lstrip('/')) +PY +mkdir -p "$permanent/theme" "$target" "$units" "$HOME/.local/bin" "$config_dir" +copy_changed() { + local source=$1 dest=$2 mode=${3:-644} + if ! cmp -s "$source" "$dest"; then + install -m "$mode" "$source" "$dest.new" + mv -f -- "$dest.new" "$dest" + fi +} +for file in manifest.json sync.py repair.py README.md 95-television-omarchy-theme omarchy-television-theme.service omarchy-television-theme.timer; do + copy_changed "$source_dir/$file" "$permanent/theme/$file" +done +copy_changed "$base/install.sh" "$permanent/install.sh" 755 +printf '%s\n' "$owner" > "$permanent/theme/.omarchy-television-theme" +printf '%s\n' "$owner" > "$target/.omarchy-television-theme" +cat > "$launcher.new" <<'SH' +#!/usr/bin/env bash +# Managed by omarchy-television-theme. +set -euo pipefail +export PATH="$HOME/.local/bin:$PATH" PYTHONDONTWRITEBYTECODE=1 +exec /usr/bin/python "$HOME/.local/share/omarchy-television-theme/theme/repair.py" "$@" +SH +chmod 755 "$launcher.new" +mv -f -- "$launcher.new" "$launcher" +python - "$config_dir/environment" "$PATH" <<'PY' +import os, sys +from pathlib import Path +path = Path(sys.argv[1]) +value = sys.argv[2] +if '\n' in value or '\r' in value: + raise SystemExit('Cannot save a PATH containing line breaks') +escaped = value.replace('\\', '\\\\').replace('"', '\\"') +temporary = path.with_name(path.name + '.new') +temporary.write_text('# Managed by omarchy-television-theme. Only PATH is saved.\nPATH="' + escaped + '"\n') +temporary.chmod(0o600) +os.replace(temporary, path) +PY +for unit in omarchy-television-theme.service omarchy-television-theme.timer; do + copy_changed "$permanent/theme/$unit" "$units/$unit" +done +for event in theme-set post-boot post-update; do + omarchy hook install "$event" "$permanent/theme/95-television-omarchy-theme" +done +systemctl --user daemon-reload +systemctl --user enable --now omarchy-television-theme.timer +systemctl --user start omarchy-television-theme.service +tv focus-status +echo "Permanent integration: $permanent" +echo "Previous installation backup: $backup" diff --git a/contrib/omarchy-theme/tests/fixtures/dark/colors.toml b/contrib/omarchy-theme/tests/fixtures/dark/colors.toml new file mode 100644 index 00000000..58e67852 --- /dev/null +++ b/contrib/omarchy-theme/tests/fixtures/dark/colors.toml @@ -0,0 +1,31 @@ +mode = "dark" + +accent = "#7aa2f7" +selection = "#292e42" +muted = "#414868" + +background = "#1a1b26" +dark_background = "#13141c" +darker_background = "#0e0e14" +lighter_background = "#24283b" + +foreground = "#a9b1d6" +dark_foreground = "#565f89" +light_foreground = "#b4bee6" +bright_foreground = "#c0caf5" + +red = "#f7768e" +yellow = "#e0af68" +orange = "#eb927b" +green = "#9ece6a" +cyan = "#449dab" +blue = "#7aa2f7" +magenta = "#ad8ee6" +brown = "#75493d" + +bright_red = "#ff7a93" +bright_yellow = "#ff9e64" +bright_green = "#b9f27c" +bright_cyan = "#0db9d7" +bright_blue = "#7da6ff" +bright_magenta = "#bb9af7" diff --git a/contrib/omarchy-theme/tests/fixtures/light/colors.toml b/contrib/omarchy-theme/tests/fixtures/light/colors.toml new file mode 100644 index 00000000..30146f35 --- /dev/null +++ b/contrib/omarchy-theme/tests/fixtures/light/colors.toml @@ -0,0 +1,31 @@ +mode = "light" + +accent = "#205EA6" +selection = "#CECDC3" +muted = "#B7B5AC" + +background = "#FFFCF0" +dark_background = "#f2efe4" +darker_background = "#e5e2d8" +lighter_background = "#E6E4D9" + +foreground = "#100F0F" +dark_foreground = "#878580" +light_foreground = "#403E3C" +bright_foreground = "#100F0F" + +red = "#D14D41" +yellow = "#D0A215" +orange = "#d0772b" +green = "#879A39" +cyan = "#3AA99F" +blue = "#205EA6" +magenta = "#CE5D97" +brown = "#683b15" + +bright_red = "#D14D41" +bright_yellow = "#D0A215" +bright_green = "#879A39" +bright_cyan = "#3AA99F" +bright_blue = "#4385BE" +bright_magenta = "#CE5D97" diff --git a/contrib/omarchy-theme/tests/test_sync.py b/contrib/omarchy-theme/tests/test_sync.py new file mode 100644 index 00000000..6ec40694 --- /dev/null +++ b/contrib/omarchy-theme/tests/test_sync.py @@ -0,0 +1,219 @@ +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + + +SOURCE = Path(__file__).resolve().parents[1] / "theme" +FIXTURES = Path(__file__).with_name("fixtures") + + +class SyncTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.package = Path(self.tmp.name) + source = SOURCE / 'sync.py' + if source.exists(): + shutil.copy(source, self.package / 'sync.py') + (self.package / 'manifest.json').write_text(json.dumps({ + 'name': 'Omarchy', 'version': '1.0.0', 'colorScheme': 'dark', + 'authoredForAppVersion': '1.4.23', + })) + + def tearDown(self): + self.tmp.cleanup() + + def run_sync(self, palette): + return subprocess.run([ + sys.executable, str(self.package / 'sync.py'), + '--colors', str(palette), '--no-activate', + ], capture_output=True, text=True) + + def test_all_installed_palettes_and_light_dark_switches(self): + palettes = sorted(FIXTURES.glob('*/colors.toml')) + sorted(Path('/usr/share/omarchy/themes').glob('*/colors.toml')) + self.assertGreater(len(palettes), 0) + import tomllib + for palette in palettes: + with self.subTest(theme=palette.parent.name): + result = self.run_sync(palette) + self.assertEqual(result.returncode, 0, result.stderr) + colors = tomllib.loads(palette.read_text()) + css = (self.package / 'theme.css').read_text() + self.assertIn('--color-surface: ' + colors['background'].lower(), css) + self.assertIn('--color-text: ' + colors['foreground'].lower(), css) + self.assertIn('--accent: ' + colors['accent'].lower(), css) + tokens = dict(re.findall(r'--([a-z-]+): (#[a-f0-9]{6})', css)) + import importlib.util + spec = importlib.util.spec_from_file_location('sync', self.package / 'sync.py') + sync = importlib.util.module_from_spec(spec) + spec.loader.exec_module(sync) + for token in ('color-text-muted', 'color-link'): + for ground in ('color-surface', 'color-surface-muted'): + self.assertGreaterEqual(sync.contrast(tokens[token], tokens[ground]), 4.5) + self.assertGreaterEqual(sync.contrast(tokens['color-primary-text'], tokens['accent']), 4.5) + manifest = json.loads((self.package / 'manifest.json').read_text()) + self.assertEqual(manifest['colorScheme'], colors['mode']) + self.assertEqual(manifest['authoredForAppVersion'], '1.4.23') + first_mtime = (self.package / 'theme.css').stat().st_mtime_ns + self.assertEqual(self.run_sync(palette).returncode, 0) + self.assertEqual(first_mtime, (self.package / 'theme.css').stat().st_mtime_ns) + + def test_legacy_palette_infers_mode_and_ignores_noncolor_settings(self): + palette = self.package / 'legacy.toml' + palette.write_text('background = "#ffffff"\nforeground = "#000000"\nhyprland_active_border = "rgb(ffffffff) 45deg"\n') + result = self.run_sync(palette) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(json.loads((self.package / 'manifest.json').read_text())['colorScheme'], 'light') + + def test_valid_json_with_wrong_state_shape_is_rebuilt(self): + self.assertEqual(self.run_sync(FIXTURES / 'dark/colors.toml').returncode, 0) + (self.package / 'sync-state.json').write_text('[]') + result = self.run_sync(FIXTURES / 'dark/colors.toml') + self.assertEqual(result.returncode, 0, result.stderr) + self.assertTrue(json.loads((self.package / 'sync-state.json').read_text())['activationPending']) + + def test_bad_palette_preserves_last_good_package(self): + good = FIXTURES / 'dark/colors.toml' + result = self.run_sync(good) + self.assertEqual(result.returncode, 0, result.stderr) + files = ['theme.css', 'manifest.json', 'sync-state.json'] + before = {name: (self.package / name).read_bytes() for name in files} + bad = self.package / 'bad.toml' + for contents in ['background = "#ffffff"', + 'background = "#fff; } body { display:none"\nforeground = "#000000"', + 'this is not TOML']: + bad.write_text(contents) + self.assertNotEqual(self.run_sync(bad).returncode, 0) + self.assertEqual(before, {name: (self.package / name).read_bytes() for name in files}) + + def test_failed_activation_is_retried_and_successful_sync_is_idle(self): + bin_dir = self.package / 'bin' + bin_dir.mkdir() + tv = bin_dir / 'tv' + tv.write_text('#!' + sys.executable + '\n' + 'import os,sys\n' + 'from pathlib import Path\n' + 'if sys.argv[1] == "focus-status":\n' + ' print(\'{"activeThemeName":"\' + os.environ["FAKE_THEME"] + \'"}\')\n' + 'else:\n' + ' with Path(os.environ["TV_CALLS"]).open("a") as out: out.write("activate\\n")\n' + ' sys.exit(int(os.environ["TV_FAIL"]))\n') + tv.chmod(0o755) + calls = self.package / 'calls' + env = dict(os.environ, PATH=str(bin_dir) + ':' + os.environ['PATH'], + FAKE_THEME=self.package.name, TV_CALLS=str(calls), TV_FAIL='1') + command = [sys.executable, str(self.package / 'sync.py'), '--require-activation', + '--colors', str(FIXTURES / 'dark/colors.toml')] + result = subprocess.run(command, env=env, capture_output=True, text=True) + self.assertNotEqual(result.returncode, 0) + self.assertTrue(json.loads((self.package / 'sync-state.json').read_text())['activationPending']) + env['TV_FAIL'] = '0' + result = subprocess.run(command, env=env, capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertFalse(json.loads((self.package / 'sync-state.json').read_text())['activationPending']) + before = calls.read_text() + result = subprocess.run(command, env=env, capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(before, calls.read_text()) + + +class RepairTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.root = Path(self.tmp.name) + self.source = self.root / 'source' + self.source.mkdir() + for name in ('sync.py', 'repair.py', 'manifest.json', 'README.md'): + shutil.copy(SOURCE / name, self.source / name) + bin_dir = self.root / 'bin' + bin_dir.mkdir() + tv = bin_dir / 'tv' + tv.write_text('#!' + sys.executable + '\nimport json,os\n' + 'print(json.dumps({"themesPath":os.environ["FAKE_THEMES"]}))\n') + tv.chmod(0o755) + self.themes = self.root / 'themes' + self.target = self.themes / 'omarchy' + self.env = dict(os.environ, PATH=str(bin_dir) + ':' + os.environ['PATH'], + FAKE_THEMES=str(self.themes), PYTHONDONTWRITEBYTECODE='1') + + def tearDown(self): + self.tmp.cleanup() + + def repair(self): + return subprocess.run([sys.executable, str(self.source / 'repair.py'), + '--no-activate', '--colors', str(FIXTURES / 'dark/colors.toml')], + env=self.env, capture_output=True, text=True) + + def test_missing_theme_and_corrupted_manifest_recover_without_workspace(self): + result = self.repair() + self.assertEqual(result.returncode, 0, result.stderr) + expected = (self.target / 'theme.css').read_bytes() + (self.target / 'manifest.json').write_text('corrupted') + (self.target / 'sync.py').unlink() + result = self.repair() + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(expected, (self.target / 'theme.css').read_bytes()) + self.assertEqual(json.loads((self.target / 'manifest.json').read_text())['colorScheme'], 'dark') + shutil.rmtree(self.target) + result = self.repair() + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(expected, (self.target / 'theme.css').read_bytes()) + + def test_another_authors_theme_is_preserved(self): + self.target.mkdir(parents=True) + css = self.target / 'theme.css' + css.write_text('someone else owns this') + result = self.repair() + self.assertNotEqual(result.returncode, 0) + self.assertEqual(css.read_text(), 'someone else owns this') + + def installer_env(self): + home = self.root / 'home' + home.mkdir() + bin_dir = self.root / 'bin' + for name in ('systemctl', 'omarchy'): + command = bin_dir / name + command.write_text('#!/bin/sh\nexit 0\n') + command.chmod(0o755) + return dict(self.env, HOME=str(home), PYTHONOPTIMIZE='1') + + def test_optimized_python_cannot_adopt_unrelated_theme(self): + self.target.mkdir(parents=True) + files = {'manifest.json': '{"name":"Unrelated"}', 'README.md': 'unrelated', 'sync.py': 'unrelated'} + for name, content in files.items(): + (self.target / name).write_text(content) + result = subprocess.run(['bash', str(SOURCE.parent / 'install.sh'), '--adopt-existing'], + env=self.installer_env(), capture_output=True, text=True) + self.assertNotEqual(result.returncode, 0) + self.assertFalse((self.target / '.omarchy-television-theme').exists()) + self.assertEqual(files, {name: (self.target / name).read_text() for name in files}) + + def test_installer_preserves_cli_path_for_service_and_can_be_rerun(self): + env = self.installer_env() + for _ in range(2): + result = subprocess.run(['bash', str(SOURCE.parent / 'install.sh')], + env=env, capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + import shlex + home = Path(env['HOME']) + saved = home / '.config/omarchy-television-theme/environment' + assignments = shlex.split(saved.read_text(), comments=True) + service_path = dict(value.split('=', 1) for value in assignments)['PATH'] + self.assertIn(str(self.root / 'bin'), service_path.split(':')) + unit = home / '.config/systemd/user/omarchy-television-theme.service' + self.assertIn('EnvironmentFile=%h/.config/omarchy-television-theme/environment', unit.read_text()) + permanent = home / '.local/share/omarchy-television-theme/theme/repair.py' + result = subprocess.run([sys.executable, str(permanent), '--no-activate', + '--colors', str(FIXTURES / 'dark/colors.toml')], + env=dict(env, PATH=service_path), capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertTrue((self.target / 'theme.css').exists()) + + +if __name__ == '__main__': + unittest.main() diff --git a/contrib/omarchy-theme/theme/95-television-omarchy-theme b/contrib/omarchy-theme/theme/95-television-omarchy-theme new file mode 100644 index 00000000..01c1ded8 --- /dev/null +++ b/contrib/omarchy-theme/theme/95-television-omarchy-theme @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +# Managed by omarchy-television-theme. Queue the persistent repair service. +set -euo pipefail +export PATH="$HOME/.local/bin:$PATH" +systemctl --user start --no-block omarchy-television-theme.service diff --git a/contrib/omarchy-theme/theme/README.md b/contrib/omarchy-theme/theme/README.md new file mode 100644 index 00000000..d1183352 --- /dev/null +++ b/contrib/omarchy-theme/theme/README.md @@ -0,0 +1,82 @@ +# Omarchy Television theme + +Matches Television to the currently applied Omarchy palette. Originally authored +on October 7, 2026, against Television 1.4.23. Theme ID: `omarchy`. + +`sync.py` reads `~/.local/state/omarchy/current/theme/colors.toml` (the installed +Omarchy location), with a fallback to the older config location. This uses the +effective palette, including user overlays. No package-owned Omarchy or Television +source is edited. Python 3.11+ is required; there are no extra dependencies. + +The four semantic colors cover surfaces and copy in the application and artifacts +using live canonical v2. Accent and terminal color families supply action, status, +and data colors. The sidebar selection uses Omarchy's selection color. The app +ground uses the darker background. This theme follows the **palette**, using solid +grounds rather than copying the desktop wallpaper. Layout, font, and control +behavior retain Television's defaults. No theme JavaScript or consent is needed. + +Supporting text and links retain the palette's hues but move toward foreground +when necessary to achieve 4.5:1 contrast against the two document surfaces. +Text on primary actions uses whichever palette or black/white ink contrasts best. +`colorScheme` follows Omarchy's declared mode, with luminance inference for older +palettes. This fixes Television's effective appearance to Omarchy while leaving +its stored appearance preference available for other themes. + +`95-television-omarchy-theme` is installed through `omarchy hook install` for +`theme-set`, `post-boot`, and `post-update`. It queues the user service +`omarchy-television-theme.service`. The enabled companion timer runs ten seconds +after it starts and five minutes after the service last finishes, recovering a +missed hook or temporarily unavailable Television server. It starts with the user +session; the Television server's own service remains enabled and unchanged. +No desktop or Television restart is performed by the integration. +The service reads its installation PATH from +`~/.config/omarchy-television-theme/environment`, preserving mise-managed +installations. This file contains only PATH, never a copy of the whole environment. + +The permanent repair source is `~/.local/share/omarchy-television-theme/theme/`. +Its `repair.py` restores missing files in the selected Television home's `omarchy` +theme, including a deleted theme folder or damaged manifest, then invokes the +generator. An ownership marker prevents overwriting someone else's replacement +theme folder. Generated CSS is rebuilt from Omarchy's current effective palette; +the repair source does not depend on the original chat workspace. + +Activation failures are recorded as pending in `sync-state.json`; the service +returns a failure so systemd records the problem, and the timer retries. When the +palette and selection are unchanged, only a lightweight selection check is needed; +there is no stylesheet rewrite or theme registry refresh. The next theme change, +login, update, or timer check selects Omarchy again if a different Television theme +was selected. Disable the integration before choosing another theme permanently. + +Writes are atomic per file and only occur when content changes, avoiding needless +artifact refreshes. A file lock serializes concurrent syncs. Invalid palettes +fail before writing runtime files, retaining the previous usable theme. The +authoring version in the manifest remains unchanged during palette updates. +`sync-state.json` records the last source, appearance, and pending activation. +There is no resident process, theme animation, or external network request. +Each five-minute check briefly starts Python and the local Television CLI. + +Manual repair and refresh: `omarchy-television-theme`. +Reinstall hooks and units: `bash ~/.local/share/omarchy-television-theme/install.sh`. +The installer is idempotent for this integration's owned paths and backs up the +previous installation to `~/.local/state/omarchy-television-theme/backups/` before +changes. That archive contains only theme/integration files, not Television's +account token, channels, or artifacts. Keep the permanent repair copy backed up +with other user configuration. Package updates leave these user-owned files alone. + +Offline verification from the source checkout: +`PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s contrib/omarchy-theme/tests -v`. +The tests exercise all installed stock palettes, both modes, unchanged syncs, +invalid input, deferred activation and retry, deleted-file recovery, corrupt +manifests, and ownership safeguards without altering desktop state. Recovery and +offline-failure tests use temporary files and a controlled Television CLI stand-in; +the live server and desktop are never stopped for tests. + +To disable: `systemctl --user disable --now omarchy-television-theme.timer`, then +remove `95-television-omarchy-theme` from the `theme-set.d`, `post-boot.d`, and +`post-update.d` hook directories under `~/.config/omarchy/hooks/`. Choose another +theme in Television afterward. Both the installed theme and permanent repair copy +can remain available for manual use. Logs: `journalctl --user -u omarchy-television-theme.service`. + +Durability work on October 7, 2026: package version 1.1.0; added owned permanent +repair copy, reinstallable systemd units, update hook, activation retry state, +unchanged-sync fast path, recovery tests, and installer backups. diff --git a/contrib/omarchy-theme/theme/manifest.json b/contrib/omarchy-theme/theme/manifest.json new file mode 100644 index 00000000..fcd81dab --- /dev/null +++ b/contrib/omarchy-theme/theme/manifest.json @@ -0,0 +1,6 @@ +{ + "name": "Omarchy", + "version": "1.1.0", + "colorScheme": "dark", + "authoredForAppVersion": "1.4.23" +} diff --git a/contrib/omarchy-theme/theme/omarchy-television-theme.service b/contrib/omarchy-theme/theme/omarchy-television-theme.service new file mode 100644 index 00000000..a7d0c6cd --- /dev/null +++ b/contrib/omarchy-theme/theme/omarchy-television-theme.service @@ -0,0 +1,11 @@ +# Managed by omarchy-television-theme. +[Unit] +Description=Keep Television matched to the active Omarchy theme +After=com.television.server.service + +[Service] +Type=oneshot +EnvironmentFile=%h/.config/omarchy-television-theme/environment +Environment=PYTHONDONTWRITEBYTECODE=1 +ExecStart=/usr/bin/python %h/.local/share/omarchy-television-theme/theme/repair.py +TimeoutStartSec=90 diff --git a/contrib/omarchy-theme/theme/omarchy-television-theme.timer b/contrib/omarchy-theme/theme/omarchy-television-theme.timer new file mode 100644 index 00000000..57c156b1 --- /dev/null +++ b/contrib/omarchy-theme/theme/omarchy-television-theme.timer @@ -0,0 +1,12 @@ +# Managed by omarchy-television-theme. +[Unit] +Description=Recover missed Omarchy Television theme synchronization + +[Timer] +OnActiveSec=10s +OnUnitInactiveSec=5min +AccuracySec=15s +Unit=omarchy-television-theme.service + +[Install] +WantedBy=timers.target diff --git a/contrib/omarchy-theme/theme/repair.py b/contrib/omarchy-theme/theme/repair.py new file mode 100644 index 00000000..39a1810b --- /dev/null +++ b/contrib/omarchy-theme/theme/repair.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +"""Restore this integration's theme files, then reconcile the active palette.""" +import json +import subprocess +import sys +from pathlib import Path + +from sync import write_changed + +OWNER = 'omarchy-television-theme:v1\n' +MARKER = '.omarchy-television-theme' + + +def main(): + source = Path(__file__).resolve().parent + result = subprocess.run(['tv', 'themes-path'], capture_output=True, text=True, check=True, timeout=15) + target = Path(json.loads(result.stdout)['themesPath']) / 'omarchy' + if target.is_symlink(): + raise ValueError('Refusing to replace a symlinked theme folder') + marker = target / MARKER + if target.exists() and (not marker.is_file() or marker.read_text() != OWNER): + raise ValueError('Existing Omarchy theme is not owned by this integration') + target.mkdir(parents=True, exist_ok=True) + write_changed(marker, OWNER) + repaired = False + for name in ('sync.py', 'repair.py', 'README.md'): + repaired = write_changed(target / name, (source / name).read_text()) or repaired + template = json.loads((source / 'manifest.json').read_text()) + try: + current = json.loads((target / 'manifest.json').read_text()) + template['colorScheme'] = current['colorScheme'] + except (OSError, ValueError, KeyError, TypeError): + pass + repaired = write_changed(target / 'manifest.json', json.dumps(template, indent=2) + '\n') or repaired + command = [sys.executable, str(target / 'sync.py'), '--require-activation'] + if repaired: + command.append('--force-activate') + return subprocess.run(command + sys.argv[1:], timeout=45).returncode + + +if __name__ == '__main__': + try: + sys.exit(main()) + except (OSError, ValueError, subprocess.SubprocessError) as error: + print(f'Omarchy Television repair failed: {error}', file=sys.stderr) + sys.exit(1) diff --git a/contrib/omarchy-theme/theme/sync.py b/contrib/omarchy-theme/theme/sync.py new file mode 100644 index 00000000..0fe9f25f --- /dev/null +++ b/contrib/omarchy-theme/theme/sync.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +"""Map the active Omarchy palette onto Television's public theme tokens.""" +import argparse +import fcntl +import json +import os +import re +import subprocess +import sys +import tempfile +import tomllib +from pathlib import Path + + +def luminance(color): + rgb = [int(color[i:i + 2], 16) / 255 for i in (1, 3, 5)] + linear = [v / 12.92 if v <= 0.04045 else ((v + 0.055) / 1.055) ** 2.4 for v in rgb] + return sum(v * w for v, w in zip(linear, (0.2126, 0.7152, 0.0722))) + + +def contrast(a, b): + light, dark = sorted((luminance(a), luminance(b)), reverse=True) + return (light + 0.05) / (dark + 0.05) + + +def readable(color, foreground, grounds): + """Retain the palette hue while lifting supporting text to readable contrast.""" + start = [int(color[i:i + 2], 16) for i in (1, 3, 5)] + end = [int(foreground[i:i + 2], 16) for i in (1, 3, 5)] + for step in range(101): + candidate = '#' + ''.join(f'{round(a + (b - a) * step / 100):02x}' for a, b in zip(start, end)) + if min(contrast(candidate, ground) for ground in grounds) >= 4.5: + return candidate + return foreground + + +def render(colors): + color_keys = { + 'background', 'foreground', 'dark_background', 'darker_background', + 'lighter_background', 'dark_foreground', 'accent', 'selection', 'muted', + 'red', 'orange', 'yellow', 'green', 'cyan', 'blue', 'magenta', 'bright_magenta', + } + for key in color_keys & colors.keys(): + value = colors[key] + if not isinstance(value, str) or not re.fullmatch(r'#[0-9a-fA-F]{6}', value): + raise ValueError(f'Invalid hex color for {key}') + bg, fg = colors['background'].lower(), colors['foreground'].lower() + mode = colors.get('mode', 'dark' if luminance(bg) < luminance(fg) else 'light') + if mode not in ('light', 'dark'): + raise ValueError('Omarchy mode must be light or dark') + + def get(key, fallback): + return colors.get(key, fallback).lower() + + surface = get('dark_background', bg) + accent = get('accent', get('blue', fg)) + muted = readable(get('dark_foreground', fg), fg, (bg, surface)) + primary_text = max((bg, fg, '#000000', '#ffffff'), key=lambda c: contrast(c, accent)) + tokens = { + 'color-surface': bg, 'color-surface-muted': surface, + 'color-text': fg, 'color-text-muted': muted, + 'accent': accent, 'neutral': get('muted', muted), + 'red': get('red', accent), 'orange': get('orange', get('yellow', accent)), + 'yellow': get('yellow', accent), 'green': get('green', accent), + 'cyan': get('cyan', accent), 'blue': get('blue', accent), + 'purple': get('magenta', accent), 'pink': get('bright_magenta', get('magenta', accent)), + 'color-border': get('muted', muted), + 'color-primary-text': primary_text, + 'color-link': readable(accent, fg, (bg, surface)), + 'control-background': get('lighter_background', surface), + } + css = '/* Generated by sync.py from the active Omarchy colors.toml. */\n:root {\n' + css += ''.join(f' --{key}: {value};\n' for key, value in tokens.items()) + css += '}\n\n/* Application-only paint; shared semantic tokens above also reach artifacts. */\n' + css += ':root[data-television-document="app"] {\n' + css += f' --app-wallpaper-image: none;\n --app-wallpaper: {get("darker_background", surface)};\n' + css += ' --wallpaper-overlay-background: var(--color-surface-muted);\n' + css += f' --channel-background-selected: {get("selection", surface)};\n' + css += ' --channel-text-color-selected: var(--color-text);\n' + css += ' --frame-titlebar-border-color: var(--color-border);\n}\n' + return css, mode + + +def write_changed(path, content): + if path.exists() and path.read_text() == content: + return False + fd, temporary = tempfile.mkstemp(prefix='.' + path.name + '-', dir=path.parent) + try: + with os.fdopen(fd, 'w') as out: + out.write(content) + os.fchmod(out.fileno(), 0o644) + os.replace(temporary, path) + return True + finally: + if os.path.exists(temporary): + os.unlink(temporary) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--colors', type=Path, help='Palette override for offline verification') + parser.add_argument('--no-activate', action='store_true', help='Generate without contacting Television') + parser.add_argument('--require-activation', action='store_true', help='Exit unsuccessfully when activation must be retried') + parser.add_argument('--force-activate', action='store_true', help='Refresh registry even if palette and selection are unchanged') + args = parser.parse_args() + package = Path(__file__).resolve().parent + current = Path.home() / '.local/state/omarchy/current' + palette = args.colors or current / 'theme/colors.toml' + if not palette.exists() and args.colors is None: + palette = Path(os.environ.get('XDG_CONFIG_HOME', Path.home() / '.config')) / 'omarchy/current/theme/colors.toml' + with (package / '.sync.lock').open('a') as lock: + fcntl.flock(lock, fcntl.LOCK_EX) + colors = tomllib.loads(palette.read_text()) + css, mode = render(colors) + manifest = json.loads((package / 'manifest.json').read_text()) + manifest['colorScheme'] = mode + name_path = current / 'theme.name' + theme_name = name_path.read_text().strip() if args.colors is None and name_path.exists() else palette.parent.name + state_path = package / 'sync-state.json' + try: + previous = json.loads(state_path.read_text()) + except (OSError, ValueError): + previous = {} + if not isinstance(previous, dict): + previous = {} + changed = any([ + write_changed(package / 'theme.css', css), + write_changed(package / 'manifest.json', json.dumps(manifest, indent=2) + '\n'), + ]) + state = {'omarchyTheme': theme_name, 'palette': str(palette), 'colorScheme': mode, + 'activationPending': changed or args.force_activate or previous.get('activationPending', True)} + write_changed(package / 'sync-state.json', json.dumps(state, indent=2) + '\n') + print(f'Omarchy Television theme synced: {theme_name} ({mode})') + if not args.no_activate: + try: + if not state['activationPending']: + selection = subprocess.run(['tv', 'focus-status'], capture_output=True, text=True, timeout=15) + if selection.returncode == 0 and json.loads(selection.stdout).get('activeThemeName') == package.name: + print('Palette and active theme are already current.') + return 0 + state['activationPending'] = True + write_changed(state_path, json.dumps(state, indent=2) + '\n') + result = subprocess.run(['tv', 'set-theme', package.name], capture_output=True, text=True, timeout=15) + if result.returncode: + print('Television activation failed; the saved palette remains pending for retry.', file=sys.stderr) + print(result.stderr.strip() or result.stdout.strip(), file=sys.stderr) + return 1 if args.require_activation else 0 + else: + state['activationPending'] = False + write_changed(state_path, json.dumps(state, indent=2) + '\n') + print(result.stdout.strip()) + except (OSError, ValueError, subprocess.TimeoutExpired) as error: + print(f'Television activation deferred: {error}', file=sys.stderr) + state['activationPending'] = True + write_changed(state_path, json.dumps(state, indent=2) + '\n') + return 1 if args.require_activation else 0 + return 0 + + +if __name__ == '__main__': + try: + sys.exit(main()) + except (OSError, ValueError, KeyError) as error: + print(f'Omarchy Television sync failed: {error}', file=sys.stderr) + sys.exit(1) diff --git a/packages/desktop/linux/README.md b/packages/desktop/linux/README.md index 017e50ee..8de39a25 100644 --- a/packages/desktop/linux/README.md +++ b/packages/desktop/linux/README.md @@ -16,6 +16,12 @@ Use **File → Disconnect from Server** or **Ctrl+,** to change servers. The app ## Omarchy +For a server running locally on Omarchy, the optional +[Omarchy theme integration](../../../contrib/omarchy-theme/README.md) keeps +Television's palette and light/dark appearance matched to the current desktop +theme. Install it separately from the source checkout; the ordinary Linux client +installer does not enable it. + The launcher chooses native Wayland whenever `WAYLAND_DISPLAY` is present. Hyprland receives app identity `computer.telepath.television`. The desktop entry works with Omarchy's application launcher and ordinary Hyprland window bindings. An agent can set `TV_OZONE_PLATFORM=x11` for XWayland troubleshooting or pass `--ozone-platform=x11`; the launcher preserves explicit flags. No theme files, Hyprland bindings, system services or existing applications are changed by installation. The Linux **Window** menu provides **Maximize / Restore** and **Close**, plus **Hide** on Hyprland or native **Minimize** on other desktops. On verified Hyprland 0.56.2, Hide moves the focused owned main window into an inactive special workspace. It keeps the original window, current document, unsaved edits and server connection alive. Opening the same installation with the same profile restores that window to its original workspace and requests focus. Maximize / Restore toggles the focused native window and keeps its server connection. diff --git a/packages/desktop/test/omarchy-theme.test.ts b/packages/desktop/test/omarchy-theme.test.ts new file mode 100644 index 00000000..9125a252 --- /dev/null +++ b/packages/desktop/test/omarchy-theme.test.ts @@ -0,0 +1,16 @@ +import { spawnSync } from "node:child_process"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +describe.skipIf(process.platform !== "linux")("Optional Omarchy theme integration", () => { + it("converts palettes and recovers missing files and deferred activation", () => { + const tests = path.resolve(import.meta.dirname, "../../../contrib/omarchy-theme/tests"); + const result = spawnSync("python3", ["-m", "unittest", "discover", "-s", tests, "-v"], { + encoding: "utf8", + env: { ...process.env, PYTHONDONTWRITEBYTECODE: "1" }, + timeout: 25_000, + }); + expect(result.error, "Python 3.11+ must be available on the Linux test host").toBeUndefined(); + expect(result.status, result.stderr + result.stdout).toBe(0); + }); +}); From cd59e66c79a177f8a497cf74fd376c32874e37e8 Mon Sep 17 00:00:00 2001 From: Gberfield <89108862+Gberfield@users.noreply.github.com> Date: Wed, 7 Oct 2026 19:39:00 -0400 Subject: [PATCH 2/2] fix(linux): keep Omarchy installer independent of ripgrep --- contrib/omarchy-theme/README.md | 2 +- contrib/omarchy-theme/install.sh | 4 ++-- contrib/omarchy-theme/tests/test_sync.py | 4 ++++ 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/contrib/omarchy-theme/README.md b/contrib/omarchy-theme/README.md index 180d066f..17cd1a95 100644 --- a/contrib/omarchy-theme/README.md +++ b/contrib/omarchy-theme/README.md @@ -6,7 +6,7 @@ that hosts the Television server and uses Television's supported theme interface It is installed separately from the Linux desktop client; it is not a bundled default theme and does not change the desktop client's installer. -Requirements: Omarchy, Python 3.11 or later, Bash, ripgrep, user systemd, and a +Requirements: Omarchy, Python 3.11 or later, Bash, user systemd, and a working local `tv` command/server. No root privileges or new Python packages are needed. diff --git a/contrib/omarchy-theme/install.sh b/contrib/omarchy-theme/install.sh index 2769cca9..8244630b 100644 --- a/contrib/omarchy-theme/install.sh +++ b/contrib/omarchy-theme/install.sh @@ -55,8 +55,8 @@ for event in theme-set post-boot post-update; do paths+=("$HOME/.config/omarchy/hooks/$event.d/95-television-omarchy-theme") done for path in "${paths[@]:2}"; do - if [[ -e "$path" ]] && ! rg -q -F 'Managed by omarchy-television-theme' "$path"; then - if [[ "$adopt" == 1 && "$path" == */95-television-omarchy-theme ]] && rg -q -F '/omarchy/sync.py' "$path"; then + if [[ -e "$path" ]] && ! grep -qF 'Managed by omarchy-television-theme' "$path"; then + if [[ "$adopt" == 1 && "$path" == */95-television-omarchy-theme ]] && grep -qF '/omarchy/sync.py' "$path"; then continue fi echo "Refusing to overwrite an unowned file: $path" >&2 diff --git a/contrib/omarchy-theme/tests/test_sync.py b/contrib/omarchy-theme/tests/test_sync.py index 6ec40694..e8cddfac 100644 --- a/contrib/omarchy-theme/tests/test_sync.py +++ b/contrib/omarchy-theme/tests/test_sync.py @@ -195,6 +195,10 @@ def test_optimized_python_cannot_adopt_unrelated_theme(self): def test_installer_preserves_cli_path_for_service_and_can_be_rerun(self): env = self.installer_env() + # Match Linux CI hosts that do not have the optional ripgrep tool. + rg = self.root / 'bin/rg' + rg.write_text('#!/bin/sh\nexit 127\n') + rg.chmod(0o755) for _ in range(2): result = subprocess.run(['bash', str(SOURCE.parent / 'install.sh')], env=env, capture_output=True, text=True)