node build fixed

This commit is contained in:
ra_ma
2025-09-20 14:08:38 +01:00
parent c6ebbe069d
commit 3d298fa434
1516 changed files with 535727 additions and 2 deletions

View File

@@ -0,0 +1,161 @@
package updater
import (
"io"
"net/http"
"runtime"
"seanime/internal/constants"
"seanime/internal/database/models"
"seanime/internal/events"
"slices"
"github.com/Masterminds/semver/v3"
"github.com/goccy/go-json"
)
type AnnouncementType string
const (
AnnouncementTypeToast AnnouncementType = "toast"
AnnouncementTypeDialog AnnouncementType = "dialog"
AnnouncementTypeBanner AnnouncementType = "banner"
)
type AnnouncementSeverity string
const (
AnnouncementSeverityInfo AnnouncementSeverity = "info"
AnnouncementSeverityWarning AnnouncementSeverity = "warning"
AnnouncementSeverityError AnnouncementSeverity = "error"
AnnouncementSeverityCritical AnnouncementSeverity = "critical"
)
type AnnouncementAction struct {
Label string `json:"label"`
URL string `json:"url"`
Type string `json:"type"`
}
type AnnouncementConditions struct {
OS []string `json:"os,omitempty"` // ["windows", "darwin", "linux"]
Platform []string `json:"platform,omitempty"` // ["tauri", "web", "denshi"]
// FeatureFlags []string `json:"featureFlags,omitempty"` // Required feature flags
VersionConstraint string `json:"versionConstraint,omitempty"` // e.g. "<= 2.9.0", "2.9.0"
UserSettingsPath string `json:"userSettingsPath,omitempty"` // JSON path to check in user settings
UserSettingsValue []string `json:"userSettingsValue,omitempty"` // Expected values at that path
}
type Announcement struct {
ID string `json:"id"` // Unique identifier for tracking
Title string `json:"title,omitempty"` // Title for dialogs/banners
Message string `json:"message"` // The message to display
Type AnnouncementType `json:"type"` // The type of announcement
Severity AnnouncementSeverity `json:"severity"` // Severity level
Date interface{} `json:"date"` // Date of the announcement
NotDismissible bool `json:"notDismissible"` // Can user dismiss it
Conditions *AnnouncementConditions `json:"conditions,omitempty"` // Advanced targeting
Actions []AnnouncementAction `json:"actions,omitempty"` // Action buttons
Priority int `json:"priority"`
}
func (u *Updater) GetAnnouncements(version string, platform string, settings *models.Settings) []Announcement {
var filteredAnnouncements []Announcement
if !u.checkForUpdate {
return filteredAnnouncements
}
// filter out
for _, announcement := range u.announcements {
if announcement.Conditions == nil {
filteredAnnouncements = append(filteredAnnouncements, announcement)
continue
}
conditions := announcement.Conditions
if len(conditions.OS) > 0 && !slices.Contains(conditions.OS, runtime.GOOS) {
continue
}
if conditions.Platform != nil && !slices.Contains(conditions.Platform, platform) {
continue
}
if conditions.VersionConstraint != "" {
versionConstraint, err := semver.NewConstraint(conditions.VersionConstraint)
if err != nil {
u.logger.Error().Err(err).Msgf("updater: Failed to parse version constraint")
continue
}
currVersion, err := semver.NewVersion(version)
if err != nil {
u.logger.Error().Err(err).Msgf("updater: Failed to parse current version")
continue
}
if !versionConstraint.Check(currVersion) {
continue
}
}
filteredAnnouncements = append(filteredAnnouncements, announcement)
}
u.announcements = filteredAnnouncements
return u.announcements
}
func (u *Updater) FetchAnnouncements() []Announcement {
var announcements []Announcement
response, err := http.Get(constants.AnnouncementURL)
if err != nil {
u.logger.Error().Err(err).Msgf("updater: Failed to get announcements")
return announcements
}
defer response.Body.Close()
body, err := io.ReadAll(response.Body)
if err != nil {
u.logger.Error().Err(err).Msgf("updater: Failed to read announcements")
return announcements
}
err = json.Unmarshal(body, &announcements)
if err != nil {
u.logger.Error().Err(err).Msgf("updater: Failed to unmarshal announcements")
return announcements
}
// Filter out announcements
var filteredAnnouncements []Announcement
for _, announcement := range announcements {
if announcement.Conditions == nil {
filteredAnnouncements = append(filteredAnnouncements, announcement)
continue
}
conditions := announcement.Conditions
if len(conditions.OS) > 0 && !slices.Contains(conditions.OS, runtime.GOOS) {
continue
}
filteredAnnouncements = append(filteredAnnouncements, announcement)
}
u.announcements = announcements
if u.wsEventManager.IsPresent() {
// Tell the client to send a request to fetch the latest announcements
u.wsEventManager.MustGet().SendEvent(events.CheckForAnnouncements, nil)
}
return announcements
}

