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