diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index faa06a7f7..9b1f943ea 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -39,7 +39,6 @@ jobs: echo 'To fix this check, run "make format" and commit the changes' exit 1 fi - go: name: Test Go code runs-on: ubuntu-latest @@ -156,6 +155,51 @@ jobs: !dist/*.zip retention-days: 7 + msi: + name: Build and publish MSI files + needs: [binaries] + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + + - uses: actions/download-artifact@v4 + with: + name: binaries + path: dist + + - name: Build MSI + run: | + sudo apt-get install -y wixl jq + + NAVIDROME_BUILD_VERSION=$(jq -r '.version' < $GITHUB_WORKSPACE/dist/metadata.json | sed -e 's/^v//' -e 's/-SNAPSHOT/.1/' ) + + mkdir -p $GITHUB_WORKSPACE/wix/386 + cp $GITHUB_WORKSPACE/LICENSE $GITHUB_WORKSPACE/wix/386 + cp $GITHUB_WORKSPACE/README.md $GITHUB_WORKSPACE/wix/386 + + cp -r $GITHUB_WORKSPACE/wix/386 $GITHUB_WORKSPACE/wix/amd64 + + cp $GITHUB_WORKSPACE/dist/navidrome_windows_386_windows_386/navidrome.exe $GITHUB_WORKSPACE/wix/386 + cp $GITHUB_WORKSPACE/dist/navidrome_windows_amd64_windows_amd64_v1/navidrome.exe $GITHUB_WORKSPACE/wix/amd64 + + # workaround for wixl WixVariable not working to override bmp locations + sudo cp $GITHUB_WORKSPACE/wix/bmp/banner.bmp /usr/share/wixl-*/ext/ui/bitmaps/bannrbmp.bmp + sudo cp $GITHUB_WORKSPACE/wix/bmp/dialogue.bmp /usr/share/wixl-*/ext/ui/bitmaps/dlgbmp.bmp + + cd $GITHUB_WORKSPACE/wix/386 + wixl ../navidrome.wxs -D Version=$NAVIDROME_BUILD_VERSION -D Platform=x86 --arch x86 --ext ui --output ../navidrome_386.msi + + cd $GITHUB_WORKSPACE/wix/amd64 + wixl ../navidrome.wxs -D Version=$NAVIDROME_BUILD_VERSION -D Platform=x64 --arch x64 --ext ui --output ../navidrome_amd64.msi + + ls -la $GITHUB_WORKSPACE/wix/*.msi + + - uses: actions/upload-artifact@v4 + with: + name: installers + path: wix/*.msi + retention-days: 7 + docker: name: Build and publish Docker images needs: [binaries] diff --git a/cmd/root.go b/cmd/root.go index b6aa0d708..f623b408f 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -2,7 +2,6 @@ package cmd import ( "context" - "fmt" "os" "os/signal" "strings" @@ -37,7 +36,7 @@ Complete documentation is available at https://www.navidrome.org/docs`, preRun() }, Run: func(cmd *cobra.Command, args []string) { - runNavidrome() + runNavidrome(cmd.Context()) }, PostRun: func(cmd *cobra.Command, args []string) { postRun() @@ -50,8 +49,7 @@ Complete documentation is available at https://www.navidrome.org/docs`, func Execute() { rootCmd.SetVersionTemplate(`{{println .Version}}`) if err := rootCmd.Execute(); err != nil { - fmt.Println(err) - os.Exit(1) + log.Fatal(err) } } @@ -69,10 +67,10 @@ func postRun() { // runNavidrome is the main entry point for the Navidrome server. It starts all the services and blocks. // If any of the services returns an error, it will log it and exit. If the process receives a signal to exit, // it will cancel the context and exit gracefully. -func runNavidrome() { +func runNavidrome(ctx context.Context) { defer db.Init()() - ctx, cancel := mainContext() + ctx, cancel := mainContext(ctx) defer cancel() g, ctx := errgroup.WithContext(ctx) @@ -88,8 +86,8 @@ func runNavidrome() { } // mainContext returns a context that is cancelled when the process receives a signal to exit. -func mainContext() (context.Context, context.CancelFunc) { - return signal.NotifyContext(context.Background(), +func mainContext(ctx context.Context) (context.Context, context.CancelFunc) { + return signal.NotifyContext(ctx, os.Interrupt, syscall.SIGHUP, syscall.SIGTERM, diff --git a/cmd/svc.go b/cmd/svc.go new file mode 100644 index 000000000..21c9b64cc --- /dev/null +++ b/cmd/svc.go @@ -0,0 +1,209 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "path/filepath" + "sync" + "time" + + "github.com/kardianos/service" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/log" + "github.com/spf13/cobra" +) + +var ( + svcStatusLabels = map[service.Status]string{ + service.StatusUnknown: "Unknown", + service.StatusStopped: "Stopped", + service.StatusRunning: "Running", + } +) + +func init() { + svcCmd.AddCommand(buildInstallCmd()) + svcCmd.AddCommand(buildUninstallCmd()) + svcCmd.AddCommand(buildStartCmd()) + svcCmd.AddCommand(buildStopCmd()) + svcCmd.AddCommand(buildStatusCmd()) + svcCmd.AddCommand(buildExecuteCmd()) + rootCmd.AddCommand(svcCmd) +} + +var svcCmd = &cobra.Command{ + Use: "service", + Aliases: []string{"svc"}, + Short: "Manage Navidrome as a service", + Long: fmt.Sprintf("Manage Navidrome as a service, using the OS service manager (%s)", service.Platform()), + Run: runServiceCmd, +} + +type svcControl struct { + ctx context.Context + cancel context.CancelFunc + done chan struct{} +} + +func (p *svcControl) Start(service.Service) error { + p.done = make(chan struct{}) + p.ctx, p.cancel = context.WithCancel(context.Background()) + go func() { + runNavidrome(p.ctx) + close(p.done) + }() + return nil +} + +func (p *svcControl) Stop(service.Service) error { + log.Info("Stopping service") + p.cancel() + select { + case <-p.done: + log.Info("Service stopped gracefully") + case <-time.After(10 * time.Second): + log.Error("Service did not stop in time. Killing it.") + } + return nil +} + +var svcInstance = sync.OnceValue(func() service.Service { + options := make(service.KeyValue) + options["Restart"] = "on-success" + options["SuccessExitStatus"] = "1 2 8 SIGKILL" + options["UserService"] = false + options["LogDirectory"] = conf.Server.DataFolder + svcConfig := &service.Config{ + Name: "navidrome", + DisplayName: "Navidrome", + Description: "Your Personal Streaming Service", + Dependencies: []string{ + "Requires=", + "After="}, + WorkingDirectory: executablePath(), + Option: options, + } + arguments := []string{"service", "execute"} + if conf.Server.ConfigFile != "" { + arguments = append(arguments, "-c", conf.Server.ConfigFile) + } + svcConfig.Arguments = arguments + + prg := &svcControl{} + svc, err := service.New(prg, svcConfig) + if err != nil { + log.Fatal(err) + } + return svc +}) + +func runServiceCmd(cmd *cobra.Command, _ []string) { + _ = cmd.Help() +} + +func executablePath() string { + ex, err := os.Executable() + if err != nil { + log.Fatal(err) + } + return filepath.Dir(ex) +} + +func buildInstallCmd() *cobra.Command { + runInstallCmd := func(_ *cobra.Command, _ []string) { + var err error + println("Installing service with:") + println(" working directory: " + executablePath()) + println(" music folder: " + conf.Server.MusicFolder) + println(" data folder: " + conf.Server.DataFolder) + println(" logs folder: " + conf.Server.DataFolder) + if cfgFile != "" { + conf.Server.ConfigFile, err = filepath.Abs(cfgFile) + if err != nil { + log.Fatal(err) + } + println(" config file: " + conf.Server.ConfigFile) + } + err = svcInstance().Install() + if err != nil { + log.Fatal(err) + } + println("Service installed. Use 'navidrome svc start' to start it.") + } + + return &cobra.Command{ + Use: "install", + Short: "Install Navidrome service.", + Run: runInstallCmd, + } +} + +func buildUninstallCmd() *cobra.Command { + return &cobra.Command{ + Use: "uninstall", + Short: "Uninstall Navidrome service. Does not delete the music or data folders", + Run: func(cmd *cobra.Command, args []string) { + err := svcInstance().Uninstall() + if err != nil { + log.Fatal(err) + } + println("Service uninstalled. Music and data folders are still intact.") + }, + } +} + +func buildStartCmd() *cobra.Command { + return &cobra.Command{ + Use: "start", + Short: "Start Navidrome service", + Run: func(cmd *cobra.Command, args []string) { + err := svcInstance().Start() + if err != nil { + log.Fatal(err) + } + println("Service started. Use 'navidrome svc status' to check its status.") + }, + } +} + +func buildStopCmd() *cobra.Command { + return &cobra.Command{ + Use: "stop", + Short: "Stop Navidrome service", + Run: func(cmd *cobra.Command, args []string) { + err := svcInstance().Stop() + if err != nil { + log.Fatal(err) + } + println("Service stopped. Use 'navidrome svc status' to check its status.") + }, + } +} + +func buildStatusCmd() *cobra.Command { + return &cobra.Command{ + Use: "status", + Short: "Show Navidrome service status", + Run: func(cmd *cobra.Command, args []string) { + status, err := svcInstance().Status() + if err != nil { + log.Fatal(err) + } + fmt.Printf("Navidrome is %s.\n", svcStatusLabels[status]) + }, + } +} + +func buildExecuteCmd() *cobra.Command { + return &cobra.Command{ + Use: "execute", + Short: "Run navidrome as a service in the foreground (it is very unlikely you want to run this, you are better off running just navidrome)", + Run: func(cmd *cobra.Command, args []string) { + err := svcInstance().Run() + if err != nil { + log.Fatal(err) + } + }, + } +} diff --git a/go.mod b/go.mod index 44a90c477..396e96e88 100644 --- a/go.mod +++ b/go.mod @@ -27,6 +27,7 @@ require ( github.com/google/wire v0.6.0 github.com/hashicorp/go-multierror v1.1.1 github.com/jellydator/ttlcache/v3 v3.3.0 + github.com/kardianos/service v1.2.2 github.com/kr/pretty v0.3.1 github.com/lestrrat-go/jwx/v2 v2.1.1 github.com/matoous/go-nanoid/v2 v2.1.0 diff --git a/go.sum b/go.sum index 1413e2529..c1a05298c 100644 --- a/go.sum +++ b/go.sum @@ -96,6 +96,8 @@ github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7 github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/kardianos/service v1.2.2 h1:ZvePhAHfvo0A7Mftk/tEzqEZ7Q4lgnR8sGz4xu1YX60= +github.com/kardianos/service v1.2.2/go.mod h1:CIMRFEJVL+0DS1a3Nx06NaMn4Dz63Ng6O7dl0qH0zVM= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -255,6 +257,7 @@ golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201015000850-e3ed0017c211/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/wix/Navidrome_UI_Flow.wxs b/wix/Navidrome_UI_Flow.wxs new file mode 100644 index 000000000..2ea38e172 --- /dev/null +++ b/wix/Navidrome_UI_Flow.wxs @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/wix/SettingsDlg.wxs b/wix/SettingsDlg.wxs new file mode 100644 index 000000000..4a83f91da --- /dev/null +++ b/wix/SettingsDlg.wxs @@ -0,0 +1,44 @@ + + + + + + + + {\WixUI_Font_Title}Configuration + + + Please enter configuration settings + + + + + + + + + + + + + + + + + + + + + + + + + CostingComplete = 1 + + + + 1 + + + + diff --git a/wix/bmp/banner.bmp b/wix/bmp/banner.bmp new file mode 100644 index 000000000..bbaa0fc2b Binary files /dev/null and b/wix/bmp/banner.bmp differ diff --git a/wix/bmp/dialogue.bmp b/wix/bmp/dialogue.bmp new file mode 100644 index 000000000..56f3db55b Binary files /dev/null and b/wix/bmp/dialogue.bmp differ diff --git a/wix/convertIniToToml.vbs b/wix/convertIniToToml.vbs new file mode 100644 index 000000000..1feb7d6d5 --- /dev/null +++ b/wix/convertIniToToml.vbs @@ -0,0 +1,17 @@ +Const ForReading = 1 +Const ForWriting = 2 + +sSourceFilename = Wscript.Arguments(0) +sTargetFilename = Wscript.Arguments(1) + +Set oFSO = CreateObject("Scripting.FileSystemObject") +Set oFile = oFSO.OpenTextFile(sSourceFilename, ForReading) +sFileContent = oFile.ReadAll +oFile.Close + +sNewFileContent = Replace(sFileContent, "[MSI_PLACEHOLDER_SECTION]" & vbCrLf, "") +If Not ( oFSO.FileExists(sTargetFilename) ) Then + Set oFile = oFSO.CreateTextFile(sTargetFilename) + oFile.Write sNewFileContent + oFile.Close +End If diff --git a/wix/navidrome.wxs b/wix/navidrome.wxs new file mode 100644 index 000000000..ad923a0a4 --- /dev/null +++ b/wix/navidrome.wxs @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Not Installed AND NOT WIX_UPGRADE_DETECTED + + + + NOT Installed AND NOT REMOVE + + + + + + + + + + +