diff --git a/release/packages/ryoku-palette-bridge/PKGBUILD b/release/packages/ryoku-palette-bridge/PKGBUILD new file mode 100644 index 0000000000..17b8919a8e --- /dev/null +++ b/release/packages/ryoku-palette-bridge/PKGBUILD @@ -0,0 +1,40 @@ +# Maintainer: Ryoku +# +# Ryoku Palette Bridge: publishes the wallpaper palette via HTTP so Spotify, +# Vesktop, and Zen Browser can consume it live. A single Go binary listens on +# localhost and fans palette changes to connected clients; no systemd hard +# dependency here—the service unit ships so the optional user-service path works. +pkgname=ryoku-palette-bridge +pkgver=${RYOKU_PKGVER:-0.1.0} +pkgrel=1 +pkgdesc="Ryoku wallpaper palette event bridge: exposes colours to SPA integrations" +arch=('x86_64') +url="https://ryoku.dev" +license=('MIT') +depends=() +makedepends=('go') +source=() + +_repo="$startdir/../../.." + +build() { + cd "$_repo/ryoku/palette-bridge" + # no vendor tree is shipped with the bridge; GOFLAGS from the publish + # container pin the proxy so the build stays offline and reproducible. + CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o "$srcdir/ryoku-palette-bridge" . +} + +package() { + install -Dm755 "$srcdir/ryoku-palette-bridge" \ + "$pkgdir/usr/bin/ryoku-palette-bridge" + install -Dm755 "$_repo/ryoku/palette-bridge/doctor.sh" \ + "$pkgdir/usr/bin/ryoku-palette-bridge-doctor" + install -Dm755 "$_repo/ryoku/palette-bridge/remove-integrations.sh" \ + "$pkgdir/usr/bin/ryoku-palette-bridge-remove-integrations" + install -Dm644 "$_repo/ryoku/palette-bridge/packaging/systemd/ryoku-palette-bridge.service" \ + "$pkgdir/usr/lib/systemd/user/ryoku-palette-bridge.service" + install -dm755 "$pkgdir/usr/share/ryoku/palette-bridge" + cp -a "$_repo/ryoku/palette-bridge/." "$pkgdir/usr/share/ryoku/palette-bridge/" + install -Dm644 "$_repo/ryoku/palette-bridge/LICENSE" \ + "$pkgdir/usr/share/licenses/$pkgname/LICENSE" +} diff --git a/ryoku/palette-bridge/LICENSE b/ryoku/palette-bridge/LICENSE new file mode 100644 index 0000000000..4c9e29d061 --- /dev/null +++ b/ryoku/palette-bridge/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Rayyan + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/ryoku/palette-bridge/doctor.sh b/ryoku/palette-bridge/doctor.sh new file mode 100755 index 0000000000..b49bb777df --- /dev/null +++ b/ryoku/palette-bridge/doctor.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MIT +set -uo pipefail + +palette=${RYOKU_PALETTE:-"${XDG_CACHE_HOME:-$HOME/.cache}/ryoku/colors.json"} +base_url=${RYOKU_PALETTE_BRIDGE_URL:-http://127.0.0.1:47616} +failed=0 + +ok() { + printf 'ok %s\n' "$1" +} + +fail() { + printf 'FAIL %s\n' "$1" >&2 + failed=1 +} + +for command_name in curl systemctl; do + if ! command -v "$command_name" >/dev/null; then + fail "required command is missing: $command_name" + fi +done +((failed == 0)) || exit "$failed" + +have_jq=true +if ! command -v jq >/dev/null; then + have_jq=false + printf 'WARN jq is missing; the palette-diff check will be skipped\n' +fi + +if $have_jq; then + if jq -e ' + type == "object" and + (.primary | test("^#[0-9a-fA-F]{6}$")) and + (.surface | test("^#[0-9a-fA-F]{6}$")) and + (.onSurface | test("^#[0-9a-fA-F]{6}$")) and + all(.[]; type == "string" and test("^#[0-9a-fA-F]{6}$")) + ' "$palette" >/dev/null 2>&1; then + ok "palette is valid: $palette" + else + fail "palette is missing or invalid: $palette" + fi +else + printf 'WARN jq is missing; palette validation skipped\n' +fi + +if systemctl --user is-active --quiet ryoku-palette-bridge.service; then + ok "canonical service is active" +elif systemctl --user is-active --quiet ryoku-spicetify-palette.service; then + printf 'WARN legacy service is active; rerun install.sh to migrate it\n' +else + fail "palette bridge service is inactive" +fi + +if [[ $(curl --connect-timeout 2 --max-time 5 -fsS "$base_url/healthz" 2>/dev/null) == ok ]]; then + ok "health endpoint responds" +else + fail "health endpoint is unavailable: $base_url/healthz" +fi + +if $have_jq; then + published=$(curl --connect-timeout 2 --max-time 5 -fsS "$base_url/v1/palette" 2>/dev/null || true) + local_normalized=$(jq -cS . "$palette" 2>/dev/null || true) + published_normalized=$(jq -cS . <<< "$published" 2>/dev/null || true) + if [[ -n $local_normalized && $published_normalized == "$local_normalized" ]]; then + ok "published palette matches" + else + fail "published palette differs from the active palette" + fi +else + printf 'WARN jq is missing; the published-palette check was skipped\n' +fi + +if ((failed == 0)); then + printf 'Ryoku Palette Bridge is healthy.\n' +fi +exit "$failed" diff --git a/ryoku/palette-bridge/go.mod b/ryoku/palette-bridge/go.mod new file mode 100644 index 0000000000..1ad4151fcd --- /dev/null +++ b/ryoku/palette-bridge/go.mod @@ -0,0 +1,3 @@ +module ryoku-palette-bridge + +go 1.24 diff --git a/ryoku/palette-bridge/install-integrations.sh b/ryoku/palette-bridge/install-integrations.sh new file mode 100755 index 0000000000..deeda0d648 --- /dev/null +++ b/ryoku/palette-bridge/install-integrations.sh @@ -0,0 +1,180 @@ +#!/usr/bin/env bash +set -euo pipefail + +project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +config_root="${XDG_CONFIG_HOME:-$HOME/.config}" +overlay_root="$config_root/ryoku/user_edits/matugen" +overlay_apps="$overlay_root/apps.toml" +live_apps="$config_root/matugen/apps.toml" +if [[ "$config_root" == "$HOME/.config" ]]; then + # Literal paths written into apps.toml; Matugen expands the tilde. + # shellcheck disable=SC2088 + matugen_template_root='~/.config/matugen/templates' + # shellcheck disable=SC2088 + vesktop_quick_css='~/.config/vesktop/settings/quickCss.css' +else + matugen_template_root="$config_root/matugen/templates" + vesktop_quick_css="$config_root/vesktop/settings/quickCss.css" +fi +want_spotify=false +want_vesktop=false +want_zen=false +state_root="${XDG_STATE_HOME:-$HOME/.local/state}/ryoku/palette-bridge" +ownership_file="$state_root/owned-files.tsv" + +usage() { + printf 'Usage: %s --all | [--spotify] [--vesktop] [--zen]\n' "${0##*/}" +} + +while (( $# )); do + case "$1" in + --all) want_spotify=true; want_vesktop=true; want_zen=true ;; + --spotify) want_spotify=true ;; + --vesktop) want_vesktop=true ;; + --zen) want_zen=true ;; + -h|--help) usage; exit 0 ;; + *) usage >&2; exit 2 ;; + esac + shift +done + +if ! $want_spotify && ! $want_vesktop && ! $want_zen; then + usage >&2 + exit 2 +fi + +prepare_matugen_overlay() { + install -d "$overlay_root/templates" + if [[ ! -f "$overlay_apps" ]]; then + [[ -f "$live_apps" ]] || { + printf 'Matugen apps file not found: %s\n' "$live_apps" >&2 + exit 1 + } + install -m 0644 "$live_apps" "$overlay_apps" + fi +} + +record_owned() { + local integration="$1" path="$2" temporary + install -d "$state_root" + temporary=$(mktemp) + if [[ -f "$ownership_file" ]]; then + awk -F '\t' -v app="$integration" -v path="$path" '!($1 == app && $2 == path)' "$ownership_file" > "$temporary" + fi + printf '%s\t%s\n' "$integration" "$path" >> "$temporary" + install -m 0600 "$temporary" "$ownership_file" + rm -f "$temporary" +} + +set_matugen_section() { + local section="$1" input_path="$2" output_path="$3" temporary + temporary=$(mktemp) + awk -v target="[$section]" ' + $0 == target { skip=1; next } + /^\[/ && skip { skip=0 } + !skip { print } + ' "$overlay_apps" > "$temporary" + { + printf '\n[%s]\n' "$section" + printf 'input_path = "%s"\n' "$input_path" + printf 'output_path = "%s"\n' "$output_path" + } >> "$temporary" + install -m 0644 "$temporary" "$overlay_apps" + rm -f "$temporary" +} + +if $want_spotify; then + command -v spicetify >/dev/null || { printf 'spicetify is required for --spotify\n' >&2; exit 1; } + install -d "$config_root/spicetify/Extensions" + install -m 0644 "$project_root/spicetify/ryoku-wallpaper-colors.js" \ + "$config_root/spicetify/Extensions/ryoku-wallpaper-colors.js" + record_owned spotify "$config_root/spicetify/Extensions/ryoku-wallpaper-colors.js" + if ! spicetify config extensions | grep -Fxq 'ryoku-wallpaper-colors.js'; then + spicetify config extensions ryoku-wallpaper-colors.js + fi + spicetify apply + printf 'Installed the Spotify live-palette extension.\n' +fi + +if $want_vesktop; then + command -v jq >/dev/null || { printf 'jq is required for --vesktop\n' >&2; exit 1; } + settings="$config_root/vesktop/settings/settings.json" + [[ -f "$settings" ]] || { printf 'Vesktop settings not found: %s\n' "$settings" >&2; exit 1; } + prepare_matugen_overlay + install -m 0644 "$project_root/templates/vesktop-colors.css" "$overlay_root/templates/vesktop-colors.css" + record_owned vesktop "$overlay_root/templates/vesktop-colors.css" + set_matugen_section templates.vesktop \ + "$matugen_template_root/vesktop-colors.css" \ + "$vesktop_quick_css" + install -d "$config_root/vesktop/themes" + install -m 0644 "$project_root/vesktop/midnight-ryoku.theme.css" \ + "$config_root/vesktop/themes/midnight-ryoku.theme.css" + record_owned vesktop "$config_root/vesktop/themes/midnight-ryoku.theme.css" + temporary=$(mktemp) + jq '.useQuickCss = true | .enabledThemes = ((.enabledThemes // []) | if index("midnight-ryoku.theme.css") then . else . + ["midnight-ryoku.theme.css"] end)' \ + "$settings" > "$temporary" + install -m 0644 "$temporary" "$settings" + rm -f "$temporary" + printf 'Installed the Vesktop no-flash Midnight integration.\n' +fi + +if $want_zen; then + zen_root="$config_root/zen" + if [[ -n "${ZEN_PROFILE_ROOT:-}" ]]; then + profile_root="$ZEN_PROFILE_ROOT" + else + profile_root=$(find "$zen_root" -mindepth 2 -maxdepth 2 -name .parentlock -printf '%h\n' -quit 2>/dev/null || true) + if [[ -z "$profile_root" && -f "$zen_root/profiles.ini" ]]; then + profile_path=$(awk -F= ' + /^\[Profile/ { active=1; path=""; preferred=0; next } + /^\[/ { if (active && preferred && path != "") { print path; exit }; active=0 } + active && $1 == "Path" { path=$2 } + active && $1 == "Default" && $2 == "1" { preferred=1 } + END { if (active && preferred && path != "") print path } + ' "$zen_root/profiles.ini" | head -n1) + [[ -n "$profile_path" ]] && profile_root="$zen_root/$profile_path" + fi + fi + [[ -n "${profile_root:-}" && -d "$profile_root" ]] || { + printf 'Could not identify a Zen profile; set ZEN_PROFILE_ROOT and retry.\n' >&2 + exit 1 + } + prepare_matugen_overlay + install -m 0644 "$project_root/templates/zen.css" "$overlay_root/templates/zen.css" + record_owned zen "$overlay_root/templates/zen.css" + if [[ "$profile_root" == "$HOME/"* ]]; then + profile_output="~${profile_root#"$HOME"}/chrome/ryoku-colors.css" + else + profile_output="$profile_root/chrome/ryoku-colors.css" + fi + set_matugen_section templates.zen "$matugen_template_root/zen.css" "$profile_output" + install -d "$profile_root/chrome" + user_chrome="$profile_root/chrome/userChrome.css" + touch "$user_chrome" + if ! grep -Fqx '@import "ryoku-colors.css";' "$user_chrome"; then + temporary=$(mktemp) + printf '@import "ryoku-colors.css";\n' > "$temporary" + sed -n '1,$p' "$user_chrome" >> "$temporary" + install -m 0644 "$temporary" "$user_chrome" + rm -f "$temporary" + fi + record_owned zen "$user_chrome" + user_js="$profile_root/user.js" + touch "$user_js" + temporary=$(mktemp) + grep -vE 'user_pref\("(toolkit\.legacyUserProfileCustomizations\.stylesheets|zen\.theme\.disable-lightweight)"' "$user_js" > "$temporary" || true + printf 'user_pref("toolkit.legacyUserProfileCustomizations.stylesheets", true);\n' >> "$temporary" + printf 'user_pref("zen.theme.disable-lightweight", false);\n' >> "$temporary" + install -m 0644 "$temporary" "$user_js" + record_owned zen "$user_js" + rm -f "$temporary" + printf 'Installed Zen profile wiring. Sign/install zen-extension separately, then restart Zen once.\n' +fi + +if $want_vesktop || $want_zen; then + command -v ryoku >/dev/null || { printf 'ryoku is required to materialize Matugen overlays\n' >&2; exit 1; } + ryoku materialize + if command -v ryogami >/dev/null; then + ryogami wallpaper repaint + fi +fi diff --git a/ryoku/palette-bridge/install.sh b/ryoku/palette-bridge/install.sh new file mode 100755 index 0000000000..4b9cc5a793 --- /dev/null +++ b/ryoku/palette-bridge/install.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -euo pipefail + +project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +bin_dir="$HOME/.local/bin" +unit_dir="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user" + +install -d "$bin_dir" "$unit_dir" +(cd "$project_root" && go build -trimpath -ldflags='-s -w' -o "$bin_dir/ryoku-palette-bridge" .) +install -m 0755 "$project_root/doctor.sh" "$bin_dir/ryoku-palette-bridge-doctor" +install -m 0755 "$project_root/remove-integrations.sh" "$bin_dir/ryoku-palette-bridge-remove-integrations" +install -m 0644 "$project_root/packaging/systemd/ryoku-palette-bridge.service" \ + "$unit_dir/ryoku-palette-bridge.service" +systemctl --user daemon-reload +for legacy_unit in ryoku-spicetify-palette.service spiceflow.service; do + if systemctl --user list-unit-files "$legacy_unit" --no-legend 2>/dev/null | + grep -q "^${legacy_unit}"; then + systemctl --user disable --now "$legacy_unit" + printf 'Disabled legacy %s (its unit file was preserved)\n' "$legacy_unit" + fi +done +printf 'Installed ryoku-palette-bridge.service (enable it when ready).\n' diff --git a/ryoku/palette-bridge/main.go b/ryoku/palette-bridge/main.go new file mode 100644 index 0000000000..daab053960 --- /dev/null +++ b/ryoku/palette-bridge/main.go @@ -0,0 +1,271 @@ +package main + +import ( + "bytes" + "encoding/json" + "errors" + "flag" + "fmt" + "log" + "net/http" + "os" + "os/signal" + "path/filepath" + "regexp" + "sync" + "syscall" + "time" + "unsafe" +) + +var hexColor = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`) + +type snapshot struct { + data []byte + version uint64 +} + +type paletteHub struct { + mu sync.RWMutex + current snapshot + clients map[chan snapshot]struct{} +} + +func newPaletteHub() *paletteHub { + return &paletteHub{clients: make(map[chan snapshot]struct{})} +} + +func validatePalette(data []byte) ([]byte, error) { + var palette map[string]string + if err := json.Unmarshal(data, &palette); err != nil { + return nil, fmt.Errorf("decode palette: %w", err) + } + for _, role := range []string{"primary", "surface", "onSurface"} { + color := palette[role] + if !hexColor.MatchString(color) { + return nil, fmt.Errorf("palette role %q is missing or invalid", role) + } + } + for role, color := range palette { + if !hexColor.MatchString(color) { + return nil, fmt.Errorf("palette role %q has invalid color %q", role, color) + } + } + compact := new(bytes.Buffer) + if err := json.Compact(compact, data); err != nil { + return nil, err + } + return compact.Bytes(), nil +} + +func (h *paletteHub) publish(data []byte) error { + valid, err := validatePalette(data) + if err != nil { + return err + } + + h.mu.Lock() + defer h.mu.Unlock() + if bytes.Equal(valid, h.current.data) { + return nil + } + h.current = snapshot{data: valid, version: h.current.version + 1} + for client := range h.clients { + select { + case client <- h.current: + default: + // A slow client needs the latest palette, not an outdated queued one. + select { + case <-client: + default: + } + client <- h.current + } + } + return nil +} + +func (h *paletteHub) readFile(path string) error { + data, err := os.ReadFile(path) + if err != nil { + return err + } + return h.publish(data) +} + +func (h *paletteHub) snapshot() snapshot { + h.mu.RLock() + defer h.mu.RUnlock() + return snapshot{data: bytes.Clone(h.current.data), version: h.current.version} +} + +func corsHeaders(header http.Header) { + header.Set("Access-Control-Allow-Origin", "*") + header.Set("Access-Control-Allow-Methods", "GET, OPTIONS") + header.Set("Access-Control-Allow-Private-Network", "true") +} + +func (h *paletteHub) paletteHandler(w http.ResponseWriter, r *http.Request) { + corsHeaders(w.Header()) + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + s := h.snapshot() + if len(s.data) == 0 { + http.Error(w, "palette unavailable", http.StatusServiceUnavailable) + return + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("ETag", fmt.Sprintf("\"%d\"", s.version)) + _, _ = w.Write(s.data) +} + +func writeEvent(w http.ResponseWriter, s snapshot) error { + _, err := fmt.Fprintf(w, "id: %d\ndata: %s\n\n", s.version, s.data) + return err +} + +func (h *paletteHub) eventsHandler(w http.ResponseWriter, r *http.Request) { + corsHeaders(w.Header()) + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming unsupported", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + + updates := make(chan snapshot, 1) + h.mu.Lock() + h.clients[updates] = struct{}{} + initial := h.current + h.mu.Unlock() + defer func() { + h.mu.Lock() + delete(h.clients, updates) + h.mu.Unlock() + }() + + if len(initial.data) > 0 { + if err := writeEvent(w, initial); err != nil { + return + } + flusher.Flush() + } + + heartbeat := time.NewTicker(30 * time.Second) + defer heartbeat.Stop() + for { + select { + case update := <-updates: + if err := writeEvent(w, update); err != nil { + return + } + flusher.Flush() + case <-heartbeat.C: + if _, err := fmt.Fprint(w, ": keepalive\n\n"); err != nil { + return + } + flusher.Flush() + case <-r.Context().Done(): + return + } + } +} + +func watchPalette(path string, reload func() error) error { + fd, err := syscall.InotifyInit1(syscall.IN_CLOEXEC) + if err != nil { + return err + } + defer syscall.Close(fd) + dir, name := filepath.Dir(path), filepath.Base(path) + _, err = syscall.InotifyAddWatch(fd, dir, syscall.IN_CLOSE_WRITE|syscall.IN_MOVED_TO) + if err != nil { + return err + } + + buffer := make([]byte, syscall.SizeofInotifyEvent+4096) + for { + n, err := syscall.Read(fd, buffer) + if err != nil { + if errors.Is(err, syscall.EINTR) { + continue + } + return err + } + changed := false + for offset := 0; offset+syscall.SizeofInotifyEvent <= n; { + event := (*syscall.InotifyEvent)(unsafe.Pointer(&buffer[offset])) + start := offset + syscall.SizeofInotifyEvent + end := start + int(event.Len) + eventName := string(bytes.TrimRight(buffer[start:end], "\x00")) + offset = end + if eventName == name { + changed = true + } + } + if changed { + if err := reload(); err != nil { + log.Printf("palette update ignored: %v", err) + } + } + } +} + +func main() { + home, err := os.UserHomeDir() + if err != nil { + log.Fatal(err) + } + cache := os.Getenv("XDG_CACHE_HOME") + if cache == "" { + cache = filepath.Join(home, ".cache") + } + palette := flag.String("palette", filepath.Join(cache, "ryoku/colors.json"), "palette JSON path") + listen := flag.String("listen", "127.0.0.1:47616", "HTTP listen address") + flag.Parse() + + hub := newPaletteHub() + if err := hub.readFile(*palette); err != nil { + log.Fatalf("initial palette: %v", err) + } + go func() { + if err := watchPalette(*palette, func() error { return hub.readFile(*palette) }); err != nil { + log.Fatalf("palette watcher: %v", err) + } + }() + + mux := http.NewServeMux() + mux.HandleFunc("/colors.json", hub.paletteHandler) + mux.HandleFunc("/v1/palette", hub.paletteHandler) + mux.HandleFunc("/v1/events", hub.eventsHandler) + mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("ok\n")) }) + server := &http.Server{Addr: *listen, Handler: mux, ReadHeaderTimeout: 5 * time.Second} + + stop := make(chan os.Signal, 1) + signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM) + go func() { + <-stop + _ = server.Close() + }() + log.Printf("serving %s on http://%s", *palette, *listen) + if err := server.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) { + log.Fatal(err) + } +} diff --git a/ryoku/palette-bridge/packaging/systemd/ryoku-palette-bridge.service b/ryoku/palette-bridge/packaging/systemd/ryoku-palette-bridge.service new file mode 100644 index 0000000000..4580005c2c --- /dev/null +++ b/ryoku/palette-bridge/packaging/systemd/ryoku-palette-bridge.service @@ -0,0 +1,12 @@ +[Unit] +Description=Ryoku wallpaper palette event bridge +After=graphical-session.target + +[Service] +Type=simple +ExecStart=/usr/bin/ryoku-palette-bridge +Restart=on-failure +RestartSec=2 + +[Install] +WantedBy=default.target diff --git a/ryoku/palette-bridge/remove-integrations.sh b/ryoku/palette-bridge/remove-integrations.sh new file mode 100755 index 0000000000..236f3613b8 --- /dev/null +++ b/ryoku/palette-bridge/remove-integrations.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +set -euo pipefail + +config_root="${XDG_CONFIG_HOME:-$HOME/.config}" +state_root="${XDG_STATE_HOME:-$HOME/.local/state}/ryoku/palette-bridge" +ownership_file="$state_root/owned-files.tsv" +overlay_apps="$config_root/ryoku/user_edits/matugen/apps.toml" +want= + +usage() { printf 'Usage: %s --spotify | --vesktop | --zen\n' "${0##*/}"; } + +case "${1:-}" in + --spotify) want=spotify ;; + --vesktop) want=vesktop ;; + --zen) want=zen ;; + -h|--help) usage; exit 0 ;; + *) usage >&2; exit 2 ;; +esac + +remove_owned_file() { + local path="$1" + [[ -f "$path" ]] && rm -f -- "$path" +} + +remove_matugen_section() { + local section="$1" temporary + [[ -f "$overlay_apps" ]] || return 0 + temporary=$(mktemp) + awk -v target="[$section]" ' + $0 == target { skip=1; next } + /^\[/ && skip { skip=0 } + !skip { print } + ' "$overlay_apps" > "$temporary" + install -m 0644 "$temporary" "$overlay_apps" + rm -f "$temporary" +} + +case "$want" in + spotify) + if command -v spicetify >/dev/null; then + spicetify config extensions ryoku-wallpaper-colors.js- || true + spicetify apply || true + fi + ;; + vesktop) + remove_matugen_section templates.vesktop + settings="$config_root/vesktop/settings/settings.json" + if [[ -f "$settings" ]] && command -v jq >/dev/null; then + temporary=$(mktemp) + jq '.enabledThemes = ((.enabledThemes // []) | map(select(. != "midnight-ryoku.theme.css")))' "$settings" > "$temporary" + install -m 0644 "$temporary" "$settings" + rm -f "$temporary" + fi + ;; + zen) + remove_matugen_section templates.zen + ;; +esac + +if [[ -f "$ownership_file" ]]; then + while IFS=$'\t' read -r integration path; do + [[ "$integration" == "$want" ]] || continue + case "$want:$path" in + spotify:"$config_root"/spicetify/Extensions/ryoku-wallpaper-colors.js|\ + vesktop:"$config_root"/ryoku/user_edits/matugen/templates/vesktop-colors.css|\ + vesktop:"$config_root"/vesktop/themes/midnight-ryoku.theme.css|\ + zen:"$config_root"/ryoku/user_edits/matugen/templates/zen.css) + remove_owned_file "$path" + ;; + zen:*/chrome/userChrome.css) + temporary=$(mktemp) + grep -Fvx '@import "ryoku-colors.css";' "$path" > "$temporary" || true + install -m 0644 "$temporary" "$path" + rm -f "$temporary" + ;; + zen:*/user.js) + temporary=$(mktemp) + grep -vE 'user_pref\("(toolkit\.legacyUserProfileCustomizations\.stylesheets", true|zen\.theme\.disable-lightweight", false)\);' "$path" > "$temporary" || true + install -m 0644 "$temporary" "$path" + rm -f "$temporary" + ;; + esac + done < "$ownership_file" + temporary=$(mktemp) + awk -F '\t' -v app="$want" '$1 != app' "$ownership_file" > "$temporary" + install -m 0600 "$temporary" "$ownership_file" + rm -f "$temporary" +fi + +if [[ "$want" == vesktop || "$want" == zen ]]; then + command -v ryoku >/dev/null && ryoku materialize + command -v ryogami >/dev/null && ryogami wallpaper repaint +fi +printf 'Removed the %s integration files owned by Ryoku Palette Bridge.\n' "$want" diff --git a/ryoku/palette-bridge/spicetify/ryoku-wallpaper-colors.js b/ryoku/palette-bridge/spicetify/ryoku-wallpaper-colors.js new file mode 100644 index 0000000000..b403efbeae --- /dev/null +++ b/ryoku/palette-bridge/spicetify/ryoku-wallpaper-colors.js @@ -0,0 +1,129 @@ +// Update Spotify's color variables directly from Ryoku's wallpaper palette. +// This changes paint values in place: no renderer reload and no playback pause. +(function ryokuWallpaperColors() { + if (window.__ryokuWallpaperColors) return; + window.__ryokuWallpaperColors = true; + const endpoint = "http://127.0.0.1:47616/v1/events"; + const roles = { + text: "onSurface", + subtext: "onSurfaceVariant", + main: "background", + "main-elevated": "surfaceContainer", + "main-transition": "surface", + highlight: "surfaceContainerHigh", + "highlight-elevated": "surfaceContainerHighest", + sidebar: "surfaceContainerLow", + player: "background", + card: "surfaceContainer", + shadow: "shadow", + "selected-row": "onSurface", + button: "primary", + "button-active": "primaryContainer", + "button-disabled": "outlineVariant", + "tab-active": "surfaceContainerHigh", + notification: "tertiary", + "notification-error": "error", + misc: "surfaceVariant", + "play-button": "primary", + "play-button-active": "primaryContainer", + "progress-fg": "secondary", + "progress-bg": "surfaceVariant", + heart: "error", + "pagelink-active": "tertiary", + "radio-btn-active": "primary", + }; + + // Modern Spotify surfaces use Encore tokens in addition to --spice-*. + const bridgeCss = ` + :root, + .encore-dark-theme, + .encore-dark-theme .encore-base-set { + --background-base: var(--spice-main) !important; + --background-highlight: var(--spice-highlight) !important; + --background-press: var(--spice-highlight-elevated) !important; + --background-elevated-base: var(--spice-main-elevated) !important; + --background-elevated-highlight: var(--spice-highlight-elevated) !important; + --background-elevated-press: var(--spice-highlight-elevated) !important; + --background-tinted-base: var(--spice-main-elevated) !important; + --background-tinted-highlight: var(--spice-highlight) !important; + --background-tinted-press: var(--spice-highlight-elevated) !important; + --text-base: var(--spice-text) !important; + --text-subdued: var(--spice-subtext) !important; + --text-bright-accent: var(--spice-button) !important; + --essential-base: var(--spice-text) !important; + --essential-subdued: var(--spice-subtext) !important; + --essential-bright-accent: var(--spice-button) !important; + --decorative-base: var(--spice-text) !important; + --decorative-subdued: var(--spice-subtext) !important; + --encore-app-semantic-background-color: var(--spice-main) !important; + --encore-background-highlight: var(--spice-highlight) !important; + --encore-background-elevated-highlight: var(--spice-highlight-elevated) !important; + --encore-box-shadow-color: var(--spice-shadow) !important; + --encore-focus-outline-color: var(--spice-button) !important; + } + :root .encore-bright-accent-set { + --background-base: var(--spice-button) !important; + --background-highlight: var(--spice-button-active) !important; + --background-press: var(--spice-button-active) !important; + --text-base: var(--spice-main) !important; + --essential-base: var(--spice-main) !important; + } + `; + + let bridge = document.getElementById("ryoku-wallpaper-token-bridge"); + if (!bridge) { + bridge = document.createElement("style"); + bridge.id = "ryoku-wallpaper-token-bridge"; + bridge.textContent = bridgeCss; + document.head.appendChild(bridge); + } + + let last = ""; + const applied = new Map(); + function setColor(style, name, color) { + if (applied.get(name) === color) return; + style.setProperty(name, color); + applied.set(name, color); + } + function rgb(hex) { + const value = hex.replace("#", ""); + return [0, 2, 4].map((i) => parseInt(value.slice(i, i + 2), 16)).join(","); + } + function update(palette) { + try { + const signature = JSON.stringify(palette); + if (signature === last) return; + last = signature; + const style = document.documentElement.style; + for (const [name, role] of Object.entries(roles)) { + const color = palette[role]; + if (!/^#[0-9a-f]{6}$/i.test(color || "")) continue; + setColor(style, "--spice-" + name, color); + setColor(style, "--spice-rgb-" + name, rgb(color)); + } + if (/^#[0-9a-f]{6}$/i.test(palette.primary || "")) { + setColor(style, "--spice-primary", palette.primary); + } + window.dispatchEvent(new CustomEvent("ryoku-palette-changed", { detail: palette })); + } catch (_) {} + } + + let reconnectTimer = null; + function connect() { + const events = new EventSource(endpoint); + events.onmessage = (event) => { + try { + update(JSON.parse(event.data)); + } catch (_) {} + }; + events.onerror = () => { + events.close(); + if (reconnectTimer !== null) return; + reconnectTimer = setTimeout(() => { + reconnectTimer = null; + connect(); + }, 1000); + }; + } + connect(); +})(); diff --git a/ryoku/palette-bridge/templates/matugen-apps.example.toml b/ryoku/palette-bridge/templates/matugen-apps.example.toml new file mode 100644 index 0000000000..7bb9d50ad2 --- /dev/null +++ b/ryoku/palette-bridge/templates/matugen-apps.example.toml @@ -0,0 +1,10 @@ +# Merge these sections into Ryoku's user-owned Matugen apps.toml overlay. +# Replace with the active directory listed by about:profiles. + +[templates.vesktop] +input_path = "~/.config/matugen/templates/vesktop-colors.css" +output_path = "~/.config/vesktop/settings/quickCss.css" + +[templates.zen] +input_path = "~/.config/matugen/templates/zen.css" +output_path = "~/.config/zen//chrome/ryoku-colors.css" diff --git a/ryoku/palette-bridge/templates/vesktop-colors.css b/ryoku/palette-bridge/templates/vesktop-colors.css new file mode 100644 index 0000000000..076acdcf43 --- /dev/null +++ b/ryoku/palette-bridge/templates/vesktop-colors.css @@ -0,0 +1,54 @@ +/* Generated palette only. Midnight remains loaded in a stable theme layer. */ +:root { + --font: "figtree"; + --corner-text: "Midnight"; + + --online-indicator: {{colors.inverse_primary.default.hex}}; + --dnd-indicator: {{colors.error.default.hex}}; + --idle-indicator: {{colors.tertiary_container.default.hex}}; + --streaming-indicator: {{colors.on_primary.default.hex}}; + + --accent-1: {{colors.tertiary.default.hex}}; + --accent-2: {{colors.primary.default.hex}}; + --accent-3: {{colors.primary.default.hex}}; + --accent-4: {{colors.surface_bright.default.hex}}; + --accent-5: {{colors.primary_fixed_dim.default.hex}}; + --accent-new: {{colors.inverse_primary.default.hex}}; + --mention: {{colors.surface.default.hex}}; + --mention-hover: {{colors.surface_bright.default.hex}}; + + --text-0: {{colors.surface.default.hex}}; + --text-1: {{colors.on_surface.default.hex}}; + --text-2: {{colors.on_surface.default.hex}}; + --text-3: {{colors.on_surface_variant.default.hex}}; + --text-4: {{colors.on_surface_variant.default.hex}}; + --text-5: {{colors.outline.default.hex}}; + + --bg-1: {{colors.surface_variant.default.hex}}; + --bg-2: {{colors.surface_container_high.default.hex}}; + --bg-3: {{colors.surface_container_low.default.hex}}; + --bg-4: {{colors.surface.default.hex}}; + --hover: {{colors.surface_bright.default.hex}}; + --active: {{colors.surface_bright.default.hex}}; + --message-hover: {{colors.surface_bright.default.hex}}; + + --spacing: 12px; + --list-item-transition: 0.2s ease; + --unread-bar-transition: 0.2s ease; + --moon-spin-transition: 0.4s ease; + --icon-spin-transition: 1s ease; + --roundness-xl: 22px; + --roundness-l: 20px; + --roundness-m: 16px; + --roundness-s: 12px; + --roundness-xs: 10px; + --roundness-xxs: 8px; + + --discord-icon: none; + --moon-icon: block; + --moon-icon-url: url("https://upload.wikimedia.org/wikipedia/commons/c/c4/Font_Awesome_5_solid_moon.svg"); + --moon-icon-size: auto; + --login-bg-filter: saturate(0.3) hue-rotate(-15deg) brightness(0.4); + --green-to-accent-3-filter: hue-rotate(56deg) saturate(1.43); + --blurple-to-accent-3-filter: hue-rotate(304deg) saturate(0.84) brightness(1.2); +} diff --git a/ryoku/palette-bridge/templates/zen.css b/ryoku/palette-bridge/templates/zen.css new file mode 100644 index 0000000000..e45f1500ed --- /dev/null +++ b/ryoku/palette-bridge/templates/zen.css @@ -0,0 +1,35 @@ +/* Generated by Matugen; live colors come from the Ryoku Zen extension. */ +:root { + --zen-primary-color: var(--toolbar-field-border-color-focus, {{colors.primary.default.hex}}) !important; + --zen-colors-primary: var(--toolbar-field-border-color-focus, {{colors.primary.default.hex}}) !important; + --zen-colors-secondary: var(--lwt-toolbar-field-highlight, {{colors.secondary.default.hex}}) !important; + --zen-colors-tertiary: var(--toolbarbutton-icon-fill-attention, {{colors.tertiary.default.hex}}) !important; + --toolbar-bgcolor: var(--lwt-accent-color, {{colors.surface.default.hex}}) !important; + --toolbar-color: var(--lwt-text-color, {{colors.on_surface.default.hex}}) !important; + --toolbar-field-color: var(--toolbar-field-text-color, {{colors.on_surface.default.hex}}) !important; + --toolbar-field-focus-background-color: var(--toolbar-field-background-color-focus, {{colors.surface_container_high.default.hex}}) !important; + --toolbar-field-focus-color: var(--toolbar-field-text-color-focus, {{colors.on_surface.default.hex}}) !important; + --toolbar-field-focus-border-color: var(--toolbar-field-border-color-focus, {{colors.primary.default.hex}}) !important; + --arrowpanel-background: var(--toolbar-field-background-color, {{colors.surface_container.default.hex}}) !important; + --arrowpanel-color: var(--lwt-text-color, {{colors.on_surface.default.hex}}) !important; + --arrowpanel-border-color: var(--toolbar-field-border-color, {{colors.outline_variant.default.hex}}) !important; + --panel-item-hover-bgcolor: var(--toolbar-field-background-color-focus, {{colors.surface_container_high.default.hex}}) !important; + --button-bgcolor: var(--toolbar-field-background-color, {{colors.surface_container.default.hex}}) !important; + --button-hover-bgcolor: var(--toolbar-field-background-color-focus, {{colors.surface_container_high.default.hex}}) !important; + --button-active-bgcolor: var(--lwt-toolbar-field-highlight, {{colors.primary_container.default.hex}}) !important; + --focus-outline-color: var(--toolbar-field-border-color-focus, {{colors.primary.default.hex}}) !important; +} + +#navigator-toolbox, +#zen-main-app-wrapper, +#zen-sidebar-top-buttons-customization-target, +#zen-sidebar-foot-buttons { + background-color: var(--lwt-accent-color, {{colors.surface.default.hex}}) !important; + color: var(--lwt-text-color, {{colors.on_surface.default.hex}}) !important; +} + +.tabbrowser-tab[selected] .tab-background, +.tabbrowser-tab[multiselected] .tab-background { + background-color: var(--tab-background-color-selected, {{colors.surface_container_high.default.hex}}) !important; + color: var(--lwt-text-color, {{colors.on_surface.default.hex}}) !important; +} diff --git a/ryoku/palette-bridge/vesktop/midnight-ryoku.theme.css b/ryoku/palette-bridge/vesktop/midnight-ryoku.theme.css new file mode 100644 index 0000000000..858e5fc786 --- /dev/null +++ b/ryoku/palette-bridge/vesktop/midnight-ryoku.theme.css @@ -0,0 +1,19 @@ +/** + * @name Midnight Ryoku + * @description Stable Midnight base; Ryoku palette variables live in QuickCSS. + * @author refact0r, Ryoku integration + * @version 1.0.0 + */ +@import url("https://refact0r.github.io/midnight-discord/build/midnight.css"); + +/* These compatibility rules remain loaded while palette variables swap. */ +.selected_f5eb4b, +.selected_f6f816 .link_d8bfb3 { + color: var(--text-0) !important; + background: var(--accent-3) !important; +} + +.selected_f6f816 .link_d8bfb3 * { + color: var(--text-0) !important; + fill: var(--text-0) !important; +} diff --git a/ryoku/palette-bridge/zen-extension/background.js b/ryoku/palette-bridge/zen-extension/background.js new file mode 100644 index 0000000000..c54e9b3b82 --- /dev/null +++ b/ryoku/palette-bridge/zen-extension/background.js @@ -0,0 +1,47 @@ +"use strict"; + +const endpoint = "http://127.0.0.1:47616/v1/events"; +let retry; +let pendingTheme; +let applying = false; +let lastTheme = ""; + +async function applyLatest(theme) { + pendingTheme = theme; + if (applying) return; + applying = true; + try { + while (pendingTheme) { + const next = pendingTheme; + pendingTheme = null; + const signature = JSON.stringify(next); + if (signature === lastTheme) continue; + try { + await browser.theme.update(next); + lastTheme = signature; + } catch (error) { + console.error("[ryoku-zen-palette] rejected palette", error); + } + } + } finally { + applying = false; + } +} + +function connect() { + const events = new EventSource(endpoint); + events.onmessage = async event => { + try { + await applyLatest(paletteToTheme(JSON.parse(event.data))); + } catch (error) { + console.error("[ryoku-zen-palette] rejected palette", error); + } + }; + events.onerror = () => { + events.close(); + clearTimeout(retry); + retry = setTimeout(connect, 1000); + }; +} + +connect(); diff --git a/ryoku/palette-bridge/zen-extension/manifest.json b/ryoku/palette-bridge/zen-extension/manifest.json new file mode 100644 index 0000000000..8756e8d4f2 --- /dev/null +++ b/ryoku/palette-bridge/zen-extension/manifest.json @@ -0,0 +1,17 @@ +{ + "manifest_version": 2, + "name": "Ryoku Zen Palette Bridge", + "version": "1.0.3", + "description": "Keeps Zen browser chrome synchronized with Ryoku's wallpaper palette.", + "permissions": ["theme", "http://127.0.0.1:47616/*"], + "background": {"scripts": ["palette.js", "background.js"]}, + "browser_specific_settings": { + "gecko": { + "id": "ryoku-zen-palette@local", + "strict_min_version": "142.0", + "data_collection_permissions": { + "required": ["none"] + } + } + } +} diff --git a/ryoku/palette-bridge/zen-extension/palette.js b/ryoku/palette-bridge/zen-extension/palette.js new file mode 100644 index 0000000000..99629624a1 --- /dev/null +++ b/ryoku/palette-bridge/zen-extension/palette.js @@ -0,0 +1,38 @@ +"use strict"; + +function paletteToTheme(palette) { + const pick = (role, fallback) => palette[role] || palette[fallback]; + return { colors: { + frame: palette.surface, + tab_background_text: palette.onSurface, + toolbar: palette.surface, + toolbar_text: palette.onSurface, + toolbar_field: pick("surfaceContainer", "surface"), + toolbar_field_text: palette.onSurface, + toolbar_field_focus: pick("surfaceContainerHigh", "surface"), + toolbar_field_text_focus: palette.onSurface, + toolbar_field_border: pick("outlineVariant", "outline"), + toolbar_field_border_focus: palette.primary, + toolbar_field_highlight: pick("primaryContainer", "primary"), + toolbar_field_highlight_text: pick("onPrimaryContainer", "onSurface"), + tab_selected: pick("surfaceContainerHigh", "surface"), + tab_text: palette.onSurface, + tab_line: palette.primary, + popup: pick("surfaceContainer", "surface"), + popup_text: palette.onSurface, + popup_border: pick("outlineVariant", "outline"), + popup_highlight: pick("primaryContainer", "primary"), + popup_highlight_text: pick("onPrimaryContainer", "onSurface"), + sidebar: palette.surface, + sidebar_text: palette.onSurface, + sidebar_highlight: pick("primaryContainer", "primary"), + sidebar_highlight_text: pick("onPrimaryContainer", "onSurface"), + sidebar_border: pick("outlineVariant", "surface"), + icons: palette.onSurface, + icons_attention: pick("tertiary", "primary"), + button_background_hover: pick("surfaceContainerHigh", "surface"), + button_background_active: pick("primaryContainer", "primary") + }}; +} + +if (typeof module !== "undefined") module.exports = { paletteToTheme };