diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f11501c..1edaa82 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -87,7 +87,7 @@ jobs: run: go install github.com/wailsapp/wails/v2/cmd/wails@v2.13.0 - name: Build executable - run: wails build -clean -webview2 embed -windowsconsole + run: wails build -clean -webview2 embed -windowsconsole -ldflags "-X main.currentBuildTag=build-${{ github.run_number }}" - name: Verify executable shell: pwsh diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a7abad7..fef540f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,8 +1,9 @@ -import { useState } from 'react' +import { useEffect, useState } from 'react' import { wailsRunnerApi } from './api/runner' -import type { OnError, RunnerApi, TransactionMode } from './api/types' +import type { OnError, RunnerApi, TransactionMode, UpdateInfo } from './api/types' import ProfileDialog from './components/ProfileDialog' import SchemaSelect from './components/SchemaSelect' +import UpdateNotice from './components/UpdateNotice' import { useRunnerController } from './state/useRunnerController' const buttonClass = @@ -30,12 +31,30 @@ export default function App({ api = wailsRunnerApi }: AppProps) { const controller = useRunnerController(api) const [detailedLogs, setDetailedLogs] = useState(false) const [profileDialogMode, setProfileDialogMode] = useState<'create' | 'edit' | null>(null) + const [updateInfo, setUpdateInfo] = useState(null) const profile = controller.selectedProfile const scripts = [...(profile?.scripts ?? [])].sort((left, right) => left.order - right.order) const connectionSummary = profile ? `${profile.connection.host}:${profile.connection.port}` : 'No connection configured' + useEffect(() => { + let cancelled = false + + void (async () => { + try { + const info = await api.checkForUpdates() + if (!cancelled) setUpdateInfo(info) + } catch { + // Update checks are best-effort and must never block the runner UI. + } + })() + + return () => { + cancelled = true + } + }, [api]) + return ( <>
@@ -74,6 +93,7 @@ export default function App({ api = wailsRunnerApi }: AppProps) { Edit
+ + ) +} diff --git a/frontend/src/state/useRunnerController.test.tsx b/frontend/src/state/useRunnerController.test.tsx index b0f5e81..221a67b 100644 --- a/frontend/src/state/useRunnerController.test.tsx +++ b/frontend/src/state/useRunnerController.test.tsx @@ -66,6 +66,14 @@ function fakeApi(overrides: Partial = {}) { stopRun: vi.fn().mockResolvedValue(true), importProfileFromDialog: vi.fn().mockResolvedValue(null), exportProfileToDialog: vi.fn().mockResolvedValue(''), + checkForUpdates: vi.fn().mockResolvedValue({ + currentTag: 'dev', + latestTag: 'dev', + available: false, + releaseUrl: '', + downloadUrl: '', + }), + openExternalURL: vi.fn(), onExecutionEvent: vi.fn().mockImplementation((handler: (event: ExecutionEvent) => void) => { executionHandler = handler return () => { diff --git a/internal/ui/wails/desktop_app.go b/internal/ui/wails/desktop_app.go index 3cb0965..7886452 100644 --- a/internal/ui/wails/desktop_app.go +++ b/internal/ui/wails/desktop_app.go @@ -9,17 +9,22 @@ import ( "github.com/vitorhugo-dotnet/go-script-sql-runner/internal/executor" "github.com/vitorhugo-dotnet/go-script-sql-runner/internal/profile" "github.com/vitorhugo-dotnet/go-script-sql-runner/internal/ui" + "github.com/vitorhugo-dotnet/go-script-sql-runner/internal/updatecheck" ) type DesktopApp struct { bridge *ui.Bridge - mu sync.RWMutex - ctx context.Context + mu sync.RWMutex + ctx context.Context + currentBuildTag string } func NewDesktopApp(bridge *ui.Bridge) *DesktopApp { - return &DesktopApp{bridge: bridge} + return &DesktopApp{ + bridge: bridge, + currentBuildTag: "dev", + } } func (a *DesktopApp) Startup(ctx context.Context) { @@ -28,6 +33,15 @@ func (a *DesktopApp) Startup(ctx context.Context) { a.mu.Unlock() } +func (a *DesktopApp) SetCurrentBuildTag(tag string) { + if tag == "" { + tag = "dev" + } + a.mu.Lock() + a.currentBuildTag = tag + a.mu.Unlock() +} + func (a *DesktopApp) appContext() (context.Context, error) { a.mu.RLock() defer a.mu.RUnlock() @@ -124,3 +138,16 @@ func (a *DesktopApp) ExportProfileToDialog(profileID string) (string, error) { if err != nil { return "", err } return a.bridge.ExportProfileToDialog(ctx, profileID) } + +func (a *DesktopApp) CheckForUpdates() (updatecheck.Result, error) { + ctx, err := a.appContext() + if err != nil { + return updatecheck.Result{}, err + } + + a.mu.RLock() + currentBuildTag := a.currentBuildTag + a.mu.RUnlock() + + return updatecheck.New(currentBuildTag).Check(ctx) +} diff --git a/internal/updatecheck/checker.go b/internal/updatecheck/checker.go new file mode 100644 index 0000000..ed6607c --- /dev/null +++ b/internal/updatecheck/checker.go @@ -0,0 +1,140 @@ +package updatecheck + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +const ( + RepositoryID int64 = 1326685411 + defaultAPIBaseURL = "https://api.github.com" + defaultAssetName = "go-script-sql-runner.exe" + githubAPIVersion = "2026-03-10" +) + +type Result struct { + CurrentTag string `json:"currentTag"` + LatestTag string `json:"latestTag"` + Available bool `json:"available"` + ReleaseURL string `json:"releaseUrl"` + DownloadURL string `json:"downloadUrl"` +} + +type Checker struct { + client *http.Client + apiBaseURL string + repositoryID int64 + currentTag string +} + +type repositoryResponse struct { + FullName string `json:"full_name"` +} + +type releaseResponse struct { + TagName string `json:"tag_name"` + HTMLURL string `json:"html_url"` + Assets []struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` + } `json:"assets"` +} + +func New(currentTag string) *Checker { + return &Checker{ + client: &http.Client{Timeout: 5 * time.Second}, + apiBaseURL: defaultAPIBaseURL, + repositoryID: RepositoryID, + currentTag: strings.TrimSpace(currentTag), + } +} + +func (c *Checker) Check(ctx context.Context) (Result, error) { + var repository repositoryResponse + if err := c.getJSON(ctx, fmt.Sprintf("/repositories/%d", c.repositoryID), &repository); err != nil { + return Result{}, fmt.Errorf("resolve update repository: %w", err) + } + + parts := strings.Split(strings.TrimSpace(repository.FullName), "/") + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return Result{}, fmt.Errorf("resolve update repository: invalid full_name %q", repository.FullName) + } + + releasePath := fmt.Sprintf( + "/repos/%s/%s/releases/latest", + url.PathEscape(parts[0]), + url.PathEscape(parts[1]), + ) + var release releaseResponse + if err := c.getJSON(ctx, releasePath, &release); err != nil { + return Result{}, fmt.Errorf("get latest release: %w", err) + } + + result := Result{ + CurrentTag: c.currentTag, + LatestTag: release.TagName, + Available: isNewerBuild(c.currentTag, release.TagName), + ReleaseURL: release.HTMLURL, + } + for _, asset := range release.Assets { + if asset.Name == defaultAssetName { + result.DownloadURL = asset.BrowserDownloadURL + break + } + } + + return result, nil +} + +func (c *Checker) getJSON(ctx context.Context, path string, target any) error { + if c.client == nil { + return fmt.Errorf("HTTP client is not configured") + } + + requestURL := strings.TrimRight(c.apiBaseURL, "/") + path + req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil) + if err != nil { + return err + } + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("X-GitHub-Api-Version", githubAPIVersion) + req.Header.Set("User-Agent", "go-script-sql-runner") + + resp, err := c.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return fmt.Errorf("GitHub API returned %s", resp.Status) + } + if err := json.NewDecoder(resp.Body).Decode(target); err != nil { + return fmt.Errorf("decode GitHub API response: %w", err) + } + return nil +} + +func isNewerBuild(currentTag, latestTag string) bool { + currentBuild, currentOK := parseBuildNumber(currentTag) + latestBuild, latestOK := parseBuildNumber(latestTag) + return currentOK && latestOK && latestBuild > currentBuild +} + +func parseBuildNumber(tag string) (int, bool) { + value := strings.TrimPrefix(strings.TrimSpace(tag), "build-") + if value == tag || value == "" { + return 0, false + } + build, err := strconv.Atoi(value) + if err != nil || build < 0 { + return 0, false + } + return build, true +} diff --git a/internal/updatecheck/checker_test.go b/internal/updatecheck/checker_test.go new file mode 100644 index 0000000..da60883 --- /dev/null +++ b/internal/updatecheck/checker_test.go @@ -0,0 +1,98 @@ +package updatecheck + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "reflect" + "testing" +) + +func TestCheckerResolvesRepositoryByIDBeforeLatestRelease(t *testing.T) { + var requestedPaths []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestedPaths = append(requestedPaths, r.URL.Path) + w.Header().Set("Content-Type", "application/json") + + switch r.URL.Path { + case "/repositories/1326685411": + _ = json.NewEncoder(w).Encode(map[string]string{ + "full_name": "renamed-owner/go-script-sql-runner", + }) + case "/repos/renamed-owner/go-script-sql-runner/releases/latest": + _ = json.NewEncoder(w).Encode(map[string]any{ + "tag_name": "build-42", + "html_url": "https://github.com/renamed-owner/go-script-sql-runner/releases/tag/build-42", + "assets": []map[string]string{ + { + "name": "go-script-sql-runner.exe", + "browser_download_url": "https://github.com/renamed-owner/go-script-sql-runner/releases/download/build-42/go-script-sql-runner.exe", + }, + }, + }) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + checker := &Checker{ + client: server.Client(), + apiBaseURL: server.URL, + repositoryID: 1326685411, + currentTag: "build-41", + } + + result, err := checker.Check(context.Background()) + if err != nil { + t.Fatalf("Check() error = %v", err) + } + + wantPaths := []string{ + "/repositories/1326685411", + "/repos/renamed-owner/go-script-sql-runner/releases/latest", + } + if !reflect.DeepEqual(requestedPaths, wantPaths) { + t.Fatalf("requested paths = %v, want %v", requestedPaths, wantPaths) + } + if !result.Available { + t.Fatal("expected update to be available") + } + if result.LatestTag != "build-42" { + t.Fatalf("LatestTag = %q, want build-42", result.LatestTag) + } + if result.DownloadURL == "" { + t.Fatal("expected executable download URL") + } +} + +func TestCheckerDoesNotMarkCurrentBuildAsUpdate(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/repositories/1326685411": + _, _ = w.Write([]byte(`{"full_name":"owner/go-script-sql-runner"}`)) + case "/repos/owner/go-script-sql-runner/releases/latest": + _, _ = w.Write([]byte(`{"tag_name":"build-42","html_url":"https://example.invalid/build-42","assets":[]}`)) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + checker := &Checker{ + client: server.Client(), + apiBaseURL: server.URL, + repositoryID: 1326685411, + currentTag: "build-42", + } + + result, err := checker.Check(context.Background()) + if err != nil { + t.Fatalf("Check() error = %v", err) + } + if result.Available { + t.Fatal("did not expect current build to be marked as an update") + } +} diff --git a/main.go b/main.go index 72958c0..4ebca85 100644 --- a/main.go +++ b/main.go @@ -18,6 +18,8 @@ import ( //go:embed all:frontend/dist var assets embed.FS +var currentBuildTag = "dev" + func main() { os.Exit(run()) } @@ -43,6 +45,7 @@ func run() int { events := wailsui.EventAdapter{} bridge := ui.NewBridge(runtime.Service, dialogs, events) desktop := wailsui.NewDesktopApp(bridge) + desktop.SetCurrentBuildTag(currentBuildTag) err = wails.Run(&options.App{ Title: "Go Script SQL Runner",