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/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/i18n/catalog/en.json b/ryoku/i18n/catalog/en.json index 97acb8ead9..7196119169 100644 --- a/ryoku/i18n/catalog/en.json +++ b/ryoku/i18n/catalog/en.json @@ -312,6 +312,7 @@ "App Launcher": "App Launcher", "App Menu": "App Menu", "App Overrides": "App Overrides", + "App integrations": "App integrations", "App launcher & command palette": "App launcher & command palette", "App overrides": "App overrides", "App shortcuts": "App shortcuts", @@ -512,6 +513,8 @@ "Bound to a dedicated key": "Bound to a dedicated key", "Brain_Shell": "Brain_Shell", "Breakdown": "Breakdown", + "Bridge returned invalid status.": "Bridge returned invalid status.", + "Bridge running": "Bridge running", "Briefly flashes, bounces, or slides a window when it gains focus. Applies on Save.": "Briefly flashes, bounces, or slides a window when it gains focus. Applies on Save.", "Brightness": "Brightness", "Brightness of non-HDR content while in HDR": "Brightness of non-HDR content while in HDR", @@ -529,7 +532,12 @@ "Browse…": "Browse…", "Build a machine from an ISO on disk. quickemu picks tuned defaults; set cores, memory and the display after it's created.": "Build a machine from an ISO on disk. quickemu picks tuned defaults; set cores, memory and the display after it's created.", "Build a machine from any ISO on disk: off-catalogue, the full QEMU reach. Fill in the sheet on the right.": "Build a machine from any ISO on disk: off-catalogue, the full QEMU reach. Fill in the sheet on the right.", + "Build and install": "Build and install", + "Build and update": "Build and update", + "Build source": "Build source", + "Build the selected local source and install the user service.": "Build the selected local source and install the user service.", "Building %1": "Building %1", + "Building Palette Bridge": "Building Palette Bridge", "Building the AUR extras": "Building the AUR extras", "Building the Ryoku desktop from source": "Building the Ryoku desktop from source", "Built-in": "Built-in", @@ -573,6 +581,7 @@ "COMPOSITOR": "COMPOSITOR", "CONFIRM": "CONFIRM", "CONFIRM - START FRESH": "CONFIRM - START FRESH", + "CONFIRM REMOVE": "CONFIRM REMOVE", "CONFIRM RESET": "CONFIRM RESET", "CONFIRM · REMOVE ALL": "CONFIRM · REMOVE ALL", "CONNECT": "CONNECT", @@ -657,6 +666,7 @@ "Chat": "Chat", "Check the terminal for details, then try again.": "Check the terminal for details, then try again.", "Checked, installed, then placed on the bar": "Checked, installed, then placed on the bar", + "Checking Palette Bridge": "Checking Palette Bridge", "Checking for updates...": "Checking for updates...", "Checking…": "Checking…", "Choose": "Choose", @@ -1175,6 +1185,7 @@ "EXTERNAL": "EXTERNAL", "EXTRA PACKAGES": "EXTRA PACKAGES", "Each add-on defines its own; applied live": "Each add-on defines its own; applied live", + "Each app is opt-in. Removal preserves files the bridge does not own.": "Each app is opt-in. Removal preserves files the bridge does not own.", "Each combo your config wants that Ryoku already uses. Keep Ryoku's shortcut, use yours (Ryoku unbinds its own so yours actually wins), or remap yours to a free chord. Untouched rows keep Ryoku's.": "Each combo your config wants that Ryoku already uses. Keep Ryoku's shortcut, use yours (Ryoku unbinds its own so yours actually wins), or remap yours to a free chord. Untouched rows keep Ryoku's.", "Each entry generates themed output from a template and optionally runs a reload command.": "Each entry generates themed output from a template and optionally runs a reload command.", "Each page's settings live in one full file below: the complete config you edit in place (the GUI writes the very same file). The shipped base underneath refreshes on update; your file wins. Run `ryoku reset ` to drop an override.": "Each page's settings live in one full file below: the complete config you edit in place (the GUI writes the very same file). The shipped base underneath refreshes on update; your file wins. Run `ryoku reset ` to drop an override.", @@ -1199,6 +1210,7 @@ "Empty": "Empty", "Empty locates by IP": "Empty locates by IP", "Emulate middle click": "Emulate middle click", + "Enable Palette Bridge": "Enable Palette Bridge", "Enable device lighting": "Enable device lighting", "Enable passthrough": "Enable passthrough", "Enable snapshot helpers?": "Enable snapshot helpers?", @@ -1557,6 +1569,7 @@ "Healing the system": "Healing the system", "Health": "Health", "Health (%1)": "Health (%1)", + "Health check passed": "Health check passed", "Heat Melt": "Heat Melt", "Heavy tools reinstall on every disposable boot (~a minute). For a fast throwaway with these baked in, make this a keeper, then “Save as template” in its detail pane and spawn clones: tools baked, boot in seconds.": "Heavy tools reinstall on every disposable boot (~a minute). For a fast throwaway with these baked in, make this a keeper, then “Save as template” in its detail pane and spawn clones: tools baked, boot in seconds.", "Height": "Height", @@ -1764,6 +1777,7 @@ "Installed plugins and bundles. RyoStore installs new ones.": "Installed plugins and bundles. RyoStore installs new ones.", "Installing Ryoku": "Installing Ryoku", "Installing installer tools (git, build tools)": "Installing installer tools (git, build tools)", + "Installing integration": "Installing integration", "Installing the Limine bootloader": "Installing the Limine bootloader", "Installing the Ryoku desktop": "Installing the Ryoku desktop", "Installing the base system": "Installing the base system", @@ -1952,6 +1966,7 @@ "Listening at the lock screen · currently switched off": "Listening at the lock screen · currently switched off", "Listening…": "Listening…", "Live": "Live", + "Live Ryoku wallpaper colours for Zen, Spotify, and Vesktop.": "Live Ryoku wallpaper colours for Zen, Spotify, and Vesktop.", "Live wallpapers": "Live wallpapers", "Live: %t": "Live: %t", "Load 1 · 5 · 15": "Load 1 · 5 · 15", @@ -2067,6 +2082,7 @@ "Mic": "Mic", "Microphone": "Microphone", "Middle-click pastes": "Middle-click pastes", + "Midnight base theme and QuickCSS palette without an app reload.": "Midnight base theme and QuickCSS palette without an app reload.", "Minimal": "Minimal", "Minimalist": "Minimalist", "Minimum size": "Minimum size", @@ -2177,6 +2193,7 @@ "Name shown in this list; nothing is stored": "Name shown in this list; nothing is stored", "Name this preset…": "Name this preset…", "Name, then Enter": "Name, then Enter", + "Native browser theme updates with a Matugen startup fallback.": "Native browser theme updates with a Matugen startup fallback.", "Natural scroll": "Natural scroll", "Natural swipe direction": "Natural swipe direction", "Nature": "Nature", @@ -2496,6 +2513,7 @@ "PADDING": "PADDING", "PAGE %1": "PAGE %1", "PALETTE": "PALETTE", + "PALETTE BRIDGE": "PALETTE BRIDGE", "PAPER": "PAPER", "PASSTHROUGH": "PASSTHROUGH", "PASSTHROUGH_OFFLINE": "PASSTHROUGH_OFFLINE", @@ -2567,6 +2585,9 @@ "Pairing failed": "Pairing failed", "Pairing…": "Pairing…", "Palette": "Palette", + "Palette Bridge": "Palette Bridge", + "Palette Bridge action failed.": "Palette Bridge action failed.", + "Palette Bridge updated.": "Palette Bridge updated.", "Palette follows the wallpaper; mono stays grey": "Palette follows the wallpaper; mono stays grey", "Palette frame": "Palette frame", "Palette used when auto-recolouring new wallpapers.": "Palette used when auto-recolouring new wallpapers.", @@ -2943,6 +2964,7 @@ "Removing %1": "Removing %1", "Removing %s: frees %d packages (%s) and drops its session entry; switching back later reinstalls them.": "Removing %s: frees %d packages (%s) and drops its session entry; switching back later reinstalls them.", "Removing a stale pacman lock (no pacman running)": "Removing a stale pacman lock (no pacman running)", + "Removing integration": "Removing integration", "Renaming to %1": "Renaming to %1", "Render the current wallpaper (optionally blurred) as the backdrop visible in the compositor's overview.": "Render the current wallpaper (optionally blurred) as the backdrop visible in the compositor's overview.", "Rendering": "Rendering", @@ -2967,6 +2989,9 @@ "Resolution": "Resolution", "Resolve": "Resolve", "Resources": "Resources", + "Restart service": "Restart service", + "Restart the bridge without changing its login setting.": "Restart the bridge without changing its login setting.", + "Restarting Palette Bridge": "Restarting Palette Bridge", "Restore the shipped order and visibility.": "Restore the shipped order and visibility.", "Restore wallpaper on startup": "Restore wallpaper on startup", "Restoring snapshot": "Restoring snapshot", @@ -3018,6 +3043,7 @@ "Run a probe to read uptime, load, memory and disk. A key-authenticated login is needed for the full reading; use COPY KEY to deploy one.": "Run a probe to read uptime, load, memory and disk. A key-authenticated login is needed for the full reading; use COPY KEY to deploy one.", "Run by the device itself, so they keep going with Ryoku closed. An effect that does nothing is its firmware; a Ryoku effect above will work instead.": "Run by the device itself, so they keep going with Ryoku closed. An effect that does nothing is its firmware; a Ryoku effect above will work instead.", "Run command": "Run command", + "Run health check": "Run health check", "Run image optimisation across the wallpaper library.": "Run image optimisation across the wallpaper library.", "Run on startup restore": "Run on startup restore", "Run these timeouts on this desktop too, not only on laptops.": "Run these timeouts on this desktop too, not only on laptops.", @@ -3027,6 +3053,7 @@ "Ryoku Hub": "Ryoku Hub", "Ryoku Motion records into %s instead of %s": "Ryoku Motion records into %s instead of %s", "Ryoku Motion records into %s, not %s": "Ryoku Motion records into %s, not %s", + "Ryoku Palette Bridge": "Ryoku Palette Bridge", "Ryoku Settings": "Ryoku Settings", "Ryoku Settings - every knob, in one place": "Ryoku Settings - every knob, in one place", "Ryoku UEFI boot entry present": "Ryoku UEFI boot entry present", @@ -3089,6 +3116,7 @@ "SENSOR ACTIVE ✦ TOUCH OR TYPE": "SENSOR ACTIVE ✦ TOUCH OR TYPE", "SET AS MAIN": "SET AS MAIN", "SET TIME ZONE": "SET TIME ZONE", + "SET UP": "SET UP", "SET UP HERMES AGENT": "SET UP HERMES AGENT", "SET UP SECURITY KEY": "SET UP SECURITY KEY", "SETTING UP…": "SETTING UP…", @@ -3266,9 +3294,11 @@ "Segments": "Segments", "Select": "Select", "Select a display to configure it.": "Select a display to configure it.", + "Select a source containing the Palette Bridge build recipe.": "Select a source containing the Palette Bridge build recipe.", "Select apps to install": "Select apps to install", "Select at least one random source category in settings": "Select at least one random source category in settings", "Select files to compress": "Select files to compress", + "Select remove again to confirm. Only bridge-owned files will be removed.": "Select remove again to confirm. Only bridge-owned files will be removed.", "Selected width": "Selected width", "Selector backdrop dim (%)": "Selector backdrop dim (%)", "Selects Main, Hero, or OkShell for Super+Space": "Selects Main, Hero, or OkShell for Super+Space", @@ -3436,7 +3466,9 @@ "Speaker": "Speaker", "Speech engine": "Speech engine", "Speed": "Speed", + "Spicetify extension for live palette updates without stopping playback.": "Spicetify extension for live palette updates without stopping playback.", "Split pills": "Split pills", + "Spotify": "Spotify", "Spotlight": "Spotlight", "Spread": "Spread", "Square corners": "Square corners", @@ -3445,6 +3477,7 @@ "Stage captured — lift and touch again": "Stage captured — lift and touch again", "Starship": "Starship", "Start": "Start", + "Start at login and publish wallpaper colours on the local endpoint.": "Start at login and publish wallpaper colours on the local endpoint.", "Start cobalt": "Start cobalt", "Start each session with the keypad typing digits.": "Start each session with the keypad typing digits.", "Start service": "Start service", @@ -3454,6 +3487,7 @@ "Started %1, but looking-glass-client is not installed. Install it to see the guest.": "Started %1, but looking-glass-client is not installed. Install it to see the guest.", "Starting %1": "Starting %1", "Starting %1 (disposable)": "Starting %1 (disposable)", + "Starting Palette Bridge": "Starting Palette Bridge", "Starting cobalt…": "Starting cobalt…", "Starting steamcmd...": "Starting steamcmd...", "Starting…": "Starting…", @@ -3491,6 +3525,7 @@ "Stop the radio": "Stop the radio", "Stopped": "Stopped", "Stopping %1": "Stopping %1", + "Stopping Palette Bridge": "Stopping Palette Bridge", "Stops the video behind a fullscreen window": "Stops the video behind a fullscreen window", "Stops the video behind a fullscreen window.": "Stops the video behind a fullscreen window.", "Store this look in the device so it holds with Ryoku closed. It replaces what is in the device's active profile slot.": "Store this look in the device so it holds with Ryoku closed. It replaces what is in the device's active profile slot.", @@ -3917,6 +3952,7 @@ "Usage: ryoku wm \n\n status print the detected provider, its capabilities and workspace model\n caps print the active provider's capability manifest (JSON)\n config [name] print a provider's config dir and the files it owns (JSON)\n reset-paths print the config files a factory reset clears (one path per line)\n use preview and switch to another compositor (installs its package)\n act [args] dispatch a window-manager action through the provider\n session print the provider's wayland-session desktop entry": "Usage: ryoku wm \n\n status print the detected provider, its capabilities and workspace model\n caps print the active provider's capability manifest (JSON)\n config [name] print a provider's config dir and the files it owns (JSON)\n reset-paths print the config files a factory reset clears (one path per line)\n use preview and switch to another compositor (installs its package)\n act [args] dispatch a window-manager action through the provider\n session print the provider's wayland-session desktop entry", "Use Add security key to enroll another key, or remove an old one below.": "Use Add security key to enroll another key, or remove an old one below.", "Use ISOLATE from the physical machine; every remote connection is cut immediately.": "Use ISOLATE from the physical machine; every remote connection is cut immediately.", + "Use a local checkout, or Ryoku's packaged source.": "Use a local checkout, or Ryoku's packaged source.", "Use free space": "Use free space", "Use mine": "Use mine", "Use passkey for": "Use passkey for", @@ -3959,6 +3995,7 @@ "Version: %s": "Version: %s", "Vertical crop point; 0 top, 1 bottom; drag preview": "Vertical crop point; 0 top, 1 bottom; drag preview", "Vertical offset": "Vertical offset", + "Vesktop": "Vesktop", "Vibrancy": "Vibrancy", "Vibrant": "Vibrant", "Video": "Video", @@ -4207,6 +4244,7 @@ "Yours": "Yours", "ZOOM": "ZOOM", "ZSH PROMPT": "ZSH PROMPT", + "Zen Browser": "Zen Browser", "Zen not installed": "Zen not installed", "Zen policy up to date": "Zen policy up to date", "Zone colours": "Zone colours", 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 }; diff --git a/ryoku/shell/CHANGELOG.md b/ryoku/shell/CHANGELOG.md index 2e5b1369ae..6f1679c07d 100644 --- a/ryoku/shell/CHANGELOG.md +++ b/ryoku/shell/CHANGELOG.md @@ -4,46 +4,10 @@ ### Added -- **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 - "Page Up", and rail counts that only count usable shortcuts - (`quickshell/keys/BindRow.qml`, `Cheatsheet.qml`, `KeyCap.qml`). -- **Night light on every compositor, from the Hub.** The warm screen was a - Hyprland leaf: the script shipped only with that variant and drove a CTM - client niri cannot serve. The backend is the window-manager provider's now - (`nightlight.on` / `nightlight.off` actions, a `nightLight` capability, the - backend's process name in caps): hyprsunset on Hyprland, gammastep over gamma - control on niri. The script rides the shell to every box, the daemon tracks - whichever backend the provider names, and the quick tile and launcher action - hide where there is none (`scripts/ryoku-cmd-nightlight`, `ipc/nightlight.go`). -- **The leaf scripts ship with the shell.** `ryoku-app`, the `ryoku-cmd-*` - tools, the recorder helpers, folder tinting and the sysinfo readouts are - called by bare name on every compositor but shipped only inside the Hyprland - variant, so a packaged niri box had none of them while a dev checkout laid - every provider's scripts. They live in `scripts/` now and ship from - `ryoku-shell` by one glob; `deploy.sh` lays only the live provider's own - leaf scripts, so a checkout finally looks like a package. -- **Summon, game mode, studio recording and the touchpad keys go through the - seam.** `window.summon`, `decoration.gameMode`, `input.touchpad`, - `output.cycle` and `output.enable` are provider actions; the cursor tracker - studio recording uses stays Hyprland payload, reached by capability - (`scripts/ryoku-summon`, `scripts/ryoku-cmd-game-mode`, - `scripts/ryoku-cmd-studiorecord`). - -- **Upscaling runs in its own worker process and reports its progress.** The - waifu2x/ffmpeg enhance ran inside the daemon: a panicking job took the whole - daemon (and the picker with it) down, a crash mid-run wedged the job lock so - every later upscale answered "already running", and nothing showed a user - where their job had gone. The pipeline now lives in a child (`ryogami - upscale-worker`) the daemon supervises over its stdout: the child carries - its own process group, so a cancel or the failsafe ceiling kills waifu2x and - ffmpeg with it; a worker crash is just a failed verdict; and if the daemon - dies first the worker notices its stdin closing and tears the job down - instead of orphaning the GPU. The same phase/progress events reach the - picker's edit panel as before, and a terminal view lands beside them: - `ryogami upscale start [scale]`, `ryogami upscale status`, - `ryogami upscale cancel` (`ryogami/daemon/upscale_worker.go`, `daemon/main.go`). +- **Palette Bridge settings now live in Win+W > Advanced > Matugen.** The + existing Matugen controls stay on their own page, while a second page handles + building the bridge, managing its service, checking its health, and setting up + or removing the Spotify, Vesktop, and Zen integrations. - **Kairos, a third built-in bar style: one dynamic island carrying the clock.** A single near-black pill floats at the top centre showing the time, and opens @@ -86,26 +50,6 @@ desktop around the island stays usable. Select it as **Kairos** in Ryoku Settings' App Launcher (`modules/launcher/variants/kairos`, `catalog.json`). -- **Kairos has its own island settings, opened from the gear in the clock pill.** - The top-right gear on the expanded clock island opens a surface that belongs to - the style alone -- a near-black plate styled like the island, with three routes: - Island (size, top offset), Clock (12-hour, seconds, date wheel) and Music (the - now-playing bubble and its hover peek). Every control writes the `kairos` key in - `shell.json` through the shell daemon and applies live; Ryoku Settings is - untouched (`barstyles/kairos/settings/`, `services/Config.qml`). - -- **Kairos quick settings grow out of the island itself.** A tune icon beside the - gear extends the expanded clock island into the style's own quick settings; one - surface, one pill, one `Motion.morph`, so open and close read as the island - stretching rather than another window appearing. It carries Wi-Fi and Bluetooth - tiles, a weather card (the music card's place), Display and Sound fader rows and - the notification list; each tile opens a page in place -- Wi-Fi networks with a - password prompt, Bluetooth connected/saved/nearby pairing, Sound output/input - volume and devices, and Display brightness, scale, resolution and Night Light. - Pages push in from the side rather than cutting. Dismiss by clicking outside, - moving the pointer away, Escape, or the tune icon; Ryoku Settings is untouched - (`barstyles/kairos/quicksettings/`, `components/Island.qml`). - - **Rashin works with any coding agent now, not just Hermes.** The Hub's Rashin page and the dashboard both list your detected agents with a one-click Wire (it drops a pointer, the ryoku skill, and prowl-agent's code-intelligence @@ -200,37 +144,7 @@ `transition.shader` and the built-in engine. Nothing wrote the keys, so no config migrates (`ryogami/wall-ui/qml/Config.qml`). -- **A Super tap no longer opens the niri overview.** Tapping Super by itself - used to toggle niri's overview through the keypress daemon; Super+Tab already - does that, so the tap binding, the daemon claim behind it and the reader it - kept alive with the visualiser off are gone (`shell.qml`, - `services/Keypresses.qml`, `ipc/keypress.go`). - ### Fixed -- **Closing the launcher no longer risks crashing the shell on niri.** Where the - compositor has no focus-grab protocol the launcher tore its screen capture - down on every close and unmapped its dismiss scrim from inside the press that - closed it, the lifecycle quickshell segfaults on under niri. The capture - object now lives as long as the surface and the scrim unmaps one turn later; - the daemon logs the exit status and stderr tail when the shell dies - (`modules/launcher/variants/hero/LauncherSurface.qml`, `LocalFrost.qml`, - `ipc/daemon.go`). -- **Bluetooth devices no longer show as MAC addresses when BlueZ has no name - for them.** BlueZ leaves a device's alias equal to its own address (dashed, - such as `73-EC-EF-CD-48-8C`) until it learns a name, and Quickshell exposes - that alias as `name` while the device-reported name is `deviceName`. Every - surface read the name from the alias alone, so an unnamed device printed its - MAC even when `deviceName` held the real name. One shared resolver now skips - an address-shaped alias, prefers the device's own reported name, and falls - back to the address only when a device truly has no name: it covers the - Kairos and QS Bar Bluetooth panels, the Sumi Bluetooth menu, the launcher's - connection tiles, and the Hub's Connections page - (`ryoku/ui/lib/bluetooth.js`, `services/BtLink.qml`, - `barstyles/kairos/quicksettings/pages/{BluetoothPage,HomePage}.qml`, - `barstyles/qsbar/panels/BluetoothPanel.qml`, - `framebars/menus/MenuBluetooth.qml`, - `launcher/variants/main/BtConnections.qml`, - `ryoku/hub/quickshell/pages/ConnectionsPage.qml`). - **Turning on the Cobalt download engine no longer adds you to the `docker` group.** Docker group membership is passwordless root for every process in your session, so a GUI toggle should never grant it. The engine already does diff --git a/ryoku/shell/quickshell/shell/modules/launcher/shared/lib/catalog.test.mjs b/ryoku/shell/quickshell/shell/modules/launcher/shared/lib/catalog.test.mjs index 9059b31246..88492665e8 100644 --- a/ryoku/shell/quickshell/shell/modules/launcher/shared/lib/catalog.test.mjs +++ b/ryoku/shell/quickshell/shell/modules/launcher/shared/lib/catalog.test.mjs @@ -10,7 +10,7 @@ const raw = JSON.parse(readFileSync( "utf8" )); -test("real catalog exposes its variants with the promised routing", () => { +test("real catalog exposes the built-in variants with the promised routing", () => { const catalog = Catalog.normalize(raw); assert.deepEqual( @@ -20,7 +20,6 @@ test("real catalog exposes its variants with the promised routing", () => { assert.equal(Catalog.defaultEntry(catalog).id, "hero"); assert.equal(Catalog.fallbackEntry(catalog).id, "okshell"); assert.equal(Catalog.entry(catalog, "main").id, "main"); - assert.equal(Catalog.entry(catalog, "kairos").id, "kairos"); assert.equal(Catalog.entry(catalog, "does-not-exist").id, "hero"); }); diff --git a/ryoku/shell/ryogami/wall-ui/qml/Config.qml b/ryoku/shell/ryogami/wall-ui/qml/Config.qml index 8853edba58..ac6fdefd37 100644 --- a/ryoku/shell/ryogami/wall-ui/qml/Config.qml +++ b/ryoku/shell/ryogami/wall-ui/qml/Config.qml @@ -30,36 +30,8 @@ QtObject { function _reparse() { var raw = _configFile.text() || "" if (!raw) { config._data = {}; config.configLoaded = false; return } - var parsed - try { parsed = JSON.parse(raw) } - catch (e) { config._data = {}; config.configLoaded = false; return } - config._data = config._migrateBackdropKeys(parsed) - config.configLoaded = true - } - - // _migrateBackdropKeys renames the pre-seam niri.* overview-backdrop keys to - // overviewBackdrop.* once, then writes the file so the rename sticks. - function _migrateBackdropKeys(data) { - if (!data || typeof data.niri !== "object" || data.niri === null) return data - var map = { - "overviewBackdrop": "enabled", - "overviewBackdropBlur": "blur", - "overviewBackdropBlurEnabled": "blurEnabled", - "backdrop": "path", - "backdropFollowWallpaper": "followWallpaper", - "backdropAutoTheme": "autoTheme", - "backdropTheme": "theme", - "backdropDim": "dim" - } - var ob = (data.overviewBackdrop && typeof data.overviewBackdrop === "object") ? data.overviewBackdrop : {} - for (var oldKey in map) { - if (data.niri[oldKey] !== undefined && ob[map[oldKey]] === undefined) - ob[map[oldKey]] = data.niri[oldKey] - } - data.overviewBackdrop = ob - delete data.niri - _configWriter.setText(JSON.stringify(data, null, 2) + "\n") - return data + try { config._data = JSON.parse(raw); config.configLoaded = true } + catch (e) { config._data = {}; config.configLoaded = false } } function saveKey(path, value) { @@ -225,38 +197,11 @@ QtObject { readonly property string matugenConfig: cacheDir + "/matugen-config.toml" readonly property string defaultMatugenConfig: _resolve(_data.defaultMatugenConfig ?? "~/.config/matugen/config.toml") readonly property string externalMatugenCommand: _data.externalMatugenCommand ?? "matugen -c %config% image %path% -t %scheme% -m %mode% --source-color-index %index%" - // The matugen knobs live in the shell's own store, not in this vendored - // config: ryoku-hub is their single writer and ryoku-shell retints from that - // file, so the picker reads them there and falls back to the vendored block - // only for a box that has never saved one. - property var _matugenKnobs: ({}) - function _reparseMatugenKnobs() { - try { config._matugenKnobs = JSON.parse(_matugenKnobsFile.text() || "") || {} } - catch (e) { config._matugenKnobs = {} } - } - property var _matugenKnobsFile: FileView { - path: (Quickshell.env("XDG_CONFIG_HOME") || (homeDir + "/.config")) + "/ryoku/matugen.json" - watchChanges: true - onLoaded: config._reparseMatugenKnobs() - onFileChanged: { reload(); config._reparseMatugenKnobs() } - } - - 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 - // clamp has to allow 4: at 3 a stored 4 displayed as 3. - readonly property int matugenColorIndex: { - var v = _matugenKnobs.sourceColorIndex - if (typeof v !== "number" && _data.matugen && typeof _data.matugen.colorIndex === "number") - v = _data.matugen.colorIndex - return (typeof v === "number") ? Math.max(0, Math.min(4, v | 0)) : 0 - } - readonly property real matugenContrast: { - var v = _matugenKnobs.contrast - if (typeof v !== "number" && _data.matugen && typeof _data.matugen.contrast === "number") - v = _data.matugen.contrast - return (typeof v === "number") ? Math.max(-1, Math.min(1, v)) : 0 - } + readonly property string paletteBridgeSource: _resolve(_data.paletteBridgeSource ?? "/usr/share/ryoku/palette-bridge") + readonly property string matugenScheme: (_data.matugen && _data.matugen.schemeType) ? _data.matugen.schemeType : "scheme-fidelity" + readonly property string matugenMode: (_data.matugen && _data.matugen.mode) ? _data.matugen.mode : "dark" + readonly property int matugenColorIndex: (_data.matugen && typeof _data.matugen.colorIndex === "number") ? Math.max(0, Math.min(3, _data.matugen.colorIndex | 0)) : 0 + readonly property real matugenContrast: (_data.matugen && typeof _data.matugen.contrast === "number") ? Math.max(-1, Math.min(1, _data.matugen.contrast)) : 0 readonly property var integrations: _data.integrations ?? [] onIntegrationsChanged: _generateMatugenConfig() @@ -291,7 +236,7 @@ QtObject { console.log("Config: generated matugen config with", ints.length, "integrations") } - Component.onCompleted: { console.log("Configuration Loaded"); config._loadCaps() } + Component.onCompleted: console.log("Configuration Loaded") property var _components: _data.components ?? {} property var _wallpaperSelector: (typeof _components.wallpaperSelector === "object" && _components.wallpaperSelector !== null) ? _components.wallpaperSelector : {} @@ -373,30 +318,19 @@ QtObject { return desktop.indexOf("kde") >= 0 || desktop.indexOf("plasma") >= 0 } - // Compositor capabilities from the ryogami daemon (which holds the wm seam - // client), so the picker gates features on what the compositor can do, never - // on its name. - property var _caps: ({}) - readonly property bool canOverviewBackdrop: { - var s = _caps.supports - return Array.isArray(s) && s.indexOf("nativeOverview") >= 0 - } - function _loadCaps() { - DaemonClient.call("wm.caps", {}, function(result, err) { - if (!err && result) config._caps = result - }) - } - property var _capsConn: Connections { - target: DaemonClient - function onReadyChanged() { if (DaemonClient.ready) config._loadCaps() } + readonly property bool isNiri: { + var desktop = (Quickshell.env("XDG_CURRENT_DESKTOP") || "").toLowerCase() + return desktop.indexOf("niri") >= 0 } - readonly property bool overviewBackdropEnabled: _data.overviewBackdrop?.enabled !== false - readonly property string overviewBackdropPath: _data.overviewBackdrop?.path ?? "" - readonly property bool overviewBackdropFollowWallpaper: _data.overviewBackdrop?.followWallpaper === true - readonly property int overviewBackdropDim: Math.max(0, Math.min(100, _data.overviewBackdrop?.dim ?? 0)) - readonly property int overviewBackdropBlur: Math.max(1, Math.min(200, _data.overviewBackdrop?.blur ?? 30)) - readonly property bool overviewBackdropBlurEnabled: _data.overviewBackdrop?.blurEnabled !== false + readonly property bool niriOverviewBackdrop: _data.niri?.overviewBackdrop === true + readonly property string niriBackdrop: _data.niri?.backdrop ?? "" + readonly property bool niriBackdropFollowWallpaper: _data.niri?.backdropFollowWallpaper === true + readonly property bool niriBackdropAutoTheme: _data.niri?.backdropAutoTheme === true + readonly property string niriBackdropTheme: _data.niri?.backdropTheme ?? "Catppuccin" + readonly property int niriBackdropDim: Math.max(0, Math.min(100, _data.niri?.backdropDim ?? 0)) + readonly property int niriOverviewBackdropBlur: Math.max(1, Math.min(200, _data.niri?.overviewBackdropBlur ?? 30)) + readonly property bool niriOverviewBackdropBlurEnabled: _data.niri?.overviewBackdropBlurEnabled !== false readonly property string kdeVideoPlugin: "luisbocanegra.smart.video.wallpaper.reborn" } diff --git a/ryoku/shell/ryogami/wall-ui/qml/wallpaper/SettingsPanel.qml b/ryoku/shell/ryogami/wall-ui/qml/wallpaper/SettingsPanel.qml index 82f3b57c8b..bf521f4245 100644 --- a/ryoku/shell/ryogami/wall-ui/qml/wallpaper/SettingsPanel.qml +++ b/ryoku/shell/ryogami/wall-ui/qml/wallpaper/SettingsPanel.qml @@ -39,7 +39,7 @@ Item { return ["selector", "paper", "edit", "theme"].indexOf(key) >= 0 var adv = ["general", "playlists", "paths", "comfort", "lighting", "performance", "postprocessing"] if (Config.matugenEnabled) adv.push("matugen") - if (Config.canOverviewBackdrop) adv.push("overview-backdrop") + if (Config.isNiri) adv.push("niri") if (Config.steamEnabled) adv.push("wallpaper-engine") return adv.indexOf(key) >= 0 } @@ -73,7 +73,7 @@ Item { } z: 102 - width: Math.min(((settingsPanel.activeTab === "performance" ? 1080 : (settingsPanel.activeTab === "general" || settingsPanel.activeTab === "edit") ? 900 : 760) * Config.uiScale) + _keybindsColW + _s(24), Screen.width - _s(48)) + width: Math.min((((settingsPanel.activeTab === "performance" || settingsPanel.activeTab === "matugen") ? 1080 : (settingsPanel.activeTab === "general" || settingsPanel.activeTab === "edit") ? 900 : 760) * Config.uiScale) + _keybindsColW + _s(24), Screen.width - _s(48)) Behavior on width { NumberAnimation { duration: Style.animFast; easing.type: Easing.OutCubic } } height: tabRow.height + contentLoader.height + 36 @@ -257,7 +257,7 @@ Item { { key: "postprocessing", label: I18n.tr("EXTERNAL") } ] if (Config.matugenEnabled) tabs.push({ key: "matugen", label: I18n.tr("MATUGEN") }) - if (Config.canOverviewBackdrop) tabs.push({ key: "overview-backdrop", label: I18n.tr("OVERVIEW BACKDROP") }) + if (Config.isNiri) tabs.push({ key: "niri", label: I18n.tr("NIRI") }) if (Config.steamEnabled) tabs.push({ key: "wallpaper-engine", label: I18n.tr("WALLPAPER ENGINE") }) return tabs } @@ -313,8 +313,8 @@ Item { if (settingsPanel.activeTab === "performance") return performanceContent.implicitHeight if (settingsPanel.activeTab === "postprocessing") return Math.min(postprocessingContent.implicitHeight, 360) if (settingsPanel.activeTab === "theme") return themeContent.implicitHeight - if (settingsPanel.activeTab === "matugen") return Math.min(matugenContent.implicitHeight, 360) - if (settingsPanel.activeTab === "overview-backdrop") return overviewBackdropContent.implicitHeight + if (settingsPanel.activeTab === "matugen") return Math.min(matugenContent.implicitHeight, Screen.height - settingsPanel._s(160)) + if (settingsPanel.activeTab === "niri") return niriContent.implicitHeight return 0 } Behavior on height { NumberAnimation { duration: Style.animFast; easing.type: Easing.OutCubic } } @@ -437,12 +437,12 @@ Item { } Loader { - id: overviewBackdropContent + id: niriContent anchors.left: parent.left anchors.right: parent.right - active: settingsPanel.activeTab === "overview-backdrop" + active: settingsPanel.activeTab === "niri" visible: active - source: "settings/OverviewBackdropSettings.qml" + source: "settings/NiriSettings.qml" onLoaded: { item.colors = Qt.binding(function() { return settingsPanel.colors }) item.saveConfigKey = function(k, v) { settingsPanel._saveConfigKey(k, v) } @@ -515,6 +515,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