View File

@@ -0,0 +1,207 @@
package updater
import (
"errors"
"fmt"
"io"
"runtime"
"strings"
"github.com/goccy/go-json"
)
// We fetch the latest release from the website first, if it fails we fallback to GitHub API
// This allows updates even if Seanime is removed from GitHub
var (
websiteUrl = "https://seanime.app/api/release"
fallbackGithubUrl = "https://api.github.com/repos/5rahim/seanime/releases/latest"
)
type (
GitHubResponse struct {
Url string `json:"url"`
AssetsUrl string `json:"assets_url"`
UploadUrl string `json:"upload_url"`
HtmlUrl string `json:"html_url"`
ID int64 `json:"id"`
NodeID string `json:"node_id"`
TagName string `json:"tag_name"`
TargetCommitish string `json:"target_commitish"`
Name string `json:"name"`
Draft bool `json:"draft"`
Prerelease bool `json:"prerelease"`
CreatedAt string `json:"created_at"`
PublishedAt string `json:"published_at"`
Assets []struct {
Url string `json:"url"`
ID int64 `json:"id"`
NodeID string `json:"node_id"`
Name string `json:"name"`
Label string `json:"label"`
ContentType string `json:"content_type"`
State string `json:"state"`
Size int64 `json:"size"`
DownloadCount int64 `json:"download_count"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
BrowserDownloadURL string `json:"browser_download_url"`
} `json:"assets"`
TarballURL string `json:"tarball_url"`
ZipballURL string `json:"zipball_url"`
Body string `json:"body"`
}
DocsResponse struct {
Release Release `json:"release"`
}
Release struct {
Url string `json:"url"`
HtmlUrl string `json:"html_url"`
NodeId string `json:"node_id"`
TagName string `json:"tag_name"`
Name string `json:"name"`
Body string `json:"body"`
PublishedAt string `json:"published_at"`
Released bool `json:"released"`
Version string `json:"version"`
Assets []ReleaseAsset `json:"assets"`
}
ReleaseAsset struct {
Url string `json:"url"`
Id int64 `json:"id"`
NodeId string `json:"node_id"`
Name string `json:"name"`
ContentType string `json:"content_type"`
Uploaded bool `json:"uploaded"`
Size int64 `json:"size"`
BrowserDownloadUrl string `json:"browser_download_url"`
}
)
func (u *Updater) GetReleaseName(version string) string {
arch := runtime.GOARCH
switch runtime.GOARCH {
case "amd64":
arch = "x86_64"
case "arm64":
arch = "arm64"
case "386":
return "i386"
}
oos := runtime.GOOS
switch runtime.GOOS {
case "linux":
oos = "Linux"
case "windows":
oos = "Windows"
case "darwin":
oos = "MacOS"
}
ext := "tar.gz"
if oos == "Windows" {
ext = "zip"
}
return fmt.Sprintf("seanime-%s_%s_%s.%s", version, oos, arch, ext)
}
func (u *Updater) fetchLatestRelease() (*Release, error) {
var release *Release
docsRelease, err := u.fetchLatestReleaseFromDocs()
if err != nil {
ghRelease, err := u.fetchLatestReleaseFromGitHub()
if err != nil {
return nil, err
}
release = ghRelease
} else {
release = docsRelease
}
return release, nil
}
func (u *Updater) fetchLatestReleaseFromGitHub() (*Release, error) {
response, err := u.client.Get(fallbackGithubUrl)
if err != nil {
return nil, err
}
defer response.Body.Close()
byteArr, readErr := io.ReadAll(response.Body)
if readErr != nil {
return nil, fmt.Errorf("error reading response: %w\n", readErr)
}
var res GitHubResponse
err = json.Unmarshal(byteArr, &res)
if err != nil {
return nil, err
}
release := &Release{
Url: res.Url,
HtmlUrl: res.HtmlUrl,
NodeId: res.NodeID,
TagName: res.TagName,
Name: res.Name,
Body: res.Body,
PublishedAt: res.PublishedAt,
Released: !res.Prerelease && !res.Draft,
Version: strings.TrimPrefix(res.TagName, "v"),
Assets: make([]ReleaseAsset, len(res.Assets)),
}
for i, asset := range res.Assets {
release.Assets[i] = ReleaseAsset{
Url: asset.Url,
Id: asset.ID,
NodeId: asset.NodeID,
Name: asset.Name,
ContentType: asset.ContentType,
Uploaded: asset.State == "uploaded",
Size: asset.Size,
BrowserDownloadUrl: asset.BrowserDownloadURL,
}
}
return release, nil
}
func (u *Updater) fetchLatestReleaseFromDocs() (*Release, error) {
response, err := u.client.Get(websiteUrl)
if err != nil {
return nil, err
}
defer response.Body.Close()
statusCode := response.StatusCode
if statusCode == 429 {
return nil, errors.New("rate limited, try again later")
}
if !((statusCode >= 200) && (statusCode <= 299)) {
return nil, fmt.Errorf("http error code: %d\n", statusCode)
}
byteArr, readErr := io.ReadAll(response.Body)
if readErr != nil {
return nil, fmt.Errorf("error reading response: %w", readErr)
}
var res DocsResponse
err = json.Unmarshal(byteArr, &res)
if err != nil {
return nil, err
}
res.Release.Version = strings.TrimPrefix(res.Release.TagName, "v")
return &res.Release, nil
}

View File

@@ -0,0 +1,138 @@
package updater
import (
"seanime/internal/constants"
"seanime/internal/events"
"seanime/internal/util"
"strings"
"testing"
"github.com/davecgh/go-spew/spew"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestUpdater_getReleaseName(t *testing.T) {
updater := Updater{}
t.Log(updater.GetReleaseName(constants.Version))
}
func TestUpdater_FetchLatestRelease(t *testing.T) {
fallbackGithubUrl = "https://seanimedud.app/api/releases" // simulate dead endpoint
//githubUrl = "https://api.github.com/repos/zbonfo/seanime-desktop/releases/latest"
updater := New(constants.Version, util.NewLogger(), events.NewMockWSEventManager(util.NewLogger()))
release, err := updater.fetchLatestRelease()
if err != nil {
t.Fatal(err)
}
if assert.NotNil(t, release) {
spew.Dump(release)
}
}
func TestUpdater_FetchLatestReleaseFromDocs(t *testing.T) {
updater := New(constants.Version, util.NewLogger(), events.NewMockWSEventManager(util.NewLogger()))
release, err := updater.fetchLatestReleaseFromDocs()
if err != nil {
t.Fatal(err)
}
if assert.NotNil(t, release) {
spew.Dump(release)
}
}
func TestUpdater_FetchLatestReleaseFromGitHub(t *testing.T) {
updater := New(constants.Version, util.NewLogger(), events.NewMockWSEventManager(util.NewLogger()))
release, err := updater.fetchLatestReleaseFromGitHub()
if err != nil {
t.Fatal(err)
}
if assert.NotNil(t, release) {
spew.Dump(release)
}
}
func TestUpdater_CompareVersion(t *testing.T) {
tests := []struct {
currVersion string
latestVersion string
shouldUpdate bool
}{
{
currVersion: "0.2.2",
latestVersion: "0.2.2",
shouldUpdate: false,
},
{
currVersion: "2.2.0-prerelease",
latestVersion: "2.2.0",
shouldUpdate: true,
},
{
currVersion: "2.2.0",
latestVersion: "2.2.0-prerelease",
shouldUpdate: false,
},
{
currVersion: "0.2.2",
latestVersion: "0.2.3",
shouldUpdate: true,
},
{
currVersion: "0.2.2",
latestVersion: "0.3.0",
shouldUpdate: true,
},
{
currVersion: "0.2.2",
latestVersion: "1.0.0",
shouldUpdate: true,
},
{
currVersion: "0.2.2",
latestVersion: "0.2.1",
shouldUpdate: false,
},
{
currVersion: "1.0.0",
latestVersion: "0.2.1",
shouldUpdate: false,
},
}
for _, tt := range tests {
t.Run(tt.latestVersion, func(t *testing.T) {
updateType, shouldUpdate := util.CompareVersion(tt.currVersion, tt.latestVersion)
assert.Equal(t, tt.shouldUpdate, shouldUpdate)
t.Log(tt.latestVersion, updateType)
})
}
}
func TestUpdater(t *testing.T) {
u := New(constants.Version, util.NewLogger(), events.NewMockWSEventManager(util.NewLogger()))
rl, err := u.GetLatestRelease()
require.NoError(t, err)
rl.TagName = "v2.2.1"
newV := strings.TrimPrefix(rl.TagName, "v")
updateTypeI, shouldUpdate := util.CompareVersion(u.CurrentVersion, newV)
isOlder := util.VersionIsOlderThan(u.CurrentVersion, newV)
util.Spew(isOlder)
util.Spew(shouldUpdate)
util.Spew(updateTypeI)
}

View File

@@ -0,0 +1,300 @@
package updater
import (
"archive/tar"
"archive/zip"
"compress/gzip"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"seanime/internal/util"
)
var (
ErrExtractionFailed = errors.New("could not extract assets")
)
// DownloadLatestRelease will download the latest release assets and extract them
// If the decompression fails, the returned string will be the directory to the compressed file
// If the decompression is successful, the returned string will be the directory to the extracted files
func (u *Updater) DownloadLatestRelease(assetUrl, dest string) (string, error) {
if u.LatestRelease == nil {
return "", errors.New("no new release found")
}
u.logger.Debug().Str("asset_url", assetUrl).Str("dest", dest).Msg("updater: Downloading latest release")
fpath, err := u.downloadAsset(assetUrl, dest)
if err != nil {
return "", err
}
dest = filepath.Dir(fpath)
u.logger.Info().Str("dest", dest).Msg("updater: Downloaded release assets")
fp, err := u.decompressAsset(fpath, "")
if err != nil {
u.logger.Error().Err(err).Msg("updater: Failed to decompress release assets")
return fp, ErrExtractionFailed
}
dest = fp
u.logger.Info().Str("dest", dest).Msg("updater: Successfully decompressed downloaded release assets")
return dest, nil
}
func (u *Updater) DownloadLatestReleaseN(assetUrl, dest, folderName string) (string, error) {
if u.LatestRelease == nil {
return "", errors.New("no new release found")
}
u.logger.Debug().Str("asset_url", assetUrl).Str("dest", dest).Msg("updater: Downloading latest release")
fpath, err := u.downloadAsset(assetUrl, dest)
if err != nil {
return "", err
}
dest = filepath.Dir(fpath)
u.logger.Info().Str("dest", dest).Msg("updater: Downloaded release assets")
fp, err := u.decompressAsset(fpath, folderName)
if err != nil {
u.logger.Error().Err(err).Msg("updater: Failed to decompress release assets")
return fp, err
}
dest = fp
u.logger.Info().Str("dest", dest).Msg("updater: Successfully decompressed downloaded release assets")
return dest, nil
}
func (u *Updater) decompressZip(archivePath string, folderName string) (dest string, err error) {
topFolderName := "seanime-" + u.LatestRelease.Version
if folderName != "" {
topFolderName = folderName
}
// "/seanime-repo/seanime-v1.0.0.zip" -> "/seanime-repo/seanime-1.0.0/"
dest = filepath.Join(filepath.Dir(archivePath), topFolderName) // "/seanime-repo/seanime-v1.0.0"
// Check if the destination folder already exists
if _, err := os.Stat(dest); err == nil {
return dest, errors.New("destination folder already exists")
}
// Create the destination folder
err = os.MkdirAll(dest, os.ModePerm)
if err != nil {
return dest, err
}
r, err := zip.OpenReader(archivePath)
if err != nil {
return dest, err
}
defer r.Close()
u.logger.Debug().Msg("updater: Decompressing release assets (zip)")
for _, f := range r.File {
fpath := filepath.Join(dest, f.Name)
if f.FileInfo().IsDir() {
os.MkdirAll(fpath, os.ModePerm)
continue
}
if err = os.MkdirAll(filepath.Dir(fpath), os.ModePerm); err != nil {
return dest, err
}
outFile, err := os.OpenFile(fpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
if err != nil {
return dest, err
}
rc, err := f.Open()
if err != nil {
outFile.Close()
return dest, err
}
_, err = io.Copy(outFile, rc)
outFile.Close()
rc.Close()
if err != nil {
return dest, err
}
}
r.Close()
err = os.Remove(archivePath)
if err != nil {
u.logger.Error().Err(err).Msg("updater: Failed to remove compressed file")
return dest, nil
}
u.logger.Debug().Msg("updater: Decompressed release assets (zip)")
return dest, nil
}
func (u *Updater) decompressTarGz(archivePath string, folderName string) (dest string, err error) {
topFolderName := "seanime-" + u.LatestRelease.Version
if folderName != "" {
topFolderName = folderName
}
dest = filepath.Join(filepath.Dir(archivePath), topFolderName)
if _, err := os.Stat(dest); err == nil {
return dest, errors.New("destination folder already exists")
}
err = os.MkdirAll(dest, os.ModePerm)
if err != nil {
return dest, err
}
file, err := os.Open(archivePath)
if err != nil {
return dest, err
}
defer file.Close()
gzr, err := gzip.NewReader(file)
if err != nil {
return dest, err
}
defer gzr.Close()
tr := tar.NewReader(gzr)
u.logger.Debug().Msg("updater: Decompressing release assets (gzip)")
for {
header, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return dest, err
}
fpath := filepath.Join(dest, header.Name)
if header.Typeflag == tar.TypeDir {
if err := os.MkdirAll(fpath, os.ModePerm); err != nil {
return dest, err
}
} else {
if err := os.MkdirAll(filepath.Dir(fpath), os.ModePerm); err != nil {
return dest, err
}
outFile, err := os.Create(fpath)
if err != nil {
return dest, err
}
if _, err := io.Copy(outFile, tr); err != nil {
outFile.Close()
return dest, err
}
outFile.Close()
}
}
gzr.Close()
file.Close()
err = os.Remove(archivePath)
if err != nil {
u.logger.Error().Err(err).Msg("updater: Failed to remove compressed file")
return dest, nil
}
u.logger.Debug().Msg("updater: Decompressed release assets (gzip)")
return dest, nil
}
// decompressAsset will uncompress the release assets and delete the compressed folder
// - "/seanime-repo/seanime-v1.0.0.zip" -> "/seanime-repo/seanime-1.0.0/"
func (u *Updater) decompressAsset(archivePath string, folderName string) (dest string, err error) {
defer util.HandlePanicInModuleWithError("updater/download/decompressAsset", &err)
u.logger.Debug().Str("archive_path", archivePath).Msg("updater: Decompressing release assets")
ext := filepath.Ext(archivePath)
if ext == ".zip" {
return u.decompressZip(archivePath, folderName)
} else if ext == ".gz" {
return u.decompressTarGz(archivePath, folderName)
}
u.logger.Error().Msg("updater: Failed to decompress release assets, unsupported archive format")
return "", fmt.Errorf("unsupported archive format: %s", ext)
}
// downloadAsset will download the release assets to a folder
// - "seanime-v1.zip" -> "/seanime-repo/seanime-v1.zip"
func (u *Updater) downloadAsset(assetUrl, dest string) (fp string, err error) {
defer util.HandlePanicInModuleWithError("updater/download/downloadAsset", &err)
u.logger.Debug().Msg("updater: Downloading assets")
fp = u.getFilePath(assetUrl, dest)
// Get the data
resp, err := http.Get(assetUrl)
if err != nil {
return "", err
}
defer resp.Body.Close()
// Check if the request was successful (status code 200)
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("failed to download file, %s", resp.Status)
}
// Create the destination folder if it doesn't exist
err = os.MkdirAll(dest, os.ModePerm)
if err != nil {
u.logger.Error().Err(err).Msg("updater: Failed to download assets")
return "", err
}
// Create the file
out, err := os.Create(fp)
if err != nil {
u.logger.Error().Err(err).Msg("updater: Failed to download assets")
return "", err
}
defer out.Close()
// Write the body to file
_, err = io.Copy(out, resp.Body)
if err != nil {
u.logger.Error().Err(err).Msg("updater: Failed to download assets")
return "", err
}
return
}
func (u *Updater) getFilePath(url, dest string) string {
// Get the file name from the URL
fileName := filepath.Base(url)
return filepath.Join(dest, fileName)
}

View File

@@ -0,0 +1,90 @@
package updater
import (
"github.com/samber/lo"
"os"
"seanime/internal/util"
"strings"
"testing"
)
func TestUpdater_DownloadLatestRelease(t *testing.T) {
updater := New("0.2.0", util.NewLogger(), nil)
//tempDir := "E:\\SEANIME-REPO-TEST"
tempDir := t.TempDir()
// Get the latest release
release, err := updater.GetLatestRelease()
if err != nil {
t.Fatal(err)
}
// Find the asset (zip file)
asset, ok := lo.Find(release.Assets, func(asset ReleaseAsset) bool {
return strings.HasSuffix(asset.BrowserDownloadUrl, "Windows_x86_64.zip")
})
if !ok {
t.Fatal("could not find release asset")
}
// Download the asset
folderPath, err := updater.DownloadLatestRelease(asset.BrowserDownloadUrl, tempDir)
if err != nil {
t.Log("Downloaded to:", folderPath)
t.Fatal(err)
}
t.Log("Downloaded to:", folderPath)
// Check if the folder is not empty
entries, err := os.ReadDir(folderPath)
if err != nil {
t.Fatal(err)
}
if len(entries) == 0 {
t.Fatal("folder is empty")
}
for _, entry := range entries {
t.Log(entry.Name())
}
// Delete the folder
if err := os.RemoveAll(folderPath); err != nil {
t.Fatal(err)
}
// Find the asset (.tar.gz file)
asset2, ok := lo.Find(release.Assets, func(asset ReleaseAsset) bool {
return strings.HasSuffix(asset.BrowserDownloadUrl, "MacOS_arm64.tar.gz")
})
if !ok {
t.Fatal("could not find release asset")
}
// Download the asset
folderPath2, err := updater.DownloadLatestRelease(asset2.BrowserDownloadUrl, tempDir)
if err != nil {
t.Log("Downloaded to:", folderPath2)
t.Fatal(err)
}
t.Log("Downloaded to:", folderPath2)
// Check if the folder is not empty
entries2, err := os.ReadDir(folderPath2)
if err != nil {
t.Fatal(err)
}
if len(entries2) == 0 {
t.Fatal("folder is empty")
}
for _, entry := range entries2 {
t.Log(entry.Name())
}
}

View File

@@ -0,0 +1,356 @@
package updater
import (
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"seanime/internal/constants"
"seanime/internal/util"
"slices"
"strings"
"syscall"
"time"
"github.com/rs/zerolog"
"github.com/samber/lo"
"github.com/samber/mo"
)
const (
tempReleaseDir = "seanime_new_release"
backupDirName = "backup_restore_if_failed"
)
type (
SelfUpdater struct {
logger *zerolog.Logger
breakLoopCh chan struct{}
originalExePath mo.Option[string]
updater *Updater
fallbackDest string
tmpExecutableName string
}
)
func NewSelfUpdater() *SelfUpdater {
logger := util.NewLogger()
ret := &SelfUpdater{
logger: logger,
breakLoopCh: make(chan struct{}),
originalExePath: mo.None[string](),
updater: New(constants.Version, logger, nil),
}
ret.tmpExecutableName = "seanime.exe.old"
switch runtime.GOOS {
case "windows":
ret.tmpExecutableName = "seanime.exe.old"
default:
ret.tmpExecutableName = "seanime.old"
}
go func() {
// Delete all files with the .old extension
exePath := getExePath()
entries, err := os.ReadDir(filepath.Dir(exePath))
if err != nil {
return
}
for _, entry := range entries {
if strings.HasSuffix(entry.Name(), ".old") {
_ = os.RemoveAll(filepath.Join(filepath.Dir(exePath), entry.Name()))
}
}
}()
return ret
}
// Started returns a channel that will be closed when the app loop should be broken
func (su *SelfUpdater) Started() <-chan struct{} {
return su.breakLoopCh
}
func (su *SelfUpdater) StartSelfUpdate(fallbackDestination string) {
su.fallbackDest = fallbackDestination
close(su.breakLoopCh)
}
// recover will just print a message and attempt to download the latest release
func (su *SelfUpdater) recover(assetUrl string) {
if su.originalExePath.IsAbsent() {
return
}
if su.fallbackDest != "" {
su.logger.Info().Str("dest", su.fallbackDest).Msg("selfupdate: Attempting to download the latest release")
_, _ = su.updater.DownloadLatestRelease(assetUrl, su.fallbackDest)
}
su.logger.Error().Msg("selfupdate: Failed to install update. Update downloaded to 'seanime_new_release'")
}
func getExePath() string {
exe, err := os.Executable() // /path/to/seanime.exe
if err != nil {
return ""
}
exePath, err := filepath.EvalSymlinks(exe) // /path/to/seanime.exe
if err != nil {
return ""
}
return exePath
}
func (su *SelfUpdater) Run() error {
exePath := getExePath()
su.originalExePath = mo.Some(exePath)
exeDir := filepath.Dir(exePath) // /path/to
var files []string
switch runtime.GOOS {
case "windows":
files = []string{
"seanime.exe",
"LICENSE",
}
default:
files = []string{
"seanime",
"LICENSE",
}
}
// Get the new assets
su.logger.Info().Msg("selfupdate: Fetching latest release info")
// Get the latest release
release, err := su.updater.GetLatestRelease()
if err != nil {
su.logger.Error().Err(err).Msg("selfupdate: Failed to get latest release")
return err
}
// Find the asset
assetName := su.updater.GetReleaseName(release.Version)
asset, ok := lo.Find(release.Assets, func(asset ReleaseAsset) bool {
return asset.Name == assetName
})
if !ok {
su.logger.Error().Msg("selfupdate: Asset not found")
return err
}
su.logger.Info().Msg("selfupdate: Downloading latest release")
// Download the asset to exeDir/seanime_tmp
newReleaseDir, err := su.updater.DownloadLatestReleaseN(asset.BrowserDownloadUrl, exeDir, tempReleaseDir)
if err != nil {
su.logger.Error().Err(err).Msg("selfupdate: Failed to download latest release")
return err
}
// DEVNOTE: Past this point, the application will be broken
// Use "recover" to attempt to recover the application
su.logger.Info().Msg("selfupdate: Creating backup")
// Delete the backup directory if it exists
_ = os.RemoveAll(filepath.Join(exeDir, backupDirName))
// Create the backup directory
backupDir := filepath.Join(exeDir, backupDirName)
_ = os.MkdirAll(backupDir, 0755)
// Backup the current assets
// Copy the files to the backup directory
// seanime.exe + /backup_restore_if_failed/seanime.exe
// LICENSE + /backup_restore_if_failed/LICENSE
for _, file := range files {
// We don't check for errors here because we don't want to stop the update process if LICENSE is not found for example
_ = copyFile(filepath.Join(exeDir, file), filepath.Join(backupDir, file))
}
su.logger.Info().Msg("selfupdate: Renaming assets")
time.Sleep(2 * time.Second)
renamingFailed := false
failedEntryNames := make([]string, 0)
// Rename the current assets
// seanime.exe -> seanime.exe.old
// LICENSE -> LICENSE.old
for _, file := range files {
err = os.Rename(filepath.Join(exeDir, file), filepath.Join(exeDir, file+".old"))
// We care about the error ONLY if the file is the executable
if err != nil && (file == "seanime" || file == "seanime.exe") {
renamingFailed = true
failedEntryNames = append(failedEntryNames, file)
//su.recover()
su.logger.Error().Err(err).Msg("selfupdate: Failed to rename entry")
//return err
}
}
if renamingFailed {
fmt.Println("---------------------------------")
fmt.Println("A second attempt will be made in 30 seconds")
fmt.Println("---------------------------------")
time.Sleep(30 * time.Second)
// Here `failedEntryNames` should only contain NECESSARY files that failed to rename
for _, entry := range failedEntryNames {
err = os.Rename(filepath.Join(exeDir, entry), filepath.Join(exeDir, entry+".old"))
if err != nil {
su.logger.Error().Err(err).Msg("selfupdate: Failed to rename entry")
su.recover(asset.BrowserDownloadUrl)
return err
}
}
}
// Now all the files have been renamed, we can move the new release to the exeDir
su.logger.Info().Msg("selfupdate: Moving assets")
// Move the new release elements to the exeDir
err = moveContents(newReleaseDir, exeDir)
if err != nil {
su.recover(asset.BrowserDownloadUrl)
su.logger.Error().Err(err).Msg("selfupdate: Failed to move assets")
return err
}
_ = os.Chmod(su.originalExePath.MustGet(), 0755)
// Delete the new release directory
_ = os.RemoveAll(newReleaseDir)
// Start the new executable
su.logger.Info().Msg("selfupdate: Starting new executable")
switch runtime.GOOS {
case "windows":
err = openWindows(su.originalExePath.MustGet())
case "darwin":
err = openMacOS(su.originalExePath.MustGet())
case "linux":
err = openLinux(su.originalExePath.MustGet())
default:
su.logger.Fatal().Msg("selfupdate: Unsupported platform")
}
// Remove .old files (will fail on Windows for executable)
// Remove seanime.exe.old and LICENSE.old
for _, file := range files {
_ = os.RemoveAll(filepath.Join(exeDir, file+".old"))
}
// Remove the backup directory
_ = os.RemoveAll(backupDir)
os.Exit(0)
return nil
}
func openWindows(path string) error {
cmd := util.NewCmd("cmd", "/c", "start", "cmd", "/k", path)
return cmd.Start()
}
func openMacOS(path string) error {
script := fmt.Sprintf(`
tell application "Terminal"
do script "%s"
activate
end tell`, path)
cmd := exec.Command("osascript", "-e", script)
return cmd.Start()
}
func openLinux(path string) error {
// Filter out the -update flag or we end up in an infinite update loop
filteredArgs := slices.DeleteFunc(os.Args, func(s string) bool { return s == "-update" })
// Replace the current process with the updated executable
return syscall.Exec(path, filteredArgs, os.Environ())
}
// moveContents moves contents of newReleaseDir to exeDir without deleting existing files
func moveContents(newReleaseDir, exeDir string) error {
// Ensure exeDir exists
if err := os.MkdirAll(exeDir, 0755); err != nil {
return err
}
// Copy contents of newReleaseDir to exeDir
return copyDir(newReleaseDir, exeDir)
}
// copyFile copies a single file from src to dst
func copyFile(src, dst string) error {
sourceFile, err := os.Open(src)
if err != nil {
return err
}
defer sourceFile.Close()
// Create destination directory if it does not exist
if err = os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
return err
}
destinationFile, err := os.Create(dst)
if err != nil {
return err
}
defer destinationFile.Close()
_, err = io.Copy(destinationFile, sourceFile)
return err
}
// copyDir recursively copies a directory tree, attempting to preserve permissions.
func copyDir(src string, dst string) error {
src = filepath.Clean(src)
dst = filepath.Clean(dst)
info, err := os.Stat(src)
if err != nil {
return err
}
if err := os.MkdirAll(dst, info.Mode()); err != nil {
return err
}
entries, err := os.ReadDir(src)
if err != nil {
return err
}
for _, entry := range entries {
srcPath := filepath.Join(src, entry.Name())
dstPath := filepath.Join(dst, entry.Name())
if entry.IsDir() {
if err := copyDir(srcPath, dstPath); err != nil {
return err
}
} else {
if err := copyFile(srcPath, dstPath); err != nil {
return err
}
}
}
return nil
}

View File

@@ -0,0 +1,127 @@
package updater
import (
"net/http"
"seanime/internal/events"
"seanime/internal/util"
"strings"
"time"
"github.com/rs/zerolog"
"github.com/samber/mo"
)
const (
PatchRelease = "patch"
MinorRelease = "minor"
MajorRelease = "major"
)
type (
Updater struct {
CurrentVersion string
hasCheckedForUpdate bool
LatestRelease *Release
checkForUpdate bool
logger *zerolog.Logger
client *http.Client
wsEventManager mo.Option[events.WSEventManagerInterface]
announcements []Announcement
}
Update struct {
Release *Release `json:"release,omitempty"`
CurrentVersion string `json:"current_version,omitempty"`
Type string `json:"type"`
}
)
func New(currVersion string, logger *zerolog.Logger, wsEventManager events.WSEventManagerInterface) *Updater {
ret := &Updater{
CurrentVersion: currVersion,
hasCheckedForUpdate: false,
checkForUpdate: true,
logger: logger,
client: &http.Client{
Timeout: time.Second * 10,
},
wsEventManager: mo.None[events.WSEventManagerInterface](),
}
if wsEventManager != nil {
ret.wsEventManager = mo.Some[events.WSEventManagerInterface](wsEventManager)
}
return ret
}
func (u *Updater) GetLatestUpdate() (*Update, error) {
if !u.checkForUpdate {
return nil, nil
}
rl, err := u.GetLatestRelease()
if err != nil {
return nil, err
}
if rl == nil || rl.TagName == "" {
return nil, nil
}
if !rl.Released {
return nil, nil
}
newV := strings.TrimPrefix(rl.TagName, "v")
updateTypeI, shouldUpdate := util.CompareVersion(u.CurrentVersion, newV)
if !shouldUpdate {
return nil, nil
}
updateType := ""
if updateTypeI == -1 {
updateType = MinorRelease
} else if updateTypeI == -2 {
updateType = PatchRelease
} else if updateTypeI == -3 {
updateType = MajorRelease
}
return &Update{
Release: rl,
CurrentVersion: u.CurrentVersion,
Type: updateType,
}, nil
}
func (u *Updater) ShouldRefetchReleases() {
u.hasCheckedForUpdate = false
if u.wsEventManager.IsPresent() {
// Tell the client to send a request to fetch the latest release
u.wsEventManager.MustGet().SendEvent(events.CheckForUpdates, nil)
}
}
func (u *Updater) SetEnabled(checkForUpdate bool) {
u.checkForUpdate = checkForUpdate
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// GetLatestRelease returns the latest release from the GitHub repository.
func (u *Updater) GetLatestRelease() (*Release, error) {
if u.hasCheckedForUpdate {
return u.LatestRelease, nil
}
release, err := u.fetchLatestRelease()
if err != nil {
return nil, err
}
u.hasCheckedForUpdate = true
u.LatestRelease = release
return release, nil
}

View File

@@ -0,0 +1,22 @@
package updater
import (
"seanime/internal/util"
"testing"
"github.com/stretchr/testify/require"
)
func TestUpdater_GetLatestUpdate(t *testing.T) {
fallbackGithubUrl = "https://seanime.app/api/releases" // simulate dead endpoint
u := New("2.0.2", util.NewLogger(), nil)
update, err := u.GetLatestUpdate()
require.NoError(t, err)
require.NotNil(t, update)
util.Spew(update)
}