Skip to content
Closed
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 -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"
}
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)
}
}
Loading
Loading