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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions install.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
37 changes: 37 additions & 0 deletions spicetify.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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:]
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions src/cmd/apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions src/cmd/auto.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions src/cmd/backup.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
140 changes: 140 additions & 0 deletions src/cmd/service_startup.go
Original file line number Diff line number Diff line change
@@ -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(`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.spicetify.service</string>
<key>ProgramArguments</key>
<array>
<string>%s</string>
<string>service</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
</dict>
</plist>
`, 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
}
128 changes: 128 additions & 0 deletions src/service/service.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
4 changes: 4 additions & 0 deletions src/utils/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ var (
"home_config": "1",
"experimental_features": "1",
},
"Service": {
"auto_update": "0",
"interval_seconds": "60",
},
"Patch": {},
}
)
Expand Down
Loading