From 023d72e259b58e02b79b063ff5743ad877ec4a28 Mon Sep 17 00:00:00 2001 From: __Aimbot__ Date: Fri, 1 May 2026 13:14:10 -0500 Subject: [PATCH] feat(service): add auto-apply on Spotify updates --- install.ps1 | 7 ++ spicetify.go | 37 ++++++++++ src/cmd/apply.go | 3 + src/cmd/auto.go | 3 + src/cmd/backup.go | 3 + src/cmd/service_startup.go | 140 +++++++++++++++++++++++++++++++++++++ src/service/service.go | 128 +++++++++++++++++++++++++++++++++ src/utils/config.go | 4 ++ 8 files changed, 325 insertions(+) create mode 100644 src/cmd/service_startup.go create mode 100644 src/service/service.go diff --git a/install.ps1 b/install.ps1 index d03c8e0e11..6cbfcc1fbe 100644 --- a/install.ps1 +++ b/install.ps1 @@ -210,4 +210,11 @@ else { Invoke-WebRequest @Parameters | Invoke-Expression } #endregion Marketplace + +#region Service +Write-Host -Object "`nEnabling Spicetify automatic update service..." -NoNewline +& spicetify service install +Write-Success +Write-Host -Object "The service will check for Spicetify updates on every Windows startup and every 6 hours." -ForegroundColor 'Green' +#endregion Service #endregion Main diff --git a/spicetify.go b/spicetify.go index ab9eb82a1f..dccf952e5c 100644 --- a/spicetify.go +++ b/spicetify.go @@ -15,6 +15,7 @@ import ( colorable "github.com/mattn/go-colorable" "github.com/pterm/pterm" "github.com/spicetify/cli/src/cmd" + "github.com/spicetify/cli/src/service" spotifystatus "github.com/spicetify/cli/src/status/spotify" "github.com/spicetify/cli/src/utils" "github.com/spicetify/cli/src/utils/isAdmin" @@ -187,6 +188,38 @@ func main() { } return + case "service": + cmd.InitPaths() + cmd.InitSetting() + commands = commands[1:] + if len(commands) == 0 { + utils.PrintBold("Starting background service for automatic re-apply") + service.Start(version) + return + } + + switch commands[0] { + case "install", "enable": + cmd.SetServiceAutoUpdate(true) + cmd.SyncServiceStartup(true) + utils.PrintSuccess("Automatic self-healing service enabled") + case "uninstall", "disable": + cmd.SetServiceAutoUpdate(false) + cmd.SyncServiceStartup(false) + utils.PrintSuccess("Automatic self-healing service disabled") + case "status": + cfg := utils.ParseConfig(cmd.GetConfigPath()) + serviceSection := cfg.GetSection("Service") + if serviceSection.Key("auto_update").MustBool(false) { + utils.PrintInfo("Automatic self-healing service is enabled") + } else { + utils.PrintInfo("Automatic self-healing service is disabled") + } + default: + utils.PrintError("Invalid parameter. It has to be \"install\", \"uninstall\" or \"status\".") + } + return + case "path": cmd.InitPaths() commands = commands[1:] @@ -400,6 +433,10 @@ restart Restart Spotify client. spotify-updates Block Spotify updates by patching spotify executable. Accepts "block" or "unblock" as the parameter. +service Run the background self-healing monitor. + Use "install" to enable automatic startup, + "uninstall" to disable it, or "status" to inspect it. + path Print path of Spotify's executable, userdata, and more. 1. Print executable path: spicetify path diff --git a/src/cmd/apply.go b/src/cmd/apply.go index ba833e7367..b012b635d2 100644 --- a/src/cmd/apply.go +++ b/src/cmd/apply.go @@ -96,6 +96,9 @@ func Apply(spicetifyVersion string) { if len(patchSection.Keys()) > 0 { Patch() } + + SetServiceAutoUpdate(true) + SyncServiceStartup(true) } // RefreshTheme updates user.css + theme.js and overwrites custom assets diff --git a/src/cmd/auto.go b/src/cmd/auto.go index 899aab1515..9badb8bd99 100644 --- a/src/cmd/auto.go +++ b/src/cmd/auto.go @@ -10,6 +10,9 @@ import ( // Auto checks Spotify state, re-backup and apply if needed, then launch // Spotify client normally. func Auto(spicetifyVersion string) { + SetServiceAutoUpdate(true) + SyncServiceStartup(true) + backupVersion := backupSection.Key("version").MustString("") spotStat := spotifystatus.Get(appPath) backStat := backupstatus.Get(prefsPath, backupFolder, backupVersion) diff --git a/src/cmd/backup.go b/src/cmd/backup.go index 79d3baf3a9..b6ce19a6e3 100644 --- a/src/cmd/backup.go +++ b/src/cmd/backup.go @@ -154,6 +154,9 @@ func clearBackup() { // Restore uses backup to revert every changes made by Spicetify. func Restore() { + SetServiceAutoUpdate(false) + SyncServiceStartup(false) + CheckStates() spinner, _ := utils.Spinner.Start("Restoring Spotify") if err := os.RemoveAll(appDestPath); err != nil { diff --git a/src/cmd/service_startup.go b/src/cmd/service_startup.go new file mode 100644 index 0000000000..f667aa2c30 --- /dev/null +++ b/src/cmd/service_startup.go @@ -0,0 +1,140 @@ +package cmd + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + + "github.com/spicetify/cli/src/utils" +) + +func SetServiceAutoUpdate(enabled bool) { + serviceSection := cfg.GetSection("Service") + if enabled { + serviceSection.Key("auto_update").SetValue("1") + } else { + serviceSection.Key("auto_update").SetValue("0") + } + + if err := cfg.Write(); err != nil { + utils.PrintWarning(fmt.Sprintf("Failed to save config: %s", err.Error())) + } +} + +func SyncServiceStartup(enabled bool) { + exe, err := os.Executable() + if err != nil { + utils.PrintWarning(fmt.Sprintf("Failed to resolve executable path: %s", err.Error())) + return + } + + if err := setServiceStartup(enabled, exe); err != nil { + utils.PrintWarning(err.Error()) + } +} + +func setServiceStartup(enabled bool, exePath string) error { + if runtime.GOOS == "windows" { + startupDir := filepath.Join(os.Getenv("APPDATA"), "Microsoft", "Windows", "Start Menu", "Programs", "Startup") + startupFile := filepath.Join(startupDir, "Spicetify Service.cmd") + + if !enabled { + if err := os.Remove(startupFile); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to remove startup entry: %w", err) + } + return nil + } + + if err := os.MkdirAll(startupDir, 0700); err != nil { + return fmt.Errorf("failed to create startup folder: %w", err) + } + + content := fmt.Sprintf( + "@echo off\r\ncd /d \"%s\"\r\nstart \"\" /min \"%s\" service\r\n", + filepath.Dir(exePath), exePath, + ) + if err := os.WriteFile(startupFile, []byte(content), 0600); err != nil { + return fmt.Errorf("failed to create startup entry: %w", err) + } + return nil + } + + if runtime.GOOS == "linux" { + configDir, err := os.UserConfigDir() + if err != nil { + return fmt.Errorf("failed to resolve config directory: %w", err) + } + + autostartDir := filepath.Join(configDir, "autostart") + startupFile := filepath.Join(autostartDir, "spicetify-service.desktop") + + if !enabled { + if err := os.Remove(startupFile); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to remove startup entry: %w", err) + } + return nil + } + + if err := os.MkdirAll(autostartDir, 0700); err != nil { + return fmt.Errorf("failed to create autostart folder: %w", err) + } + + content := fmt.Sprintf( + "[Desktop Entry]\nType=Application\nName=Spicetify Service\nExec=%s service\nX-GNOME-Autostart-enabled=true\nNoDisplay=true\n", + strings.ReplaceAll(exePath, "\\", "\\\\"), + ) + if err := os.WriteFile(startupFile, []byte(content), 0600); err != nil { + return fmt.Errorf("failed to create startup entry: %w", err) + } + return nil + } + + if runtime.GOOS == "darwin" { + home, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("failed to resolve home directory: %w", err) + } + + launchAgentsDir := filepath.Join(home, "Library", "LaunchAgents") + startupFile := filepath.Join(launchAgentsDir, "com.spicetify.service.plist") + + if !enabled { + if err := os.Remove(startupFile); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to remove startup entry: %w", err) + } + return nil + } + + if err := os.MkdirAll(launchAgentsDir, 0700); err != nil { + return fmt.Errorf("failed to create launch agents folder: %w", err) + } + + content := fmt.Sprintf(` + + + + Label + com.spicetify.service + ProgramArguments + + %s + service + + RunAtLoad + + KeepAlive + + + +`, exePath) + + if err := os.WriteFile(startupFile, []byte(content), 0600); err != nil { + return fmt.Errorf("failed to create startup entry: %w", err) + } + return nil + } + + return nil +} diff --git a/src/service/service.go b/src/service/service.go new file mode 100644 index 0000000000..4ce00b69b0 --- /dev/null +++ b/src/service/service.go @@ -0,0 +1,128 @@ +package service + +import ( + "path/filepath" + "time" + + "github.com/spicetify/cli/src/cmd" + backupstatus "github.com/spicetify/cli/src/status/backup" + spotifystatus "github.com/spicetify/cli/src/status/spotify" + "github.com/spicetify/cli/src/utils" +) + +// Start runs the background service loop which monitors Spotify and reapplies Spicetify when needed. +func Start(spicetifyVersion string) { + cmd.InitConfig(true) + cmd.InitPaths() + cmd.InitSetting() + + cfg := utils.ParseConfig(cmd.GetConfigPath()) + setting := cfg.GetSection("Setting") + serviceSection := cfg.GetSection("Service") + + if !serviceSection.Key("auto_update").MustBool(false) { + utils.PrintInfo("Service is disabled in config (Service.auto_update=0).") + return + } + + spotifyPath := utils.ReplaceEnvVarsInString(setting.Key("spotify_path").String()) + prefsPath := utils.ReplaceEnvVarsInString(setting.Key("prefs_path").String()) + appsPath := filepath.Join(spotifyPath, "Apps") + backupPath := utils.GetStateFolder("Backup") + backupVersion := cfg.GetSection("Backup").Key("version").MustString("") + interval := time.Duration(serviceSection.Key("interval_seconds").MustInt(60)) * time.Second + + // Initialize with old timestamp so first iteration triggers update check on Windows startup + lastState := serviceState{ + applied: spotifystatus.Get(appsPath).IsApplied(), + lastUpdateCheck: time.Now().Unix() - 6*3600 - 1, + } + + for { + time.Sleep(interval) + + cfg = utils.ParseConfig(cmd.GetConfigPath()) + serviceSection = cfg.GetSection("Service") + if !serviceSection.Key("auto_update").MustBool(false) { + utils.PrintInfo("Service disabled, stopping background monitor.") + return + } + + setting = cfg.GetSection("Setting") + spotifyPath = utils.ReplaceEnvVarsInString(setting.Key("spotify_path").String()) + prefsPath = utils.ReplaceEnvVarsInString(setting.Key("prefs_path").String()) + appsPath = filepath.Join(spotifyPath, "Apps") + backupPath = utils.GetStateFolder("Backup") + backupVersion = cfg.GetSection("Backup").Key("version").MustString("") + interval = time.Duration(serviceSection.Key("interval_seconds").MustInt(60)) * time.Second + + currentState := snapshotState(spotifyPath, prefsPath, appsPath) + if currentState == lastState { + continue + } + + if needsHeal(lastState, currentState) { + utils.PrintInfo("Checking for Spicetify updates or reapplying patch...") + heal(spicetifyVersion, prefsPath, backupPath, backupVersion) + lastState = snapshotState(spotifyPath, prefsPath, appsPath) + continue + } + + lastState = currentState + } +} + +type serviceState struct { + applied bool + lastUpdateCheck int64 +} + +func snapshotState(spotifyPath, prefsPath, appsPath string) serviceState { + state := serviceState{ + applied: spotifystatus.Get(appsPath).IsApplied(), + lastUpdateCheck: time.Now().Unix(), + } + return state +} + +func needsHeal(previous, current serviceState) bool { + // Check if patch was lost + if !current.applied { + return true + } + + // Check if enough time has passed since last update check (6 hours) + if previous.lastUpdateCheck > 0 && (current.lastUpdateCheck-previous.lastUpdateCheck) > 6*3600 { + return true + } + + return false +} + +func heal(spicetifyVersion, prefsPath, backupPath, backupVersion string) { + cmd.InitConfig(true) + cmd.InitPaths() + cmd.InitSetting() + + // Try to update Spicetify CLI if new version is available + cmd.Update(spicetifyVersion) + utils.PrintSuccess("Update check completed") + + // If patch is not applied, reapply it + cfg := utils.ParseConfig(cmd.GetConfigPath()) + setting := cfg.GetSection("Setting") + spotifyPath := utils.ReplaceEnvVarsInString(setting.Key("spotify_path").String()) + appsPath := filepath.Join(spotifyPath, "Apps") + + backStat := backupstatus.Get(prefsPath, backupPath, backupVersion) + if !backStat.IsBackuped() { + utils.PrintWarning("Backup is not ready; will reapply on next cycle.") + return + } + + if !spotifystatus.Get(appsPath).IsApplied() { + cmd.SpotifyKill() + cmd.Apply(spicetifyVersion) + utils.PrintSuccess("Reapplied Spicetify") + } +} diff --git a/src/utils/config.go b/src/utils/config.go index b564a6adf6..6ce4f5cecf 100644 --- a/src/utils/config.go +++ b/src/utils/config.go @@ -39,6 +39,10 @@ var ( "home_config": "1", "experimental_features": "1", }, + "Service": { + "auto_update": "0", + "interval_seconds": "60", + }, "Patch": {}, } )