Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions release/packages/ryoku-palette-bridge/PKGBUILD
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Maintainer: Ryoku <[email protected]>
#
# 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"
}
6 changes: 6 additions & 0 deletions ryoku/hub/backend/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -184,6 +189,7 @@ func usage() {
fmt.Fprintln(os.Stderr, " ryoku-hub voxtype get|ensure")
fmt.Fprintln(os.Stderr, " ryoku-hub voxtype set <json>")
fmt.Fprintln(os.Stderr, " ryoku-hub voxtype download|rmmodel <key>")
fmt.Fprintln(os.Stderr, " ryoku-hub palette-bridge status|install|service|integration|doctor [<source>]")
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 <json>")
fmt.Fprintln(os.Stderr, " ryoku-hub fastfetch save <json>")
Expand Down
214 changes: 214 additions & 0 deletions ryoku/hub/backend/palettebridge.go
Original file line number Diff line number Diff line change
@@ -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 <spotify|vesktop|zen> <source>")
}
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])
}
}
65 changes: 65 additions & 0 deletions ryoku/hub/backend/palettebridge_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
21 changes: 21 additions & 0 deletions ryoku/palette-bridge/LICENSE
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading