From 9fde938b77d29bde056b131ba52b79b8dd187a14 Mon Sep 17 00:00:00 2001 From: John Lin Date: Tue, 31 Mar 2026 20:41:42 +0800 Subject: [PATCH 1/2] Fix Windows support: platform-abstract process lifecycle, binary update, and install script Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- cmd/proc_unix.go | 23 +++++++++++++ cmd/proc_windows.go | 30 ++++++++++++++++- cmd/restart.go | 3 +- cmd/start.go | 20 +++-------- cmd/update.go | 57 +++++++++++++++++++------------ install.ps1 | 81 +++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 174 insertions(+), 40 deletions(-) create mode 100644 install.ps1 diff --git a/cmd/proc_unix.go b/cmd/proc_unix.go index 1aca3ae..eabdde9 100644 --- a/cmd/proc_unix.go +++ b/cmd/proc_unix.go @@ -3,10 +3,33 @@ package cmd import ( + "context" + "os" "os/exec" + "os/signal" "syscall" ) +func notifyContext(ctx context.Context) (context.Context, context.CancelFunc) { + return signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM) +} + func setSysProcAttr(cmd *exec.Cmd) { cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} } + +func processExists(pid int) bool { + p, err := os.FindProcess(pid) + if err != nil { + return false + } + return p.Signal(syscall.Signal(0)) == nil +} + +func signalTerminate(p *os.Process) error { + return p.Signal(syscall.SIGTERM) +} + +func killByName(exePath string) { + _ = exec.Command("pkill", "-f", exePath+" start").Run() +} diff --git a/cmd/proc_windows.go b/cmd/proc_windows.go index 5507e2f..80d10c3 100644 --- a/cmd/proc_windows.go +++ b/cmd/proc_windows.go @@ -2,8 +2,36 @@ package cmd -import "os/exec" +import ( + "context" + "fmt" + "os" + "os/exec" + "os/signal" + "path/filepath" + "strings" +) + +func notifyContext(ctx context.Context) (context.Context, context.CancelFunc) { + return signal.NotifyContext(ctx, os.Interrupt) +} func setSysProcAttr(_ *exec.Cmd) { // No Setsid on Windows — process is already detached via Start() } + +func processExists(pid int) bool { + out, err := exec.Command("tasklist", "/FI", fmt.Sprintf("PID eq %d", pid), "/NH").Output() + if err != nil { + return false + } + return strings.Contains(string(out), fmt.Sprintf("%d", pid)) +} + +func signalTerminate(p *os.Process) error { + return p.Kill() +} + +func killByName(exePath string) { + _ = exec.Command("taskkill", "/F", "/IM", filepath.Base(exePath)).Run() +} diff --git a/cmd/restart.go b/cmd/restart.go index e19de96..b90217c 100644 --- a/cmd/restart.go +++ b/cmd/restart.go @@ -3,7 +3,6 @@ package cmd import ( "fmt" "os" - "syscall" "time" "github.com/spf13/cobra" @@ -22,7 +21,7 @@ var restartCmd = &cobra.Command{ if err == nil && processExists(pid) { fmt.Printf("Stopping weclaw (pid=%d)...\n", pid) if p, err := os.FindProcess(pid); err == nil { - p.Signal(syscall.SIGTERM) + _ = signalTerminate(p) } for i := 0; i < 20; i++ { if !processExists(pid) { diff --git a/cmd/start.go b/cmd/start.go index 48fc818..efb5510 100644 --- a/cmd/start.go +++ b/cmd/start.go @@ -6,10 +6,8 @@ import ( "log" "os" "os/exec" - "os/signal" "path/filepath" "sync" - "syscall" "time" "github.com/fastclaw-ai/weclaw/agent" @@ -44,7 +42,7 @@ func runStart(cmd *cobra.Command, args []string) error { accounts, _ := ilink.LoadAllCredentials() if len(accounts) == 0 { fmt.Println("No WeChat accounts found, starting login...") - ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + ctx, cancel := notifyContext(context.Background()) _, err := doLogin(ctx) cancel() if err != nil { @@ -54,7 +52,7 @@ func runStart(cmd *cobra.Command, args []string) error { return runDaemon() } - ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + ctx, cancel := notifyContext(context.Background()) defer cancel() // Load all accounts @@ -405,21 +403,12 @@ func readPid() (int, error) { return pid, nil } -func processExists(pid int) bool { - p, err := os.FindProcess(pid) - if err != nil { - return false - } - // Signal 0 checks if process exists without killing it - return p.Signal(syscall.Signal(0)) == nil -} - // stopAllWeclaw kills all running weclaw processes (by PID file and by process scan). func stopAllWeclaw() { // 1. Kill by PID file if pid, err := readPid(); err == nil && processExists(pid) { if p, err := os.FindProcess(pid); err == nil { - _ = p.Signal(syscall.SIGTERM) + _ = signalTerminate(p) } } os.Remove(pidFile()) @@ -429,7 +418,6 @@ func stopAllWeclaw() { if err != nil { return } - // Use pkill to kill all processes matching the executable path - _ = exec.Command("pkill", "-f", exe+" start").Run() + killByName(exe) time.Sleep(500 * time.Millisecond) } diff --git a/cmd/update.go b/cmd/update.go index fc86af1..49b56b2 100644 --- a/cmd/update.go +++ b/cmd/update.go @@ -1,7 +1,6 @@ package cmd import ( - "encoding/json" "fmt" "io" "log" @@ -122,23 +121,27 @@ func runUpdate(cmd *cobra.Command, args []string) error { } func getLatestVersion() (string, error) { - resp, err := http.Get(fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", githubRepo)) + // Use HTTP redirect instead of API to avoid rate limits + client := &http.Client{ + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + } + resp, err := client.Get(fmt.Sprintf("https://github.com/%s/releases/latest", githubRepo)) if err != nil { return "", err } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("GitHub API returned %d", resp.StatusCode) - } + resp.Body.Close() - var release struct { - TagName string `json:"tag_name"` + loc := resp.Header.Get("Location") + if loc == "" { + return "", fmt.Errorf("no redirect from GitHub releases/latest") } - if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { - return "", err + parts := strings.Split(loc, "/tag/") + if len(parts) != 2 || parts[1] == "" { + return "", fmt.Errorf("unexpected redirect URL: %s", loc) } - return release.TagName, nil + return parts[1], nil } func downloadFile(url string) (string, error) { @@ -178,17 +181,29 @@ func replaceBinary(src, dst string) error { return nil } - // Try with sudo on Unix - if runtime.GOOS != "windows" { - fmt.Printf("Installing to %s (requires sudo)...\n", dst) - cmd := exec.Command("sudo", "cp", src, dst) - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - return cmd.Run() + if runtime.GOOS == "windows" { + // On Windows the running binary is locked. Move it aside first, + // then place the new binary. The old file will be cleaned up on + // next restart or can be deleted manually. + old := dst + ".old" + os.Remove(old) + if err := os.Rename(dst, old); err != nil { + return fmt.Errorf("cannot move old binary aside: %w", err) + } + if err := os.Rename(src, dst); err != nil { + os.Rename(old, dst) + return fmt.Errorf("cannot install new binary: %w", err) + } + return nil } - return fmt.Errorf("cannot write to %s", dst) + // Try with sudo on Unix + fmt.Printf("Installing to %s (requires sudo)...\n", dst) + cmd := exec.Command("sudo", "cp", src, dst) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() } func resolveSymlink(path string) (string, error) { diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 0000000..5da9875 --- /dev/null +++ b/install.ps1 @@ -0,0 +1,81 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + Install weclaw on Windows. +.DESCRIPTION + Downloads the latest weclaw release from GitHub and installs it + to $env:LOCALAPPDATA\weclaw. Adds the directory to the user PATH + if not already present. +#> + +$ErrorActionPreference = "Stop" + +$Repo = "fastclaw-ai/weclaw" +$Binary = "weclaw" +$InstallDir = "$env:LOCALAPPDATA\weclaw" + +# Detect architecture +$Arch = switch ($env:PROCESSOR_ARCHITECTURE) { + "AMD64" { "amd64" } + "ARM64" { "arm64" } + default { Write-Error "Unsupported architecture: $env:PROCESSOR_ARCHITECTURE"; exit 1 } +} + +Write-Host "Detected: windows/$Arch" + +# Get latest version via redirect +Write-Host "Fetching latest release..." +try { + $Response = Invoke-WebRequest -Uri "https://github.com/$Repo/releases/latest" ` + -MaximumRedirection 0 -ErrorAction SilentlyContinue -UseBasicParsing +} catch { + $Response = $_.Exception.Response +} + +$Location = if ($Response.Headers["Location"]) { + $Response.Headers["Location"] +} elseif ($Response.Headers.Location) { + $Response.Headers.Location +} else { + $null +} + +if (-not $Location) { + Write-Error "Could not determine latest version. Is there a release on GitHub?" + exit 1 +} + +$Version = ($Location -split "/tag/")[-1].Trim() +Write-Host "Latest version: $Version" + +# Download +$Filename = "${Binary}_windows_${Arch}.exe" +$Url = "https://github.com/$Repo/releases/download/$Version/$Filename" + +Write-Host "Downloading $Url..." +$TmpFile = Join-Path $env:TEMP $Filename + +Invoke-WebRequest -Uri $Url -OutFile $TmpFile -UseBasicParsing + +# Install +if (-not (Test-Path $InstallDir)) { + New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null +} + +$DestPath = Join-Path $InstallDir "$Binary.exe" +Move-Item -Path $TmpFile -Destination $DestPath -Force + +# Add to PATH if not already present +$UserPath = [Environment]::GetEnvironmentVariable("Path", "User") +if ($UserPath -notlike "*$InstallDir*") { + [Environment]::SetEnvironmentVariable("Path", "$UserPath;$InstallDir", "User") + Write-Host "" + Write-Host "Added $InstallDir to user PATH." + Write-Host "Please restart your terminal for PATH changes to take effect." +} + +Write-Host "" +Write-Host "weclaw $Version installed to $DestPath" +Write-Host "" +Write-Host "Get started:" +Write-Host " weclaw start" From 6ae95de567c1015ff40e98e8b20803fc9f65844f Mon Sep 17 00:00:00 2001 From: John Lin Date: Tue, 31 Mar 2026 20:48:21 +0800 Subject: [PATCH 2/2] Use HTTP redirect in install.sh to avoid GitHub API rate limits Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- install.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/install.sh b/install.sh index f57e61f..1368639 100755 --- a/install.sh +++ b/install.sh @@ -24,7 +24,7 @@ echo "Detected: ${OS}/${ARCH}" # Get latest version echo "Fetching latest release..." -VERSION=$(curl -fsSL -H "User-Agent: weclaw-installer" "https://api.github.com/repos/${REPO}/releases/latest" | sed -n 's/.*"tag_name" *: *"\([^"]*\)".*/\1/p') +VERSION=$(curl -fsSI "https://github.com/${REPO}/releases/latest" | grep -i '^location:' | sed 's|.*/tag/||' | tr -d '\r') if [ -z "$VERSION" ]; then echo "Error: could not determine latest version. Is there a release on GitHub?"