From 443a743af39571fb2efb47434d90e85ada088551 Mon Sep 17 00:00:00 2001 From: carlos Date: Tue, 22 Sep 2026 13:50:04 -0400 Subject: [PATCH] [ryoku] hub: palette bridge settings and package Adds the Palette Bridge page under the wallpaper settings Matugen tab (status, build, service, integration install/remove, doctor), the hub palette-bridge CLI verbs the page calls, the paletteBridgeSource config property, and the ryoku-palette-bridge package that vendors the bridge with -mod=vendor for offline builds. Co-authored-by: rayyan --- .../packages/ryoku-palette-bridge/PKGBUILD | 40 +++ ryoku/hub/backend/main.go | 6 + ryoku/hub/backend/palettebridge.go | 214 ++++++++++++++ ryoku/hub/backend/palettebridge_test.go | 65 +++++ ryoku/palette-bridge/LICENSE | 21 ++ ryoku/palette-bridge/doctor.sh | 77 +++++ ryoku/palette-bridge/go.mod | 3 + ryoku/palette-bridge/install-integrations.sh | 180 ++++++++++++ ryoku/palette-bridge/install.sh | 24 ++ ryoku/palette-bridge/main.go | 271 ++++++++++++++++++ .../systemd/ryoku-palette-bridge.service | 12 + ryoku/palette-bridge/remove-integrations.sh | 94 ++++++ .../spicetify/ryoku-wallpaper-colors.js | 129 +++++++++ .../templates/matugen-apps.example.toml | 10 + .../templates/vesktop-colors.css | 54 ++++ ryoku/palette-bridge/templates/zen.css | 35 +++ .../vesktop/midnight-ryoku.theme.css | 19 ++ .../zen-extension/background.js | 47 +++ .../zen-extension/manifest.json | 17 ++ ryoku/palette-bridge/zen-extension/palette.js | 38 +++ ryoku/shell/CHANGELOG.md | 9 + ryoku/shell/ryogami/wall-ui/qml/Config.qml | 4 + .../wall-ui/qml/wallpaper/SettingsPanel.qml | 3 + .../wallpaper/settings/MatugenSettings.qml | 38 ++- .../settings/MatugenSettings.test.mjs | 26 ++ .../settings/PaletteBridgeSettings.qml | 190 ++++++++++++ .../wall-ui/qml/wallpaper/settings/qmldir | 1 + 27 files changed, 1626 insertions(+), 1 deletion(-) create mode 100644 release/packages/ryoku-palette-bridge/PKGBUILD create mode 100644 ryoku/hub/backend/palettebridge.go create mode 100644 ryoku/hub/backend/palettebridge_test.go create mode 100644 ryoku/palette-bridge/LICENSE create mode 100755 ryoku/palette-bridge/doctor.sh create mode 100644 ryoku/palette-bridge/go.mod create mode 100755 ryoku/palette-bridge/install-integrations.sh create mode 100755 ryoku/palette-bridge/install.sh create mode 100644 ryoku/palette-bridge/main.go create mode 100644 ryoku/palette-bridge/packaging/systemd/ryoku-palette-bridge.service create mode 100755 ryoku/palette-bridge/remove-integrations.sh create mode 100644 ryoku/palette-bridge/spicetify/ryoku-wallpaper-colors.js create mode 100644 ryoku/palette-bridge/templates/matugen-apps.example.toml create mode 100644 ryoku/palette-bridge/templates/vesktop-colors.css create mode 100644 ryoku/palette-bridge/templates/zen.css create mode 100644 ryoku/palette-bridge/vesktop/midnight-ryoku.theme.css create mode 100644 ryoku/palette-bridge/zen-extension/background.js create mode 100644 ryoku/palette-bridge/zen-extension/manifest.json create mode 100644 ryoku/palette-bridge/zen-extension/palette.js create mode 100644 ryoku/shell/ryogami/wall-ui/qml/wallpaper/settings/MatugenSettings.test.mjs create mode 100644 ryoku/shell/ryogami/wall-ui/qml/wallpaper/settings/PaletteBridgeSettings.qml create mode 100644 ryoku/shell/ryogami/wall-ui/qml/wallpaper/settings/qmldir diff --git a/release/packages/ryoku-palette-bridge/PKGBUILD b/release/packages/ryoku-palette-bridge/PKGBUILD new file mode 100644 index 0000000000..35f5e6a5bb --- /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 -mod=vendor -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/hub/backend/main.go b/ryoku/hub/backend/main.go index 79da09de0f..366461c280 100644 --- a/ryoku/hub/backend/main.go +++ b/ryoku/hub/backend/main.go @@ -115,6 +115,11 @@ func main() { fmt.Fprintln(os.Stderr, "ryoku-hub:", err) os.Exit(1) } + case "palette-bridge": + if err := runPaletteBridge(args[1:]); err != nil { + fmt.Fprintln(os.Stderr, "ryoku-hub:", err) + os.Exit(1) + } default: usage() os.Exit(2) @@ -184,6 +189,7 @@ func usage() { fmt.Fprintln(os.Stderr, " ryoku-hub voxtype get|ensure") fmt.Fprintln(os.Stderr, " ryoku-hub voxtype set ") fmt.Fprintln(os.Stderr, " ryoku-hub voxtype download|rmmodel ") + fmt.Fprintln(os.Stderr, " ryoku-hub palette-bridge status|install|service|integration|doctor []") fmt.Fprintln(os.Stderr, " ryoku-hub rice list|preflight|capture|apply|restore|save|fork|delete|import|publish|setwall|files|export") fmt.Fprintln(os.Stderr, " ryoku-hub fastfetch get|preview ") fmt.Fprintln(os.Stderr, " ryoku-hub fastfetch save ") diff --git a/ryoku/hub/backend/palettebridge.go b/ryoku/hub/backend/palettebridge.go new file mode 100644 index 0000000000..845f87f7d8 --- /dev/null +++ b/ryoku/hub/backend/palettebridge.go @@ -0,0 +1,214 @@ +package main + +import ( + "fmt" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +const paletteBridgeUnit = "ryoku-palette-bridge.service" + +var paletteBridgeRun = func(name string, args ...string) ([]byte, error) { + return exec.Command(name, args...).CombinedOutput() +} + +type paletteBridgeIntegration struct { + ID string `json:"id"` + Name string `json:"name"` + Installed bool `json:"installed"` + Managed bool `json:"managed"` +} + +type paletteBridgeStatus struct { + Contract int `json:"contract"` + Available bool `json:"available"` + SourceReady bool `json:"sourceReady"` + Installed bool `json:"installed"` + Enabled bool `json:"enabled"` + Active bool `json:"active"` + Healthy bool `json:"healthy"` + Endpoint string `json:"endpoint"` + Version string `json:"version"` + Integrations []paletteBridgeIntegration `json:"integrations"` +} + +func paletteBridgeHome() string { + if h := os.Getenv("HOME"); h != "" { + return h + } + h, _ := os.UserHomeDir() + return h +} + +func paletteBridgeConfigRoot() string { + if d := os.Getenv("XDG_CONFIG_HOME"); d != "" { + return d + } + return filepath.Join(paletteBridgeHome(), ".config") +} + +func paletteBridgeSource(source string, files ...string) (string, error) { + if source == "" { + source = "/usr/share/ryoku/palette-bridge" + } + abs, err := filepath.Abs(source) + if err != nil { + return "", err + } + info, err := os.Stat(abs) + if err != nil || !info.IsDir() { + return "", fmt.Errorf("palette bridge source is not a directory: %s", abs) + } + for _, file := range files { + info, err = os.Stat(filepath.Join(abs, file)) + if err != nil || info.IsDir() { + return "", fmt.Errorf("palette bridge source is missing %s", file) + } + } + return abs, nil +} + +func paletteBridgeCommand(path string, args ...string) error { + out, err := paletteBridgeRun(path, args...) + if err != nil { + msg := strings.TrimSpace(string(out)) + if msg == "" { + msg = err.Error() + } + return fmt.Errorf("%s", msg) + } + if len(out) > 0 { + fmt.Print(string(out)) + } + return nil +} + +func paletteBridgeUnitState(verb string) bool { + _, err := paletteBridgeRun("systemctl", "--user", verb, "--quiet", paletteBridgeUnit) + return err == nil +} + +func paletteBridgeManagedFiles() map[string]bool { + state := os.Getenv("XDG_STATE_HOME") + if state == "" { + state = filepath.Join(paletteBridgeHome(), ".local", "state") + } + b, err := os.ReadFile(filepath.Join(state, "ryoku", "palette-bridge", "owned-files.tsv")) + if err != nil { + return map[string]bool{} + } + managed := map[string]bool{} + for _, line := range strings.Split(string(b), "\n") { + if fields := strings.SplitN(line, "\t", 2); len(fields) == 2 { + managed[fields[0]+"\t"+filepath.Clean(fields[1])] = true + } + } + return managed +} + +func paletteBridgeStatusFor(source string) paletteBridgeStatus { + config := paletteBridgeConfigRoot() + managed := paletteBridgeManagedFiles() + spotify := filepath.Join(config, "spicetify", "Extensions", "ryoku-wallpaper-colors.js") + vesktop := filepath.Join(config, "vesktop", "themes", "midnight-ryoku.theme.css") + zen := filepath.Join(config, "ryoku", "user_edits", "matugen", "templates", "zen.css") + _, binErr := exec.LookPath("ryoku-palette-bridge") + _, sourceErr := paletteBridgeSource(source, "install.sh", "install-integrations.sh") + client := http.Client{Timeout: 350 * time.Millisecond} + resp, healthErr := client.Get("http://127.0.0.1:47616/healthz") + healthy := healthErr == nil && resp.StatusCode >= 200 && resp.StatusCode < 300 + if resp != nil { + _ = resp.Body.Close() + } + return paletteBridgeStatus{ + Contract: 1, Available: binErr == nil || sourceErr == nil, SourceReady: sourceErr == nil, + Installed: binErr == nil, Enabled: paletteBridgeUnitState("is-enabled"), + Active: paletteBridgeUnitState("is-active"), Healthy: healthy, + Endpoint: "http://127.0.0.1:47616", Version: "1", + Integrations: []paletteBridgeIntegration{ + {ID: "spotify", Name: "Spotify", Installed: fileExists(spotify), Managed: managed["spotify\t"+spotify]}, + {ID: "vesktop", Name: "Vesktop", Installed: fileExists(vesktop), Managed: managed["vesktop\t"+vesktop]}, + {ID: "zen", Name: "Zen Browser", Installed: fileExists(zen), Managed: paletteBridgeZenManaged(managed)}, + }, + } +} + +func paletteBridgeZenManaged(managed map[string]bool) bool { + // install-integrations.sh records three Zen-owned paths per integration + // (the template, the profile userChrome.css, and its user.js), so the + // template path alone is not enough to decide whether the bridge manages + // the Zen integration. + for k := range managed { + if strings.HasPrefix(k, "zen\t") { + return true + } + } + return false +} + +func paletteBridgeIntegrationID(id string) bool { + return id == "spotify" || id == "vesktop" || id == "zen" +} + +func runPaletteBridge(args []string) error { + if len(args) == 0 || args[0] == "status" { + source := "" + if len(args) > 1 { + source = args[1] + } + return printJSON(paletteBridgeStatusFor(source)) + } + switch args[0] { + case "install": + if len(args) != 2 { + return fmt.Errorf("palette-bridge install requires a source directory") + } + source, err := paletteBridgeSource(args[1], "install.sh") + if err != nil { + return err + } + return paletteBridgeCommand(filepath.Join(source, "install.sh")) + case "service": + if len(args) != 2 || (args[1] != "enable" && args[1] != "disable" && args[1] != "restart") { + return fmt.Errorf("palette-bridge service requires enable, disable, or restart") + } + verb := args[1] + if verb == "enable" || verb == "disable" { + return paletteBridgeCommand("systemctl", "--user", verb, "--now", paletteBridgeUnit) + } + return paletteBridgeCommand("systemctl", "--user", "restart", paletteBridgeUnit) + case "doctor": + doctor, lookupErr := exec.LookPath("ryoku-palette-bridge-doctor") + if lookupErr != nil { + source := "" + if len(args) > 1 { + source = args[1] + } + resolved, err := paletteBridgeSource(source, "doctor.sh") + if err != nil { + return err + } + doctor = filepath.Join(resolved, "doctor.sh") + } + return paletteBridgeCommand(doctor) + case "integration": + if len(args) != 4 || (args[1] != "install" && args[1] != "remove") || !paletteBridgeIntegrationID(args[2]) { + return fmt.Errorf("palette-bridge integration requires install|remove ") + } + script := "install-integrations.sh" + if args[1] == "remove" { + script = "remove-integrations.sh" + } + source, err := paletteBridgeSource(args[3], script) + if err != nil { + return err + } + return paletteBridgeCommand(filepath.Join(source, script), "--"+args[2]) + default: + return fmt.Errorf("unknown palette-bridge subcommand: %s", args[0]) + } +} diff --git a/ryoku/hub/backend/palettebridge_test.go b/ryoku/hub/backend/palettebridge_test.go new file mode 100644 index 0000000000..36bf41ae91 --- /dev/null +++ b/ryoku/hub/backend/palettebridge_test.go @@ -0,0 +1,65 @@ +package main + +import ( + "errors" + "os" + "path/filepath" + "reflect" + "testing" +) + +func makePaletteBridgeSource(t *testing.T, names ...string) string { + t.Helper() + dir := t.TempDir() + for _, name := range names { + if err := os.WriteFile(filepath.Join(dir, name), []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + } + return dir +} + +func TestPaletteBridgeSourceRequiresRecipe(t *testing.T) { + dir := makePaletteBridgeSource(t, "install.sh") + if _, err := paletteBridgeSource(dir, "install.sh"); err != nil { + t.Fatalf("valid recipe: %v", err) + } + if _, err := paletteBridgeSource(dir, "remove-integrations.sh"); err == nil { + t.Fatal("source without the requested recipe was accepted") + } +} + +func TestPaletteBridgeInstallUsesTypedCommand(t *testing.T) { + source := makePaletteBridgeSource(t, "install.sh") + original := paletteBridgeRun + t.Cleanup(func() { paletteBridgeRun = original }) + var got []string + paletteBridgeRun = func(name string, args ...string) ([]byte, error) { + got = append([]string{name}, args...) + return nil, nil + } + if err := runPaletteBridge([]string{"install", source}); err != nil { + t.Fatal(err) + } + want := []string{filepath.Join(source, "install.sh")} + if !reflect.DeepEqual(got, want) { + t.Fatalf("command = %q, want %q", got, want) + } +} + +func TestPaletteBridgeRejectsUnknownIntegration(t *testing.T) { + if err := runPaletteBridge([]string{"integration", "install", "browser", t.TempDir()}); err == nil { + t.Fatal("unknown integration was accepted") + } +} + +func TestPaletteBridgeCommandReturnsOutput(t *testing.T) { + original := paletteBridgeRun + t.Cleanup(func() { paletteBridgeRun = original }) + paletteBridgeRun = func(string, ...string) ([]byte, error) { + return []byte("specific failure\n"), errors.New("exit status 1") + } + if got := paletteBridgeCommand("helper"); got == nil || got.Error() != "specific failure" { + t.Fatalf("error = %v", got) + } +} 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..6a47fccefa --- /dev/null +++ b/ryoku/palette-bridge/install.sh @@ -0,0 +1,24 @@ +#!/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" +# The packaged unit runs /usr/bin/ryoku-palette-bridge; this script builds into +# $bin_dir, so point ExecStart at the binary actually produced here. +sed "s|^ExecStart=.*|ExecStart=$bin_dir/ryoku-palette-bridge|" \ + "$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 }; diff --git a/ryoku/shell/CHANGELOG.md b/ryoku/shell/CHANGELOG.md index efde7969e1..942ef96978 100644 --- a/ryoku/shell/CHANGELOG.md +++ b/ryoku/shell/CHANGELOG.md @@ -4,6 +4,15 @@ ### Added +- **Palette Bridge rides the wallpaper palette to your apps.** A small local + event server publishes the current matugen palette over HTTP so Spicetify, + Vesktop and Zen recolour with the wallpaper. The wallpaper settings' Matugen + tab grew a Palette Bridge page to build it, run it as a user service, and + install or remove each integration; the ryoku-palette-bridge package ships + the source tree and the unit + (`ryoku/palette-bridge/`, `ryoku/hub/backend/palettebridge.go`, + `settings/PaletteBridgeSettings.qml`). + - **The Super+K cheatsheet reads the shared legend.** Short labels with the hint on hover or while searching, a "not here" tag on a shortcut the running compositor cannot do (the reason on hover), caps wide enough for "Num 1" and diff --git a/ryoku/shell/ryogami/wall-ui/qml/Config.qml b/ryoku/shell/ryogami/wall-ui/qml/Config.qml index 4f8fbe055d..a97d3f5d43 100644 --- a/ryoku/shell/ryogami/wall-ui/qml/Config.qml +++ b/ryoku/shell/ryogami/wall-ui/qml/Config.qml @@ -243,6 +243,10 @@ QtObject { onFileChanged: { reload(); config._reparseMatugenKnobs() } } + // Where the Palette Bridge source tree lives for build/doctor actions; the + // packaged default is the read-only data dir, overridable in config.json. + readonly property string paletteBridgeSource: _resolve(_data.paletteBridgeSource ?? "/usr/share/ryoku/palette-bridge") + readonly property string matugenScheme: _matugenKnobs.schemeType || (_data.matugen && _data.matugen.schemeType) || "scheme-fidelity" readonly property string matugenMode: _matugenKnobs.mode || (_data.matugen && _data.matugen.mode) || "dark" // The hub's source index runs 0..4 and the picker offers all five, so the diff --git a/ryoku/shell/ryogami/wall-ui/qml/wallpaper/SettingsPanel.qml b/ryoku/shell/ryogami/wall-ui/qml/wallpaper/SettingsPanel.qml index 3ae0a553e7..f3db849317 100644 --- a/ryoku/shell/ryogami/wall-ui/qml/wallpaper/SettingsPanel.qml +++ b/ryoku/shell/ryogami/wall-ui/qml/wallpaper/SettingsPanel.qml @@ -547,6 +547,9 @@ Item { item.colors = Qt.binding(function() { return settingsPanel.colors }) item.saveConfigKey = function(k, v) { settingsPanel._saveConfigKey(k, v) } item.cloneIntegrations = function() { return settingsPanel._cloneIntegrations() } + item.notify = function(message, success) { + if (!success) settingsPanel._showWarning(I18n.tr("Palette Bridge"), message) + } } } } diff --git a/ryoku/shell/ryogami/wall-ui/qml/wallpaper/settings/MatugenSettings.qml b/ryoku/shell/ryogami/wall-ui/qml/wallpaper/settings/MatugenSettings.qml index 994c7e475e..ac761846e6 100644 --- a/ryoku/shell/ryogami/wall-ui/qml/wallpaper/settings/MatugenSettings.qml +++ b/ryoku/shell/ryogami/wall-ui/qml/wallpaper/settings/MatugenSettings.qml @@ -1,6 +1,7 @@ import QtQuick import Quickshell import Quickshell.Io +import ".." import "../.." import "../../components" import Ryoku.Ui.Singletons @@ -10,6 +11,8 @@ Column { property var colors property var saveConfigKey property var cloneIntegrations + property var notify + property string activePage: "matugen" property var _templates: ({}) readonly property var _apps: Object.keys(root._templates).sort() @@ -27,9 +30,33 @@ Column { } width: parent ? parent.width : 0 - spacing: 8 + spacing: 12 + + Row { + anchors.horizontalCenter: parent.horizontalCenter + spacing: Style.spacingSmall + + FilterButton { + colors: root.colors + label: I18n.tr("MATUGEN") + register: false + height: 28 + isActive: root.activePage === "matugen" + onClicked: root.activePage = "matugen" + } + + FilterButton { + colors: root.colors + label: I18n.tr("PALETTE BRIDGE") + register: false + height: 28 + isActive: root.activePage === "bridge" + onClicked: root.activePage = "bridge" + } + } SettingsCard { + visible: root.activePage === "matugen" colors: root.colors title: I18n.tr("External Matugen") subtitle: I18n.tr("Run Matugen alongside Ryogami-wall's internal configuration.") @@ -54,6 +81,7 @@ Column { } SettingsCard { + visible: root.activePage === "matugen" colors: root.colors title: I18n.tr("Integrations") subtitle: I18n.tr("Each entry generates themed output from a template and optionally runs a reload command.") @@ -104,6 +132,7 @@ Column { } SettingsCard { + visible: root.activePage === "matugen" colors: root.colors title: I18n.tr("App templates") subtitle: I18n.tr("Recolour each app's config from the generated palette.") @@ -135,4 +164,11 @@ Column { } } } + + PaletteBridgeSettings { + visible: root.activePage === "bridge" + colors: root.colors + saveConfigKey: root.saveConfigKey + notify: root.notify + } } diff --git a/ryoku/shell/ryogami/wall-ui/qml/wallpaper/settings/MatugenSettings.test.mjs b/ryoku/shell/ryogami/wall-ui/qml/wallpaper/settings/MatugenSettings.test.mjs new file mode 100644 index 0000000000..1dddd23ac4 --- /dev/null +++ b/ryoku/shell/ryogami/wall-ui/qml/wallpaper/settings/MatugenSettings.test.mjs @@ -0,0 +1,26 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; + +const source = readFileSync(new URL("./MatugenSettings.qml", import.meta.url), "utf8"); +const panel = readFileSync(new URL("../SettingsPanel.qml", import.meta.url), "utf8"); +const moduleTypes = readFileSync(new URL("./qmldir", import.meta.url), "utf8"); + +assert.match(source, /Column\s*{/, + "Matugen must keep the known-working settings page root"); +assert.doesNotMatch(panel, /id:\s*matugenViewport/, + "Matugen subpages must not be buried in a scrolling parent"); +assert.match(source, /property string activePage:\s*"matugen"/, + "Matugen must open on its original controls"); +assert.match(source, /label:\s*I18n\.tr\("MATUGEN"\)[\s\S]*label:\s*I18n\.tr\("PALETTE BRIDGE"\)/, + "Matugen must expose a second row of subpages"); +assert.match(panel, /activeTab === "performance" \|\| settingsPanel\.activeTab === "matugen"/, + "Matugen must use the wide settings layout"); + +const external = source.indexOf('title: I18n.tr("External Matugen")'); +const bridge = source.indexOf("PaletteBridgeSettings {"); +assert.ok(external >= 0 && bridge > external, + "existing Matugen controls must remain visible before Palette Bridge controls"); +assert.match(moduleTypes, /^PaletteBridgeSettings 1\.0 PaletteBridgeSettings\.qml$/m, + "PaletteBridgeSettings must be registered so Matugen can instantiate it"); + +console.log("PASS: Matugen and Palette Bridge render as tall sibling subpages"); diff --git a/ryoku/shell/ryogami/wall-ui/qml/wallpaper/settings/PaletteBridgeSettings.qml b/ryoku/shell/ryogami/wall-ui/qml/wallpaper/settings/PaletteBridgeSettings.qml new file mode 100644 index 0000000000..7917334123 --- /dev/null +++ b/ryoku/shell/ryogami/wall-ui/qml/wallpaper/settings/PaletteBridgeSettings.qml @@ -0,0 +1,190 @@ +import QtQuick +import Quickshell +import Quickshell.Io +import "../.." +import "../../components" +import Ryoku.Ui.Singletons + +// Settings for the optional Ryoku Palette Bridge. The backend handles validation +// and service policy; this page displays its status and sends actions. +Column { + id: root + property var colors + property var saveConfigKey + property var notify + property var status: ({ contract: 1, integrations: [] }) + property bool busy: false + property string message: "" + property string pendingRemove: "" + property string _statusBuf: "" + property string _actionBuf: "" + + width: parent ? parent.width : 0 + spacing: 8 + + Component.onCompleted: refresh() + + function refresh() { + if (!statusProc.running) { + root._statusBuf = "" + statusProc.command = ["ryoku-hub", "palette-bridge", "status", Config.paletteBridgeSource] + statusProc.running = true + } + } + + function act(args, label) { + if (root.busy) return + root.busy = true + root.message = label + "…" + root._actionBuf = "" + actionProc.command = ["ryoku-hub", "palette-bridge"].concat(args) + actionProc.running = true + } + + function integration(id) { + var rows = root.status.integrations || [] + for (var i = 0; i < rows.length; i++) if (rows[i].id === id) return rows[i] + return ({ id: id, installed: false }) + } + + function toggleIntegration(id) { + if (integration(id).installed && integration(id).managed) { + if (root.pendingRemove !== id) { + root.pendingRemove = id + root.message = I18n.tr("Select remove again to confirm. Only bridge-owned files will be removed.") + confirmTimer.restart() + return + } + root.pendingRemove = "" + act(["integration", "remove", id, Config.paletteBridgeSource], I18n.tr("Removing integration")) + return + } + act(["integration", "install", id, Config.paletteBridgeSource], I18n.tr("Installing integration")) + } + + Timer { id: refreshTimer; interval: 500; onTriggered: root.refresh() } + Timer { id: confirmTimer; interval: 6000; onTriggered: root.pendingRemove = "" } + + Process { + id: statusProc + stdout: SplitParser { splitMarker: ""; onRead: function(data) { root._statusBuf += data } } + onExited: function(code) { + if (code === 0) { + try { root.status = JSON.parse(root._statusBuf) } + catch (e) { root.message = I18n.tr("Bridge returned invalid status.") } + } else { + root.status = ({ contract: 1, available: false, integrations: [] }) + } + root._statusBuf = "" + } + } + + Process { + id: actionProc + stdout: SplitParser { splitMarker: ""; onRead: function(data) { root._actionBuf += data } } + stderr: SplitParser { splitMarker: ""; onRead: function(data) { root._actionBuf += data } } + onExited: function(code) { + root.busy = false + var detail = root._actionBuf.trim() + root.message = code === 0 ? (detail || I18n.tr("Palette Bridge updated.")) + : (detail || I18n.tr("Palette Bridge action failed.")) + if (root.notify) root.notify(root.message, code === 0) + root._actionBuf = "" + refreshTimer.restart() + } + } + + SettingsCard { + colors: root.colors + title: I18n.tr("Ryoku Palette Bridge") + subtitle: I18n.tr("Live Ryoku wallpaper colours for Zen, Spotify, and Vesktop.") + + RowTextInput { + colors: root.colors + title: I18n.tr("Build source") + description: I18n.tr("Use a local checkout, or Ryoku's packaged source.") + value: Config.paletteBridgeSource + placeholder: "/usr/share/ryoku/palette-bridge" + onCommit: function(v) { + if (root.saveConfigKey) root.saveConfigKey("paletteBridgeSource", v) + root.refresh() + } + } + + RowAction { + colors: root.colors + title: root.status.installed ? I18n.tr("Build and update") : I18n.tr("Build and install") + description: root.status.sourceReady + ? I18n.tr("Build the selected local source and install the user service.") + : I18n.tr("Select a source containing the Palette Bridge build recipe.") + enabled: !root.busy && !!root.status.sourceReady + opacity: enabled ? 1 : 0.45 + onClicked: root.act(["install", Config.paletteBridgeSource], I18n.tr("Building Palette Bridge")) + } + + RowToggle { + colors: root.colors + title: root.status.active ? I18n.tr("Bridge running") : I18n.tr("Enable Palette Bridge") + description: I18n.tr("Start at login and publish wallpaper colours on the local endpoint.") + checked: !!root.status.enabled + enabled: !root.busy && !!root.status.installed + opacity: enabled ? 1 : 0.45 + onToggle: function(on) { root.act(["service", on ? "enable" : "disable"], on ? I18n.tr("Starting Palette Bridge") : I18n.tr("Stopping Palette Bridge")) } + } + + RowAction { + colors: root.colors + title: I18n.tr("Restart service") + description: I18n.tr("Restart the bridge without changing its login setting.") + enabled: !root.busy && !!root.status.installed + opacity: enabled ? 1 : 0.45 + onClicked: root.act(["service", "restart"], I18n.tr("Restarting Palette Bridge")) + } + + RowAction { + colors: root.colors + title: root.status.healthy ? I18n.tr("Health check passed") : I18n.tr("Run health check") + description: root.message || I18n.tr("%1 · contract v%2").arg(root.status.endpoint || "127.0.0.1:47616").arg(root.status.contract || 1) + valueLabel: root.status.active ? I18n.tr("RUNNING") : I18n.tr("STOPPED") + enabled: !root.busy && (!!root.status.installed || !!root.status.sourceReady) + opacity: enabled ? 1 : 0.45 + onClicked: root.act(["doctor", Config.paletteBridgeSource], I18n.tr("Checking Palette Bridge")) + } + } + + SettingsCard { + colors: root.colors + title: I18n.tr("App integrations") + subtitle: I18n.tr("Each app is opt-in. Removal preserves files the bridge does not own.") + + RowAction { + colors: root.colors + title: I18n.tr("Spotify") + description: I18n.tr("Spicetify extension for live palette updates without stopping playback.") + valueLabel: root.pendingRemove === "spotify" ? I18n.tr("CONFIRM REMOVE") : (root.integration("spotify").installed && root.integration("spotify").managed ? I18n.tr("REMOVE") : I18n.tr("SET UP")) + enabled: !root.busy && !!root.status.sourceReady + opacity: enabled ? 1 : 0.45 + onClicked: root.toggleIntegration("spotify") + } + + RowAction { + colors: root.colors + title: I18n.tr("Vesktop") + description: I18n.tr("Midnight base theme and QuickCSS palette without an app reload.") + valueLabel: root.pendingRemove === "vesktop" ? I18n.tr("CONFIRM REMOVE") : (root.integration("vesktop").installed && root.integration("vesktop").managed ? I18n.tr("REMOVE") : I18n.tr("SET UP")) + enabled: !root.busy && !!root.status.sourceReady + opacity: enabled ? 1 : 0.45 + onClicked: root.toggleIntegration("vesktop") + } + + RowAction { + colors: root.colors + title: I18n.tr("Zen Browser") + description: I18n.tr("Native browser theme updates with a Matugen startup fallback.") + valueLabel: root.pendingRemove === "zen" ? I18n.tr("CONFIRM REMOVE") : (root.integration("zen").installed && root.integration("zen").managed ? I18n.tr("REMOVE") : I18n.tr("SET UP")) + enabled: !root.busy && !!root.status.sourceReady + opacity: enabled ? 1 : 0.45 + onClicked: root.toggleIntegration("zen") + } + } +} diff --git a/ryoku/shell/ryogami/wall-ui/qml/wallpaper/settings/qmldir b/ryoku/shell/ryogami/wall-ui/qml/wallpaper/settings/qmldir new file mode 100644 index 0000000000..49e7aa0469 --- /dev/null +++ b/ryoku/shell/ryogami/wall-ui/qml/wallpaper/settings/qmldir @@ -0,0 +1 @@ +PaletteBridgeSettings 1.0 PaletteBridgeSettings.qml