diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml
index ce312d74..a6ed3645 100644
--- a/.github/release-drafter.yml
+++ b/.github/release-drafter.yml
@@ -57,7 +57,7 @@ template: |
📥 |
- | 🐧 Linux |
+ 🐧 Linux |
Flathub |
amd64 |
📥 |
@@ -68,14 +68,22 @@ template: |
📥 |
- DEB |
+ DEB |
amd64 |
📥 |
- AppImage |
+ arm64 |
+ 📥 |
+
+
+ AppImage |
amd64 |
- 📥 |
+ 📥 |
+
+
+ | arm64 |
+ 📥 |
| 🤖 Android |
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 23fcb4c0..deba082c 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -52,10 +52,11 @@ jobs:
mv ChineseSimplified.isl "C:\Program Files (x86)\Inno Setup 6\Languages\"
go build -tags nosqlite -ldflags="-w -s -X github.com/GopeedLab/gopeed/pkg/base.Version=$env:VERSION" -buildmode=c-shared -o ui/flutter/windows/libgopeed.dll github.com/GopeedLab/gopeed/bind/desktop
- go build -ldflags="-w -s" -o ui/flutter/assets/host/host.exe github.com/GopeedLab/gopeed/cmd/host
+ go build -ldflags="-w -s" -o ui/flutter/assets/exec/host.exe github.com/GopeedLab/gopeed/cmd/host
+ go build -ldflags="-w -s" -o ui/flutter/assets/exec/updater.exe github.com/GopeedLab/gopeed/cmd/updater
cd ui/flutter
$TAG = "v$env:VERSION"
- flutter build windows
+ flutter build windows --dart-define="UPDATE_CHANNEL=windowsPortable"
$mingw = "C:\Program Files\Git\mingw64\bin"
$system = "C:\Windows\System32"
$release = "build\windows\x64\runner\Release\"
@@ -69,6 +70,7 @@ jobs:
New-Item -Path build\windows\Output -ItemType Directory
Compress-Archive -Path "$release*" -DestinationPath "build\windows\Output\Gopeed-$TAG-windows-amd64-portable.zip"
+ flutter build windows --dart-define="UPDATE_CHANNEL=windowsInstaller"
cd build/windows
echo @"
; Script generated by the Inno Setup Script Wizard.
@@ -152,12 +154,14 @@ jobs:
run: |
go build -tags nosqlite -ldflags="-w -s -X github.com/GopeedLab/gopeed/pkg/base.Version=$VERSION" -buildmode=c-shared -o libgopeed.dylib github.com/GopeedLab/gopeed/bind/desktop
go build -ldflags="-w -s" github.com/GopeedLab/gopeed/cmd/host
+ go build -ldflags="-w -s" github.com/GopeedLab/gopeed/cmd/updater
- uses: actions/upload-artifact@v4
with:
name: macos-arm64-lib
path: |
libgopeed.dylib
host
+ updater
build-macos:
if: ${{ github.event.inputs.platform == 'all' || github.event.inputs.platform == 'macos' }}
runs-on: macos-13
@@ -190,20 +194,24 @@ jobs:
# amd64 lib
go build -tags nosqlite -ldflags="-w -s -X github.com/GopeedLab/gopeed/pkg/base.Version=$VERSION" -buildmode=c-shared -o ui/flutter/lib-amd64/libgopeed.dylib github.com/GopeedLab/gopeed/bind/desktop
go build -ldflags="-w -s" -o ui/flutter/lib-amd64/host github.com/GopeedLab/gopeed/cmd/host
+ go build -ldflags="-w -s" -o ui/flutter/lib-amd64/updater github.com/GopeedLab/gopeed/cmd/updater
# universal binary
mkdir -p ui/flutter/macos/Frameworks
cp ui/flutter/lib-amd64/libgopeed.dylib ui/flutter/macos/Frameworks/amd64-lib
cp ui/flutter/lib-arm64/libgopeed.dylib ui/flutter/macos/Frameworks/arm64-lib
cp ui/flutter/lib-amd64/host ui/flutter/macos/Frameworks/amd64-host
cp ui/flutter/lib-arm64/host ui/flutter/macos/Frameworks/arm64-host
+ cp ui/flutter/lib-amd64/updater ui/flutter/macos/Frameworks/amd64-updater
+ cp ui/flutter/lib-arm64/updater ui/flutter/macos/Frameworks/arm64-updater
cd ui/flutter/macos/Frameworks
lipo -create -output libgopeed.dylib amd64-lib arm64-lib
lipo -create -output host amd64-host arm64-host
- rm -rf amd64-lib arm64-lib amd64-host arm64-host
- mv host ../../assets/host/host
+ lipo -create -output updater amd64-updater arm64-updater
+ rm -rf amd64-lib arm64-lib amd64-host arm64-host amd64-updater arm64-updater
+ mv host ../../assets/exec/host
cd $PROJECT_DIR/ui/flutter
- flutter build macos
+ flutter build macos --dart-define="UPDATE_CHANNEL=macosDmg"
cd build/macos/Build/Products/Release
cat>appdmg.json< distribute_options.yaml
+ output: dist/
+ releases:
+ - name: linux
+ jobs:
+ - name: appimage
+ package:
+ platform: linux
+ target: appimage
+ build_args:
+ dart-define:
+ UPDATE_CHANNEL: linuxAppImage
+ - name: deb
+ package:
+ platform: linux
+ target: deb
+ build_args:
+ dart-define:
+ UPDATE_CHANNEL: linuxDeb
+ EOF
dart pub global activate -sgit https://github.com/GopeedLab/flutter_distributor.git --git-path packages/flutter_distributor
- flutter_distributor package --platform linux --targets appimage,deb
+ flutter_distributor release --name linux
cd dist/*
- mv gopeed-*-linux.AppImage Gopeed-v${VERSION}-linux-x86_64.AppImage
- mv gopeed-*-linux.deb Gopeed-v${VERSION}-linux-amd64.deb
- - name: Upload
- uses: shogo82148/actions-upload-release-asset@v1
- with:
- upload_url: ${{ needs.get-release.outputs.upload_url }}
- asset_path: ui/flutter/dist/*/*
- overwrite: true
- build-linux-arm64:
- if: ${{ github.event.inputs.platform == 'all' || github.event.inputs.platform == 'linux-arm64' }}
- runs-on: ubuntu-22.04-arm
- needs: [get-release]
- steps:
- - uses: actions/checkout@v3
- - uses: actions/setup-go@v4
- with:
- go-version: ${{ env.GO_VERSION }}
- - uses: subosito/flutter-action@v2
- with:
- channel: master
- flutter-version: ${{ env.FLUTTER_VERSION }}
- - name: Install dependencies
- run: |
- sudo apt update -y
- # flutter build dependencies
- sudo apt install -y ninja-build libgtk-3-dev libayatana-appindicator3-1 libayatana-appindicator3-dev
- # appimage build dependencies
- sudo apt install -y libfuse2 locate
- wget -O appimagetool "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-aarch64.AppImage"
- chmod +x appimagetool
- sudo mv appimagetool /usr/local/bin/
- - name: Build
- env:
- VERSION: ${{ needs.get-release.outputs.version }}
- run: |
- go build -tags nosqlite -ldflags="-w -s -X github.com/GopeedLab/gopeed/pkg/base.Version=$VERSION" -buildmode=c-shared -o ui/flutter/linux/bundle/lib/libgopeed.so github.com/GopeedLab/gopeed/bind/desktop
- go build -ldflags="-w -s" -o ui/flutter/assets/host/host github.com/GopeedLab/gopeed/cmd/host
- cd ui/flutter
- dart pub global activate -sgit https://github.com/GopeedLab/flutter_distributor.git --git-path packages/flutter_distributor
- flutter_distributor package --platform linux --targets appimage,deb
- cd dist/*
- mv gopeed-*-linux.AppImage Gopeed-v${VERSION}-linux-aarch64.AppImage
- mv gopeed-*-linux.deb Gopeed-v${VERSION}-linux-arm64.deb
+
+ # Get current running architecture, x86_64 -> amd64 and aarch64 -> arm64
+ ARCH="amd64"
+ if [[ "${{ matrix.os }}" == *-arm ]]; then
+ ARCH="arm64"
+ fi
+
+ mv gopeed-*-linux.AppImage Gopeed-v${VERSION}-linux-${ARCH}.AppImage
+ mv gopeed-*-linux.deb Gopeed-v${VERSION}-linux-${ARCH}.deb
- name: Upload
uses: shogo82148/actions-upload-release-asset@v1
with:
@@ -321,7 +328,8 @@ jobs:
VERSION: ${{ needs.get-release.outputs.version }}
run: |
go build -tags nosqlite -ldflags="-w -s -X github.com/GopeedLab/gopeed/pkg/base.Version=$VERSION" -buildmode=c-shared -o ui/flutter/linux/bundle/lib/libgopeed.so github.com/GopeedLab/gopeed/bind/desktop
- go build -ldflags="-w -s" -o ui/flutter/assets/host/host github.com/GopeedLab/gopeed/cmd/host
+ go build -ldflags="-w -s" -o ui/flutter/assets/exec/host github.com/GopeedLab/gopeed/cmd/host
+ go build -ldflags="-w -s" -o ui/flutter/assets/exec/updater github.com/GopeedLab/gopeed/cmd/updater
cd ui/flutter
sudo snap install snapcraft --classic
@@ -398,7 +406,7 @@ jobs:
set +e
dart pub get
set -eux
- flutter build linux --release -v
+ flutter build linux --dart-define="UPDATE_CHANNEL=linuxSnap"
mkdir -p \$CRAFT_PART_INSTALL/bin/
cp -r build/linux/*/release/bundle/* \$CRAFT_PART_INSTALL/bin/
gpu-2404:
@@ -463,7 +471,7 @@ jobs:
gomobile bind -tags nosqlite -ldflags="-w -s -checklinkname=0 -X github.com/GopeedLab/gopeed/pkg/base.Version=$VERSION" -o ui/flutter/android/app/libs/libgopeed.aar -target=android -androidapi 21 -javapkg=com.gopeed github.com/GopeedLab/gopeed/bind/mobile
cd ui/flutter
echo $APK_KEYSTORE | base64 -di > android/app/upload-keystore.jks
- flutter build apk
+ flutter build apk --dart-define="UPDATE_CHANNEL=androidApk"
mkdir dist
cp build/app/outputs/flutter-apk/app-release.apk dist/Gopeed-v$VERSION-android.apk
- name: Upload
@@ -493,7 +501,7 @@ jobs:
gomobile init
gomobile bind -tags nosqlite -ldflags="-w -s -X github.com/GopeedLab/gopeed/pkg/base.Version=$VERSION" -o ui/flutter/ios/Frameworks/Libgopeed.xcframework -target=ios github.com/GopeedLab/gopeed/bind/mobile
cd ui/flutter
- flutter build ios --no-codesign
+ flutter build ios --no-codesign --dart-define="UPDATE_CHANNEL=iosIpa"
mkdir Payload
cp -r build/ios/iphoneos/Runner.app Payload
zip -r -y Payload.zip Payload/Runner.app
@@ -639,7 +647,7 @@ jobs:
- name: Build flutter web
run: |
cd ui/flutter
- flutter build web --no-web-resources-cdn
+ flutter build web --no-web-resources-cdn --dart-define="UPDATE_CHANNEL=docker"
cd ../../
rm -rf cmd/web/dist
cp -r ui/flutter/build/web cmd/web/dist
diff --git a/README.md b/README.md
index 8a51b74c..83686867 100644
--- a/README.md
+++ b/README.md
@@ -41,7 +41,7 @@ Visit ✈ [Official Website](https://gopeed.com) | 📖 [Official Docs](https://
📥 |
- | 🐧 Linux |
+ 🐧 Linux |
Flathub |
amd64 |
📥 |
@@ -52,14 +52,22 @@ Visit ✈ [Official Website](https://gopeed.com) | 📖 [Official Docs](https://
📥 |
- DEB |
+ DEB |
amd64 |
📥 |
- AppImage |
+ arm64 |
+ 📥 |
+
+
+ AppImage |
amd64 |
- 📥 |
+ 📥 |
+
+
+ | arm64 |
+ 📥 |
| 🤖 Android |
diff --git a/README_ja-JP.md b/README_ja-JP.md
index f124d92a..54eefc4d 100644
--- a/README_ja-JP.md
+++ b/README_ja-JP.md
@@ -41,7 +41,7 @@ Gopeed (正式名 Go Speed) は `Golang` + `Flutter` によって開発された
📥 |
- | 🐧 Linux |
+ 🐧 Linux |
Flathub |
amd64 |
📥 |
@@ -52,14 +52,22 @@ Gopeed (正式名 Go Speed) は `Golang` + `Flutter` によって開発された
📥 |
- DEB |
+ DEB |
amd64 |
📥 |
- AppImage |
+ arm64 |
+ 📥 |
+
+
+ AppImage |
amd64 |
- 📥 |
+ 📥 |
+
+
+ | arm64 |
+ 📥 |
| 🤖 Android |
diff --git a/README_vi-VN.md b/README_vi-VN.md
index 38cb65c4..2b0c7c6b 100644
--- a/README_vi-VN.md
+++ b/README_vi-VN.md
@@ -41,7 +41,7 @@ Truy cập ✈ [Trang web chính thức](https://gopeed.com) | 📖 [Tài liệu
📥 |
- | 🐧 Linux |
+ 🐧 Linux |
Flathub |
amd64 |
📥 |
@@ -52,14 +52,22 @@ Truy cập ✈ [Trang web chính thức](https://gopeed.com) | 📖 [Tài liệu
📥 |
- DEB |
+ DEB |
amd64 |
📥 |
- AppImage |
+ arm64 |
+ 📥 |
+
+
+ AppImage |
amd64 |
- 📥 |
+ 📥 |
+
+
+ | arm64 |
+ 📥 |
| 🤖 Android |
diff --git a/README_zh-CN.md b/README_zh-CN.md
index 1be10798..501dfdb6 100644
--- a/README_zh-CN.md
+++ b/README_zh-CN.md
@@ -41,7 +41,7 @@ Gopeed(全称 Go Speed),直译过来中文名叫做`够快下载器`(不
📥 |
- | 🐧 Linux |
+ 🐧 Linux |
Flathub |
amd64 |
📥 |
@@ -52,14 +52,22 @@ Gopeed(全称 Go Speed),直译过来中文名叫做`够快下载器`(不
📥 |
- DEB |
+ DEB |
amd64 |
📥 |
- AppImage |
+ arm64 |
+ 📥 |
+
+
+ AppImage |
amd64 |
- 📥 |
+ 📥 |
+
+
+ | arm64 |
+ 📥 |
| 🤖 Android |
diff --git a/README_zh-TW.md b/README_zh-TW.md
index df3929bd..feaac672 100644
--- a/README_zh-TW.md
+++ b/README_zh-TW.md
@@ -41,7 +41,7 @@ Gopeed(全稱 Go Speed),是一款使用`Golang`+`Flutter`編寫的高速
📥 |
- | 🐧 Linux |
+ 🐧 Linux |
Flathub |
amd64 |
📥 |
@@ -52,14 +52,22 @@ Gopeed(全稱 Go Speed),是一款使用`Golang`+`Flutter`編寫的高速
📥 |
- DEB |
+ DEB |
amd64 |
📥 |
- AppImage |
+ arm64 |
+ 📥 |
+
+
+ AppImage |
amd64 |
- 📥 |
+ 📥 |
+
+
+ | arm64 |
+ 📥 |
| 🤖 Android |
diff --git a/cmd/host/main.go b/cmd/host/main.go
index db6f4da7..b918f40a 100644
--- a/cmd/host/main.go
+++ b/cmd/host/main.go
@@ -55,6 +55,8 @@ var apiMap = map[string]func(params json.RawMessage) (data any, err error){
},
}
+// go build -ldflags="-s -w" -o ui/flutter/assets/exec/ github.com/GopeedLab/gopeed/cmd/host
+
func main() {
for {
// Read message length (first 4 bytes)
diff --git a/cmd/updater/main.go b/cmd/updater/main.go
new file mode 100644
index 00000000..cdbb3afd
--- /dev/null
+++ b/cmd/updater/main.go
@@ -0,0 +1,116 @@
+package main
+
+import (
+ "flag"
+ "fmt"
+ "log"
+ "os"
+ "path/filepath"
+ "syscall"
+ "time"
+
+ "github.com/pkg/browser"
+ "github.com/pkg/errors"
+)
+
+// go build -ldflags="-s -w" -o ui/flutter/assets/exec/ github.com/GopeedLab/gopeed/cmd/updater
+
+func main() {
+ pid := flag.Int("pid", 0, "PID of the process to update")
+ updateChannel := flag.String("channel", "", "Update channel")
+ packagePath := flag.String("asset", "", "Path to the package asset")
+ logPath := flag.String("log", "", "Log file path")
+ flag.Parse()
+
+ if *pid == 0 {
+ log.Println("Invalid PID")
+ os.Exit(1)
+ }
+ if *updateChannel == "" {
+ log.Println("Invalid update channel")
+ os.Exit(1)
+ }
+
+ if *logPath != "" {
+ logFile, err := os.OpenFile(*logPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0666)
+ if err == nil {
+ defer logFile.Close()
+ log.SetOutput(logFile)
+ }
+ }
+
+ var (
+ restart bool
+ err error
+ )
+ if restart, err = update(*pid, *updateChannel, *packagePath); err != nil {
+ log.Printf("Update failed: %v\n", err)
+ os.Exit(1)
+ }
+
+ // Restart the application
+ if restart {
+ browser.OpenURL("gopeed:///")
+ }
+
+ // Delete package asset
+ if *packagePath != "" {
+ os.Remove(*packagePath)
+ }
+
+ os.Exit(0)
+}
+
+func update(pid int, updateChannel, packagePath string) (restart bool, err error) {
+ killSignalChan := make(chan any, 1)
+
+ go func() {
+ <-killSignalChan
+
+ if err = killProcess(pid); err != nil {
+ log.Printf("Failed to kill process: %v\n", err)
+ }
+
+ if err = waitForProcessExit(pid); err != nil {
+ log.Printf("Failed to wait for process exit: %v\n", err)
+ }
+ }()
+
+ appDir := filepath.Dir(os.Args[0])
+ log.Printf("Updating process updateChannel=%s packagePath=%s appDir=%s\n", updateChannel, packagePath, appDir)
+ if restart, err = install(killSignalChan, updateChannel, packagePath, appDir); err != nil {
+ return false, errors.Wrap(err, "failed to install package")
+ }
+
+ return
+}
+
+func waitForProcessExit(pid int) error {
+ deadline := time.Now().Add(10 * time.Second)
+ for time.Now().Before(deadline) {
+ process, err := os.FindProcess(pid)
+ if err != nil {
+ // On some systems, error is returned if process doesn't exist
+ return nil
+ }
+
+ // Send null signal to test if process exists
+ err = process.Signal(syscall.Signal(0))
+ if err != nil {
+ // If error occurs, the process no longer exists
+ return nil
+ }
+
+ time.Sleep(100 * time.Millisecond)
+ }
+ return fmt.Errorf("process %d still running after timeout", pid)
+}
+
+func killProcess(pid int) error {
+ process, err := os.FindProcess(pid)
+ if err != nil {
+ return err
+ }
+
+ return process.Kill()
+}
diff --git a/cmd/updater/updater_darwin.go b/cmd/updater/updater_darwin.go
new file mode 100644
index 00000000..db04c4d2
--- /dev/null
+++ b/cmd/updater/updater_darwin.go
@@ -0,0 +1,68 @@
+//go:build darwin
+
+package main
+
+import (
+ "fmt"
+ "os/exec"
+ "path/filepath"
+ "strings"
+)
+
+func install(killSignalChan chan<- any, updateChannel, packagePath, destDir string) (bool, error) {
+ return true, installByDmg(killSignalChan, packagePath, destDir)
+}
+
+// installByDmg handles macOS dmg package installation
+func installByDmg(killSignalChan chan<- any, packagePath, destDir string) error {
+ // /Applications/Gopeed.app/Contents/MacOS -> /Applications
+ appPath := getParentDir(getParentDir(getParentDir(destDir)))
+ output, err := exec.Command("hdiutil", "attach", packagePath, "-nobrowse").Output()
+ if err != nil {
+ return err
+ }
+
+ mountPoint := ""
+ for _, line := range strings.Split(string(output), "\n") {
+ if strings.Contains(line, "/Volumes/") {
+ fields := strings.Fields(line)
+ if len(fields) > 2 {
+ mountPoint = fields[len(fields)-1]
+ break
+ }
+ }
+ }
+
+ if mountPoint == "" {
+ return fmt.Errorf("failed to get mount point")
+ }
+
+ // Detach the mounted DMG
+ defer exec.Command("hdiutil", "detach", mountPoint, "-quiet").Run()
+
+ matches, err := filepath.Glob(filepath.Join(mountPoint, "*.app"))
+ if err != nil {
+ return err
+ }
+ if len(matches) == 0 {
+ return fmt.Errorf("no .app found in dmg")
+ }
+
+ killSignalChan <- nil
+
+ // Copy the new app to the destination
+ // cp -Rf /Volumes/GoPeed/GoPeed.app /Applications
+ if err := exec.Command("cp", "-Rf", matches[0], appPath).Run(); err != nil {
+ return err
+ }
+
+ return nil
+}
+
+// Get parent directory safely, handling trailing separators
+func getParentDir(path string) string {
+ // Remove trailing separators if they exist
+ path = strings.TrimRight(path, string(filepath.Separator))
+ // Now get the parent directory
+ return filepath.Dir(path)
+}
diff --git a/cmd/updater/updater_linux.go b/cmd/updater/updater_linux.go
new file mode 100644
index 00000000..71c42364
--- /dev/null
+++ b/cmd/updater/updater_linux.go
@@ -0,0 +1,72 @@
+//go:build linux
+
+package main
+
+import (
+ "fmt"
+ "os/exec"
+)
+
+func install(killSignalChan chan<- any, updateChannel, packagePath, destDir string) (bool, error) {
+ killSignalChan <- nil
+ switch updateChannel {
+ case "linuxDeb":
+ return true, installByDeb(packagePath)
+ case "linuxFlathub":
+ return true, installByFlathub()
+ case "linuxSnap":
+ return true, installBySnap()
+ default:
+ return false, fmt.Errorf("unsupported update channel for Linux: %s", updateChannel)
+ }
+}
+
+// executeInTerminal tries to execute a command in one of several common terminal emulators
+func executeInTerminal(command string) error {
+ terminals := []string{
+ "gnome-terminal", // GNOME
+ "konsole", // KDE
+ "xfce4-terminal", // XFCE
+ "xterm", // X11
+ }
+
+ command = fmt.Sprintf(`echo "Starting update..." && echo "[CMD] %s" && %s`, command, command)
+
+ for _, term := range terminals {
+ if _, err := exec.LookPath(term); err == nil {
+ var cmd *exec.Cmd
+ switch term {
+ case "gnome-terminal", "xfce4-terminal":
+ cmd = exec.Command(term, "--", "bash", "-c", command)
+ case "konsole":
+ cmd = exec.Command(term, "-e", "bash", "-c", command)
+ case "xterm":
+ cmd = exec.Command(term, "-e", command)
+ }
+
+ if err := cmd.Start(); err == nil {
+ return nil
+ }
+ }
+ }
+
+ return fmt.Errorf("no suitable terminal emulator found. Please install gnome-terminal, konsole, xfce4-terminal or xterm")
+}
+
+// installByDeb installs the .deb package
+func installByDeb(packagePath string) error {
+ command := fmt.Sprintf(`sudo dpkg -i "%s"`, packagePath)
+ return executeInTerminal(command)
+}
+
+// installByFlathub updates the application via Flathub
+func installByFlathub() error {
+ command := "flatpak update com.gopeed.Gopeed -y"
+ return executeInTerminal(command)
+}
+
+// installBySnap updates the application via Snap
+func installBySnap() error {
+ command := "sudo snap refresh gopeed"
+ return executeInTerminal(command)
+}
diff --git a/cmd/updater/updater_windows.go b/cmd/updater/updater_windows.go
new file mode 100644
index 00000000..03dd6b42
--- /dev/null
+++ b/cmd/updater/updater_windows.go
@@ -0,0 +1,134 @@
+//go:build windows
+
+package main
+
+import (
+ "archive/zip"
+ "fmt"
+ "io"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+)
+
+func install(killSignalChan chan<- any, updateChannel, packagePath, destDir string) (bool, error) {
+ switch updateChannel {
+ case "windowsInstaller":
+ return false, installByInstaller(killSignalChan, packagePath, destDir)
+ default:
+ return true, installByPortable(killSignalChan, packagePath, destDir)
+ }
+}
+
+// installByInstaller extracts the installer from the zip file and runs it
+func installByInstaller(killSignalChan chan<- any, packagePath, destDir string) error {
+ // Create a temp directory for extraction
+ tempDir, err := os.MkdirTemp("", "gopeed_update")
+ if err != nil {
+ return err
+ }
+ defer os.RemoveAll(tempDir)
+
+ // Extract the zip file
+ reader, err := zip.OpenReader(packagePath)
+ if err != nil {
+ return err
+ }
+ defer reader.Close()
+
+ // Find the installer file
+ var installerPath string
+ for _, file := range reader.File {
+ if file.FileInfo().IsDir() {
+ continue
+ }
+
+ // Extract file
+ path := filepath.Join(tempDir, file.Name)
+
+ if err := os.MkdirAll(filepath.Dir(path), os.ModePerm); err != nil {
+ return err
+ }
+
+ dstFile, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, file.Mode())
+ if err != nil {
+ return err
+ }
+
+ srcFile, err := file.Open()
+ if err != nil {
+ dstFile.Close()
+ return err
+ }
+
+ _, err = io.Copy(dstFile, srcFile)
+ srcFile.Close()
+ dstFile.Close()
+ if err != nil {
+ return err
+ }
+
+ // If this is likely an installer (.exe, .msi), save its path
+ ext := strings.ToLower(filepath.Ext(file.Name))
+ if ext == ".exe" || ext == ".msi" {
+ installerPath = path
+ }
+ }
+
+ if installerPath == "" {
+ return fmt.Errorf("no installer found in the update package")
+ }
+
+ // Run the installer
+ cmd := exec.Command(installerPath)
+ if err := cmd.Start(); err != nil {
+ return err
+ }
+
+ killSignalChan <- nil
+ return nil
+}
+
+// installByPortable extracts the portable version to the destination directory
+func installByPortable(killSignalChan chan<- any, packagePath, destDir string) error {
+ killSignalChan <- nil
+
+ reader, err := zip.OpenReader(packagePath)
+ if err != nil {
+ return err
+ }
+ defer reader.Close()
+
+ for _, file := range reader.File {
+ path := filepath.Join(destDir, file.Name)
+
+ if file.FileInfo().IsDir() {
+ os.MkdirAll(path, file.Mode())
+ continue
+ }
+
+ if err := os.MkdirAll(filepath.Dir(path), os.ModePerm); err != nil {
+ return err
+ }
+
+ dstFile, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, file.Mode())
+ if err != nil {
+ return err
+ }
+
+ srcFile, err := file.Open()
+ if err != nil {
+ dstFile.Close()
+ return err
+ }
+
+ _, err = io.Copy(dstFile, srcFile)
+ srcFile.Close()
+ dstFile.Close()
+ if err != nil {
+ return err
+ }
+ }
+ return nil
+}
diff --git a/ui/flutter/.gitignore b/ui/flutter/.gitignore
index 6bb3b1a4..9e9c2f15 100644
--- a/ui/flutter/.gitignore
+++ b/ui/flutter/.gitignore
@@ -69,5 +69,7 @@ windows/flutter/generated_plugins.cmake
database.hive
database.lock
-assets/host/host
-assets/host/host.exe
+assets/exec/host
+assets/exec/host.exe
+assets/exec/updater
+assets/exec/updater.exe
diff --git a/ui/flutter/assets/host/.gitkeep b/ui/flutter/assets/exec/.gitkeep
similarity index 100%
rename from ui/flutter/assets/host/.gitkeep
rename to ui/flutter/assets/exec/.gitkeep
diff --git a/ui/flutter/lib/app/modules/app/controllers/app_controller.dart b/ui/flutter/lib/app/modules/app/controllers/app_controller.dart
index b97cd33a..d83f0fd6 100644
--- a/ui/flutter/lib/app/modules/app/controllers/app_controller.dart
+++ b/ui/flutter/lib/app/modules/app/controllers/app_controller.dart
@@ -24,9 +24,11 @@ import '../../../../database/database.dart';
import '../../../../database/entity.dart';
import '../../../../i18n/message.dart';
import '../../../../main.dart';
+import '../../../../util/github_mirror.dart';
import '../../../../util/locale_manager.dart';
import '../../../../util/log_util.dart';
import '../../../../util/package_info.dart';
+import '../../../../util/updater.dart';
import '../../../../util/util.dart';
import '../../../routes/app_pages.dart';
import '../../create/dto/create_router_params.dart';
@@ -35,47 +37,22 @@ import '../../redirect/views/redirect_view.dart';
const unixSocketPath = 'gopeed.sock';
const allTrackerSubscribeUrls = [
- 'https://github.com/ngosang/trackerslist/raw/master/trackers_all.txt',
- 'https://github.com/ngosang/trackerslist/raw/master/trackers_all_http.txt',
- 'https://github.com/ngosang/trackerslist/raw/master/trackers_all_https.txt',
- 'https://github.com/ngosang/trackerslist/raw/master/trackers_all_ip.txt',
- 'https://github.com/ngosang/trackerslist/raw/master/trackers_all_udp.txt',
- 'https://github.com/ngosang/trackerslist/raw/master/trackers_all_ws.txt',
- 'https://github.com/ngosang/trackerslist/raw/master/trackers_best.txt',
- 'https://github.com/ngosang/trackerslist/raw/master/trackers_best_ip.txt',
- 'https://github.com/XIU2/TrackersListCollection/raw/master/all.txt',
- 'https://github.com/XIU2/TrackersListCollection/raw/master/best.txt',
- 'https://github.com/XIU2/TrackersListCollection/raw/master/http.txt',
+ 'https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_all.txt',
+ 'https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_all_http.txt',
+ 'https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_all_https.txt',
+ 'https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_all_ip.txt',
+ 'https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_all_udp.txt',
+ 'https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_all_ws.txt',
+ 'https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_best.txt',
+ 'https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_best_ip.txt',
+ 'https://raw.githubusercontent.com/XIU2/TrackersListCollection/master/all.txt',
+ 'https://raw.githubusercontent.com/XIU2/TrackersListCollection/master/best.txt',
+ 'https://raw.githubusercontent.com/XIU2/TrackersListCollection/master/http.txt',
];
-const allTrackerCdns = [
- // jsdelivr: https://fastly.jsdelivr.net/gh/ngosang/trackerslist/trackers_all.txt
- ["https://fastly.jsdelivr.net/gh", r".*github.com(/.*)/raw/master(/.*)"],
- // ghproxy: https://ghproxy.com/https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_all.txt
- [
- "https://ghproxy.com/https://raw.githubusercontent.com",
- r".*github.com(/.*)/raw(/.*)"
- ],
- // mirror.ghproxy.com: https://mirror.ghproxy.com/https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_all.txt
- [
- "https://mirror.ghproxy.com/https://raw.githubusercontent.com",
- r".*github.com(/.*)/raw(/.*)"
- ],
-];
-final allTrackerSubscribeUrlCdns = Map.fromIterable(allTrackerSubscribeUrls,
- key: (v) => v as String,
- value: (v) {
- final ret = [v as String];
- for (final cdn in allTrackerCdns) {
- final reg = RegExp(cdn[1]);
- final match = reg.firstMatch(v.toString());
- var matchStr = "";
- for (var i = 1; i <= match!.groupCount; i++) {
- matchStr += match.group(i)!;
- }
- ret.add("${cdn[0]}$matchStr");
- }
- return ret;
- });
+final allTrackerSubscribeUrlCdns = {
+ for (var v in allTrackerSubscribeUrls)
+ v: githubMirrorUrls(v, MirrorType.githubSource)
+};
class AppController extends GetxController with WindowListener, TrayListener {
static StartConfig? _defaultStartConfig;
@@ -109,6 +86,9 @@ class AppController extends GetxController with WindowListener, TrayListener {
_initLaunchAtStartup().onError((error, stackTrace) =>
logger.w("initLaunchAtStartup error", error, stackTrace));
+
+ _initCheckUpdate().onError((error, stackTrace) =>
+ logger.w("initCheckUpdate error", error, stackTrace));
}
@override
@@ -525,6 +505,13 @@ class AppController extends GetxController with WindowListener, TrayListener {
autoStartup.value = await launchAtStartup.isEnabled();
}
+ Future _initCheckUpdate() async {
+ final versionInfo = await checkUpdate();
+ if (versionInfo != null) {
+ await showUpdateDialog(Get.context!, versionInfo);
+ }
+ }
+
Future saveConfig() async {
Database.instance.saveStartConfig(StartConfigEntity(
network: startConfig.value.network,
diff --git a/ui/flutter/lib/app/modules/setting/controllers/setting_controller.dart b/ui/flutter/lib/app/modules/setting/controllers/setting_controller.dart
index 3f1995c0..f9cedd02 100644
--- a/ui/flutter/lib/app/modules/setting/controllers/setting_controller.dart
+++ b/ui/flutter/lib/app/modules/setting/controllers/setting_controller.dart
@@ -1,11 +1,10 @@
-import 'dart:convert';
-
import 'package:get/get.dart';
-import 'package:gopeed/api/api.dart';
+
+import '../../../../util/updater.dart';
class SettingController extends GetxController {
final tapStatues = {}.obs;
- final latestVersion = "".obs;
+ final latestVersion = Rxn();
@override
void onInit() {
@@ -26,23 +25,6 @@ class SettingController extends GetxController {
// fetch latest version
void fetchLatestVersion() async {
- String? releaseDataStr;
- try {
- releaseDataStr = (await proxyRequest(
- "https://api.github.com/repos/GopeedLab/gopeed/releases/latest"))
- .data;
- } catch (e) {
- releaseDataStr =
- (await proxyRequest("https://gopeed.com/api/release")).data;
- }
- if (releaseDataStr == null) {
- return;
- }
- final releaseData = jsonDecode(releaseDataStr);
- final tagName = releaseData["tag_name"];
- if (tagName == null) {
- return;
- }
- latestVersion.value = releaseData["tag_name"].substring(1);
+ latestVersion.value = await checkUpdate();
}
}
diff --git a/ui/flutter/lib/app/modules/setting/views/setting_view.dart b/ui/flutter/lib/app/modules/setting/views/setting_view.dart
index abbe8658..c1b64f2b 100644
--- a/ui/flutter/lib/app/modules/setting/views/setting_view.dart
+++ b/ui/flutter/lib/app/modules/setting/views/setting_view.dart
@@ -18,6 +18,7 @@ import '../../../../util/log_util.dart';
import '../../../../util/message.dart';
import '../../../../util/package_info.dart';
import '../../../../util/scheme_register/scheme_register.dart';
+import '../../../../util/updater.dart';
import '../../../../util/util.dart';
import '../../../views/check_list_view.dart';
import '../../../views/directory_selector.dart';
@@ -514,29 +515,7 @@ class SettingView extends GetView {
}
buildVersion() {
- bool isNewVersion(String current, String latest) {
- if (latest == "") {
- return false;
- }
-
- final v1Parts = current.split('.');
- final v2Parts = latest.split('.');
-
- for (var i = 0; i < 3; i++) {
- final v1Part = int.parse(v1Parts[i]);
- final v2Part = int.parse(v2Parts[i]);
-
- if (v1Part < v2Part) {
- return true;
- } else if (v1Part > v2Part) {
- return false;
- }
- }
- return false;
- }
-
- var hasNewVersion =
- isNewVersion(packageInfo.version, controller.latestVersion.value);
+ var hasNewVersion = controller.latestVersion.value != null;
return ListTile(
title: hasNewVersion
? badges.Badge(
@@ -546,27 +525,7 @@ class SettingView extends GetView {
subtitle: Text(packageInfo.version),
onTap: () {
if (hasNewVersion) {
- showDialog(
- context: context,
- builder: (context) => AlertDialog(
- content: Text('newVersionTitle'.trParams(
- {'version': controller.latestVersion.value})),
- actions: [
- TextButton(
- onPressed: () {
- Get.back();
- },
- child: Text('newVersionLater'.tr),
- ),
- TextButton(
- onPressed: () {
- launchUrl(Uri.parse('https://gopeed.com'),
- mode: LaunchMode.externalApplication);
- },
- child: Text('newVersionUpdate'.tr),
- ),
- ],
- ));
+ showUpdateDialog(context, controller.latestVersion.value!);
}
},
);
diff --git a/ui/flutter/lib/i18n/langs/hu_hu b/ui/flutter/lib/i18n/langs/hu_hu.dart
similarity index 100%
rename from ui/flutter/lib/i18n/langs/hu_hu
rename to ui/flutter/lib/i18n/langs/hu_hu.dart
diff --git a/ui/flutter/lib/i18n/message.dart b/ui/flutter/lib/i18n/message.dart
index 841e9788..50a892ca 100644
--- a/ui/flutter/lib/i18n/message.dart
+++ b/ui/flutter/lib/i18n/message.dart
@@ -15,6 +15,7 @@ import 'langs/zh_cn.dart';
import 'langs/zh_tw.dart';
import 'langs/es_es.dart';
import 'langs/uk_ua.dart';
+import 'langs/hu_hu.dart';
final messages = _Messages();
@@ -37,5 +38,6 @@ class _Messages extends Translations {
...frFR,
...esES,
...ukUA,
+ ...huHU,
};
}
diff --git a/ui/flutter/lib/main.dart b/ui/flutter/lib/main.dart
index 0493e85f..102b3f13 100644
--- a/ui/flutter/lib/main.dart
+++ b/ui/flutter/lib/main.dart
@@ -16,6 +16,7 @@ import 'util/locale_manager.dart';
import 'util/log_util.dart';
import 'util/package_info.dart';
import 'util/scheme_register/scheme_register.dart';
+import 'util/updater.dart';
import 'util/util.dart';
class Args {
@@ -93,26 +94,34 @@ Future init(Args args) async {
}
() async {
- try {
- registerUrlScheme("gopeed");
- if (controller.downloaderConfig.value.extra.defaultBtClient) {
- registerDefaultTorrentClient();
- }
- } catch (e) {
- logger.e("register scheme fail", e);
- }
-
- try {
- await installHost();
- } catch (e) {
- logger.e("browser extension host binary install fail", e);
- }
- for (final browser in Browser.values) {
+ if (Util.isDesktop()) {
try {
- await installManifest(browser);
+ registerUrlScheme("gopeed");
+ if (controller.downloaderConfig.value.extra.defaultBtClient) {
+ registerDefaultTorrentClient();
+ }
} catch (e) {
- logger.e(
- "browser [${browser.name}] extension host integration fail", e);
+ logger.e("register scheme fail", e);
+ }
+
+ try {
+ await installHost();
+ } catch (e) {
+ logger.e("browser extension host binary install fail", e);
+ }
+ for (final browser in Browser.values) {
+ try {
+ await installManifest(browser);
+ } catch (e) {
+ logger.e(
+ "browser [${browser.name}] extension host integration fail", e);
+ }
+ }
+
+ try {
+ await installUpdater();
+ } catch (e) {
+ logger.e("updater install fail", e);
}
}
}();
diff --git a/ui/flutter/lib/util/arch/arch.dart b/ui/flutter/lib/util/arch/arch.dart
new file mode 100644
index 00000000..92fca529
--- /dev/null
+++ b/ui/flutter/lib/util/arch/arch.dart
@@ -0,0 +1,15 @@
+import 'arch_stub.dart'
+ if (dart.library.io) 'entry/arch_native.dart'
+ if (dart.library.html) 'entry/arch_web.dart';
+
+// Copy from pkg/sky_engine/lib/ffi/abi.dart
+enum Architecture {
+ arm,
+ arm64,
+ ia32,
+ x64,
+ riscv32,
+ riscv64,
+}
+
+Architecture getArch() => doGetArch();
diff --git a/ui/flutter/lib/util/arch/arch_stub.dart b/ui/flutter/lib/util/arch/arch_stub.dart
new file mode 100644
index 00000000..b67ac8c3
--- /dev/null
+++ b/ui/flutter/lib/util/arch/arch_stub.dart
@@ -0,0 +1,3 @@
+import 'arch.dart';
+
+Architecture doGetArch() => throw UnimplementedError();
diff --git a/ui/flutter/lib/util/arch/entry/arch_native.dart b/ui/flutter/lib/util/arch/entry/arch_native.dart
new file mode 100644
index 00000000..5c73d894
--- /dev/null
+++ b/ui/flutter/lib/util/arch/entry/arch_native.dart
@@ -0,0 +1,13 @@
+// ignore: avoid_web_libraries_in_flutter
+import 'dart:ffi';
+
+import '../arch.dart';
+
+Architecture doGetArch() {
+ final currentAbi = Abi.current().toString();
+ final archName = currentAbi.split("_")[1];
+ final arch = Architecture.values.firstWhere(
+ (element) => element.name == archName,
+ orElse: () => Architecture.x64);
+ return arch;
+}
diff --git a/ui/flutter/lib/util/arch/entry/arch_web.dart b/ui/flutter/lib/util/arch/entry/arch_web.dart
new file mode 100644
index 00000000..a401d0be
--- /dev/null
+++ b/ui/flutter/lib/util/arch/entry/arch_web.dart
@@ -0,0 +1,5 @@
+import '../arch.dart';
+
+Architecture doGetArch() {
+ return Architecture.x64;
+}
diff --git a/ui/flutter/lib/util/browser_extension_host/entry/browser_extension_host_native.dart b/ui/flutter/lib/util/browser_extension_host/entry/browser_extension_host_native.dart
index 6e73e108..3d5feed9 100644
--- a/ui/flutter/lib/util/browser_extension_host/entry/browser_extension_host_native.dart
+++ b/ui/flutter/lib/util/browser_extension_host/entry/browser_extension_host_native.dart
@@ -1,10 +1,10 @@
import 'dart:convert';
import 'dart:io';
-import 'package:flutter/services.dart';
import 'package:path/path.dart' as path;
import 'package:win32_registry/win32_registry.dart';
+import '../../util.dart';
import '../../win32.dart';
import '../browser_extension_host.dart';
@@ -26,32 +26,9 @@ const _firefoxNativeHostsKey = r'Software\Mozilla\NativeMessagingHosts';
/// Install host binary for browser extension
Future doInstallHost() async {
- final hostPath = _joinExePath(_hostExecName);
-
- Future> getHostAssetData() async {
- final hostAsset = await rootBundle.load('assets/host/$_hostExecName');
- return hostAsset.buffer
- .asUint8List(hostAsset.offsetInBytes, hostAsset.lengthInBytes);
- }
-
- // Check if host binary is not installed
- if (!await File(hostPath).exists()) {
- final hostData = await getHostAssetData();
- final file = File(hostPath);
- await file.writeAsBytes(hostData);
- // Add execute permission
- if (!Platform.isWindows) {
- await Process.run('chmod', ['+x', hostPath]);
- }
- return;
- }
- // Check if host binary is outdated
- final hostAssetData = await getHostAssetData();
- final hostFileData = await File(hostPath).readAsBytes();
- if (hostAssetData.length != hostFileData.length) {
- await File(hostPath).writeAsBytes(hostAssetData);
- return;
- }
+ final hostPath = Util.homePathJoin(_hostExecName);
+ await Util.installAsset('assets/exec/$_hostExecName', hostPath,
+ executable: true);
}
/// Check if specified browser is installed
@@ -236,7 +213,7 @@ String? _getManifestPath(Browser browser) {
final manifestName =
browser == Browser.firefox ? '$_hostName.moz.json' : '$_hostName.json';
if (Platform.isWindows) {
- return _joinExePath(manifestName);
+ return Util.homePathJoin(manifestName);
}
final home =
@@ -282,7 +259,7 @@ Future _checkWindowsRegistry(String keyPath) async {
}
Future _getManifestContent(Browser browser) async {
- final hostPath = _joinExePath(_hostExecName);
+ final hostPath = Util.homePathJoin(_hostExecName);
final manifest = {
'name': _hostName,
'description': 'Gopeed browser extension host',
@@ -309,9 +286,3 @@ String _getWindowsRegistryKey(Browser browser) {
return _firefoxNativeHostsKey;
}
}
-
-String _joinExePath(String fileName) {
- final execPath = Platform.resolvedExecutable;
- final execDir = path.dirname(execPath);
- return path.join(execDir, fileName);
-}
diff --git a/ui/flutter/lib/util/github_mirror.dart b/ui/flutter/lib/util/github_mirror.dart
new file mode 100644
index 00000000..bf58b197
--- /dev/null
+++ b/ui/flutter/lib/util/github_mirror.dart
@@ -0,0 +1,79 @@
+import 'package:dio/dio.dart';
+
+// List of mirrors for GitHub
+const _sourceMirror = [
+ // ghfast.top: https://ghfast.top/https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_all.txt
+ ["https://ghfast.top/", r"(.*)"],
+ // github.moeyy.xyz: https://github.moeyy.xyz/https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_all.txt
+ ["https://github.moeyy.xyz/", r"(.*)"],
+ // jsdelivr: https://fastly.jsdelivr.net/gh/ngosang/trackerslist/trackers_all.txt
+ [
+ "https://fastly.jsdelivr.net/gh",
+ r".*raw.githubusercontent.com(/.*)(/.*)/(?:master|main)(/.*)"
+ ],
+];
+
+// List of mirrors for GitHub release
+const _assertMirror = [
+ // ghfast.top: https://ghfast.top/https://github.com/GopeedLab/gopeed/releases/download/v1.6.10/Gopeed-v1.6.10-windows-amd64.zip
+ ["https://ghfast.top/", r"(.*)"],
+ // github.moeyy.xyz: https://github.moeyy.xyz/https://github.com/GopeedLab/gopeed/releases/download/v1.6.10/Gopeed-v1.6.10-windows-amd64.zip
+ ["https://github.moeyy.xyz/", r"(.*)"],
+];
+
+enum MirrorType {
+ githubSource,
+ githubRelease,
+}
+
+/// Auto detect the best mirror for the given [rawUrl] and [type]
+Future githubAutoMirror(String rawUrl, MirrorType type) async {
+ final mirrorUrls = githubMirrorUrls(rawUrl, type);
+
+ // Ping all mirrors and get the fastest one
+ final pingResult = await Future.wait(mirrorUrls.map((e) async {
+ final client = Dio()
+ ..options.sendTimeout = const Duration(seconds: 3)
+ ..options.connectTimeout = const Duration(seconds: 3);
+ var time = DateTime.now().millisecondsSinceEpoch;
+ try {
+ final response = await client.head(e);
+ if (response.statusCode == 200) {
+ time = DateTime.now().millisecondsSinceEpoch - time;
+ return (e, time);
+ }
+ } catch (e) {
+ // ignore
+ } finally {
+ client.close();
+ }
+ return (e, -1);
+ }));
+
+ var list = pingResult.where((e) => e.$2 != -1).toList()
+ ..sort((a, b) => a.$2.compareTo(b.$2));
+ if (list.isNotEmpty) {
+ return list.first.$1;
+ }
+
+ return rawUrl;
+}
+
+List githubMirrorUrls(String rawUrl, MirrorType type) {
+ final mirrors = switch (type) {
+ MirrorType.githubSource => _sourceMirror,
+ MirrorType.githubRelease => _assertMirror,
+ };
+
+ final ret = [];
+ for (final cdn in mirrors) {
+ final reg = RegExp(cdn[1]);
+ final match = reg.firstMatch(rawUrl.toString());
+ var matchStr = "";
+ for (var i = 1; i <= match!.groupCount; i++) {
+ matchStr += match.group(i)!;
+ }
+ ret.add("${cdn[0]}$matchStr");
+ }
+ return ret;
+}
diff --git a/ui/flutter/lib/util/updater.dart b/ui/flutter/lib/util/updater.dart
new file mode 100644
index 00000000..80c119f3
--- /dev/null
+++ b/ui/flutter/lib/util/updater.dart
@@ -0,0 +1,432 @@
+import 'dart:async';
+import 'dart:convert';
+import 'dart:io';
+
+import 'package:dio/dio.dart';
+import 'package:flutter/material.dart';
+import 'package:get/get.dart';
+import 'package:install_plugin/install_plugin.dart';
+import 'package:path/path.dart' as path;
+import 'package:path_provider/path_provider.dart';
+import 'package:url_launcher/url_launcher.dart';
+
+import '../api/api.dart';
+import '../app/views/outlined_button_loading.dart';
+import 'arch/arch.dart';
+import 'github_mirror.dart';
+import 'log_util.dart';
+import 'message.dart';
+import 'package_info.dart';
+import 'util.dart';
+
+enum Channel {
+ windowsInstaller,
+ windowsPortable,
+ macosDmg,
+ linuxFlathub,
+ linuxSnap,
+ linuxDeb,
+ linuxAppImage,
+ androidApk,
+ iosIpa,
+ docker,
+}
+
+const _channelEnv = String.fromEnvironment("UPDATE_CHANNEL");
+final _channel =
+ Channel.values.where((e) => e.name == _channelEnv).firstOrNull ??
+ () {
+ if (Util.isWindows()) {
+ return Channel.windowsPortable;
+ } else if (Util.isMacos()) {
+ return Channel.macosDmg;
+ } else if (Util.isAndroid()) {
+ return Channel.androidApk;
+ } else if (Util.isIOS()) {
+ return Channel.iosIpa;
+ } else {
+ return null;
+ }
+ }();
+final _updaterBin = "updater${Util.isWindows() ? ".exe" : ""}";
+
+Future installUpdater() async {
+ await Util.installAsset(
+ 'assets/exec/$_updaterBin', Util.homePathJoin(_updaterBin),
+ executable: true);
+}
+
+class VersionInfo {
+ final String version;
+ final String changeLog;
+
+ VersionInfo(this.version, this.changeLog);
+}
+
+Future checkUpdate() async {
+ String? releaseDataStr;
+ try {
+ releaseDataStr = (await proxyRequest(
+ "https://api.github.com/repos/GopeedLab/gopeed/releases/latest"))
+ .data;
+ } catch (e) {
+ releaseDataStr =
+ (await proxyRequest("https://gopeed.com/api/release")).data;
+ }
+ if (releaseDataStr == null) {
+ return null;
+ }
+ final releaseData = jsonDecode(releaseDataStr);
+ final tagName = releaseData["tag_name"];
+ if (tagName == null) {
+ return null;
+ }
+ final latestVersion = releaseData["tag_name"].substring(1);
+
+ // compare version x.y.z to x.y.z
+ final currentVersion = packageInfo.version;
+ var isNewVersion = false;
+ if (latestVersion != currentVersion) {
+ final currentVersionList = currentVersion.split(".");
+ final latestVersionList = latestVersion.split(".");
+ for (var i = 0; i < currentVersionList.length; i++) {
+ if (int.parse(latestVersionList[i]) > int.parse(currentVersionList[i])) {
+ isNewVersion = true;
+ break;
+ }
+ }
+ }
+
+ if (!isNewVersion) {
+ return null;
+ }
+
+ return VersionInfo(latestVersion, releaseData["body"]);
+}
+
+Future showUpdateDialog(
+ BuildContext context, VersionInfo versionInfo) async {
+ final fullChangeLog = versionInfo.changeLog;
+ final isZh = Get.locale?.languageCode == "zh";
+ final changeLogRegex = isZh
+ ? RegExp(r"(#\s+更新日志.*)", multiLine: true, dotAll: true)
+ : RegExp(r"(# Release notes.*)#\s+更新日志", multiLine: true, dotAll: true);
+ final changeLog = changeLogRegex.firstMatch(fullChangeLog)?.group(1) ?? "";
+ await showDialog(
+ context: Get.context!,
+ barrierDismissible: false,
+ builder: (context) {
+ bool isDownloading = false;
+ double progress = 0;
+ int total = 0;
+ final buttonController = OutlinedButtonLoadingController();
+ return StatefulBuilder(
+ builder: (context, setState) {
+ final screenSize = MediaQuery.of(context).size;
+ final dialogWidth =
+ screenSize.width < 500 ? screenSize.width * 0.9 : 500.0;
+ final dialogHeight =
+ screenSize.height < 600 ? screenSize.height * 0.8 : 400.0;
+
+ return Dialog(
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(16),
+ ),
+ child: SizedBox(
+ width: dialogWidth,
+ child: Padding(
+ padding: const EdgeInsets.all(20),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ 'newVersionTitle'
+ .trParams({'version': versionInfo.version}),
+ style: const TextStyle(
+ fontSize: 20,
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ const SizedBox(height: 16),
+ Container(
+ height: dialogHeight * 0.5,
+ decoration: BoxDecoration(
+ border:
+ Border.all(color: Theme.of(context).dividerColor),
+ borderRadius: BorderRadius.circular(8),
+ ),
+ child: ScrollConfiguration(
+ behavior: ScrollConfiguration.of(context).copyWith(
+ scrollbars: true,
+ ),
+ child: Builder(
+ builder: (context) {
+ final controller = ScrollController();
+ return Scrollbar(
+ controller: controller,
+ thumbVisibility: true,
+ child: SingleChildScrollView(
+ controller: controller,
+ child: Padding(
+ padding: const EdgeInsets.all(12),
+ child: Column(
+ crossAxisAlignment:
+ CrossAxisAlignment.start,
+ children:
+ _parseMarkdown(changeLog, context),
+ ),
+ ),
+ ),
+ );
+ },
+ ),
+ ),
+ ),
+ if (isDownloading) ...[
+ const SizedBox(height: 16),
+ LinearProgressIndicator(
+ value: progress,
+ valueColor: AlwaysStoppedAnimation(
+ Theme.of(context).colorScheme.primary,
+ ),
+ ),
+ const SizedBox(height: 4),
+ Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Text(
+ '${(progress * 100).toStringAsFixed(1)}%',
+ style: const TextStyle(fontSize: 12),
+ ),
+ Text(
+ total == 0
+ ? ''
+ : '${Util.fmtByte((total * progress).toInt())} / ${Util.fmtByte(total)}',
+ style: const TextStyle(fontSize: 12),
+ ),
+ ],
+ )
+ ],
+ const SizedBox(height: 20),
+ Row(
+ mainAxisAlignment: MainAxisAlignment.end,
+ children: [
+ TextButton(
+ onPressed: isDownloading ? null : () => Get.back(),
+ child: Text(
+ 'newVersionLater'.tr,
+ style: TextStyle(
+ color: isDownloading
+ ? Theme.of(context).disabledColor
+ : Theme.of(context).colorScheme.error),
+ ),
+ ),
+ const SizedBox(width: 8),
+ OutlinedButtonLoading(
+ controller: buttonController,
+ onPressed: () async {
+ setState(() {
+ isDownloading = true;
+ });
+ buttonController.start();
+ try {
+ await _update(versionInfo.version,
+ (received, fileTotal) {
+ setState(() {
+ total = fileTotal;
+ progress = received / fileTotal;
+ });
+ });
+ } catch (e) {
+ showErrorMessage(e);
+ } finally {
+ setState(() {
+ isDownloading = false;
+ });
+ buttonController.stop();
+ }
+ },
+ child: Text('newVersionUpdate'.tr),
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ },
+ );
+ },
+ );
+}
+
+List _parseMarkdown(String markdown, BuildContext context) {
+ final List widgets = [];
+ final lines = markdown.split('\n');
+
+ for (final line in lines) {
+ if (line.trim().isEmpty) continue;
+ if (line.startsWith('# ')) {
+ // H1 header
+ widgets.add(Text(
+ line.substring(2),
+ style: const TextStyle(
+ fontSize: 18,
+ fontWeight: FontWeight.bold,
+ ),
+ ));
+ } else if (line.startsWith('## ')) {
+ // H2 header
+ widgets.add(Text(
+ line.substring(3),
+ style: const TextStyle(
+ fontSize: 16,
+ fontWeight: FontWeight.bold,
+ ),
+ ));
+ } else if (line.trim().startsWith('- ')) {
+ // List item
+ widgets.add(Padding(
+ padding: const EdgeInsets.only(left: 8),
+ child: Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ const Text('• ', style: TextStyle(fontSize: 14)),
+ Expanded(
+ child: Text(
+ line.substring(line.indexOf('-') + 1).trim().replaceFirst(
+ RegExp(r'@[^\s]*\s\(#\d+\)'),
+ ''), // Remove contributor and pr number
+ style: const TextStyle(fontSize: 14),
+ ),
+ ),
+ ],
+ ),
+ ));
+ } else {
+ // Normal text
+ widgets.add(Text(
+ line,
+ style: const TextStyle(fontSize: 14),
+ ));
+ }
+
+ // Add spacing between elements
+ widgets.add(const SizedBox(height: 8));
+ }
+
+ return widgets;
+}
+
+Future _update(String version, Function(int, int) onProgress) async {
+ var newVersionAssetPath = "";
+ final newVersionAssetName = _getAssetName(version);
+
+ // Need to download the asset
+ if (newVersionAssetName.isNotEmpty) {
+ final downloadUrl =
+ 'https://github.com/GopeedLab/gopeed/releases/download/v$version/$newVersionAssetName';
+ newVersionAssetPath = await _getAssetPath(version);
+
+ if (downloadUrl.isNotEmpty) {
+ final fastDownloadUrl =
+ await githubAutoMirror(downloadUrl, MirrorType.githubRelease);
+ final downloadClient = Dio();
+ await downloadClient.download(fastDownloadUrl, newVersionAssetPath,
+ onReceiveProgress: onProgress);
+ }
+ }
+
+ switch (_channel) {
+ case Channel.windowsInstaller:
+ case Channel.windowsPortable:
+ case Channel.macosDmg:
+ case Channel.linuxFlathub:
+ case Channel.linuxSnap:
+ case Channel.linuxDeb:
+ final updaterPath = Util.homePathJoin(_updaterBin);
+ // Check the updater binary is exists
+ if (!await File(updaterPath).exists()) {
+ await launchUrl(
+ Uri.parse(
+ 'https://github.com/GopeedLab/gopeed/releases/tag/v$version'),
+ mode: LaunchMode.externalApplication);
+ break;
+ }
+ /**
+ *Usage of updater command:
+ -pid int
+ PID of the process to update
+ -channel string
+ Update channel
+ -asset string
+ Path to the package asset
+ -log string
+ Log file path
+ */
+ await Process.run(updaterPath, [
+ "-pid",
+ pid.toString(),
+ "-channel",
+ _channel!.name,
+ "-asset",
+ newVersionAssetPath,
+ "-log",
+ path.join(logsDir(), "updater.log")
+ ]);
+ break;
+ case Channel.androidApk:
+ await InstallPlugin.installApk(newVersionAssetPath);
+ break;
+ default:
+ await launchUrl(
+ Uri.parse(
+ 'https://github.com/GopeedLab/gopeed/releases/tag/v$version'),
+ mode: LaunchMode.externalApplication);
+ break;
+ }
+}
+
+String _getAssetName(String version) {
+ final arch = getArch();
+
+ String commonArchName() {
+ return switch (arch) {
+ Architecture.ia32 => "386",
+ Architecture.x64 => "amd64",
+ _ => arch.name
+ };
+ }
+
+ switch (_channel) {
+ case Channel.windowsInstaller:
+ return 'Gopeed-v$version-windows-${commonArchName()}.zip';
+ case Channel.windowsPortable:
+ return 'Gopeed-v$version-windows-${commonArchName()}-portable.zip';
+ case Channel.macosDmg:
+ return 'Gopeed-v$version-macos.dmg';
+ case Channel.linuxDeb:
+ return 'Gopeed-v$version-linux-${commonArchName()}.deb';
+ case Channel.androidApk:
+ final apkArchName = switch (arch) {
+ Architecture.arm => "armeabi-v7a",
+ Architecture.arm64 => "arm64-v8a",
+ Architecture.x64 => "x86_64",
+ _ => null
+ };
+ var apkNamePrefix = "Gopeed-v$version-android";
+ if (apkArchName != null) {
+ apkNamePrefix += "-$apkArchName";
+ }
+ return "$apkNamePrefix.apk";
+ default:
+ return "";
+ }
+}
+
+Future _getAssetPath(String version) async {
+ return path.join(
+ (await getTemporaryDirectory()).path, _getAssetName(version));
+}
diff --git a/ui/flutter/lib/util/util.dart b/ui/flutter/lib/util/util.dart
index 9eafc7fc..86483abe 100644
--- a/ui/flutter/lib/util/util.dart
+++ b/ui/flutter/lib/util/util.dart
@@ -2,7 +2,9 @@ import 'dart:async';
import 'dart:convert';
import 'dart:io';
+import 'package:crypto/crypto.dart';
import 'package:flutter/foundation.dart';
+import 'package:flutter/services.dart';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as path;
@@ -153,4 +155,45 @@ class Util {
timer = Timer(Duration(milliseconds: ms), fn);
};
}
+
+ static String homePathJoin(String fileName) {
+ final execPath = Platform.resolvedExecutable;
+ final execDir = path.dirname(execPath);
+ return path.join(execDir, fileName);
+ }
+
+ static Future installAsset(String assetPath, String targetPath,
+ {bool executable = false}) async {
+ Future> getAssetData() async {
+ final asset = await rootBundle.load(assetPath);
+ return asset.buffer.asUint8List(asset.offsetInBytes, asset.lengthInBytes);
+ }
+
+ // Check if target file is not installed
+ if (!await File(targetPath).exists()) {
+ final assetData = await getAssetData();
+ final file = File(targetPath);
+ await file.writeAsBytes(assetData);
+ // Add execute permission when file first created
+ if (executable && !Platform.isWindows) {
+ await Process.run('chmod', ['+x', targetPath]);
+ }
+ return;
+ }
+
+ // Check if target file needs to be updated
+ final assetData = await getAssetData();
+ if (_md5(assetData) != await _md5File(File(targetPath))) {
+ await File(targetPath).writeAsBytes(assetData);
+ return;
+ }
+ }
+
+ static String _md5(List data) {
+ return md5.convert(data).toString();
+ }
+
+ static Future _md5File(File file) async {
+ return file.openRead().transform(md5).first.toString();
+ }
}
diff --git a/ui/flutter/pubspec.lock b/ui/flutter/pubspec.lock
index 2a45fb62..0d0bdf7c 100644
--- a/ui/flutter/pubspec.lock
+++ b/ui/flutter/pubspec.lock
@@ -271,7 +271,7 @@ packages:
source: hosted
version: "0.3.4+2"
crypto:
- dependency: transitive
+ dependency: "direct main"
description:
name: crypto
sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855"
@@ -546,6 +546,15 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.3.0"
+ install_plugin:
+ dependency: "direct main"
+ description:
+ path: "."
+ ref: cf08af829f4a4145634f8a047108f505fdbe5eaa
+ resolved-ref: cf08af829f4a4145634f8a047108f505fdbe5eaa
+ url: "https://github.com/hui-z/flutter_install_plugin.git"
+ source: git
+ version: "2.1.0"
intl:
dependency: transitive
description:
diff --git a/ui/flutter/pubspec.yaml b/ui/flutter/pubspec.yaml
index dacabe79..18b40f01 100644
--- a/ui/flutter/pubspec.yaml
+++ b/ui/flutter/pubspec.yaml
@@ -71,6 +71,11 @@ dependencies:
contentsize_tabbarview: ^0.0.2
win32_registry: ^1.1.5
share_handler: ^0.0.22
+ crypto: ^3.0.6
+ install_plugin:
+ git:
+ url: https://github.com/hui-z/flutter_install_plugin.git
+ ref: cf08af829f4a4145634f8a047108f505fdbe5eaa
dependency_overrides:
permission_handler_windows:
git:
@@ -108,7 +113,7 @@ flutter:
- assets/tray_icon/
- assets/extension/
# Browser extension native host binary, see https://github.com/GopeedLab/gopeed/tree/main/cmd/host
- - assets/host/
+ - assets/exec/
# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/assets-and-images/#resolution-aware