From 404f9d0c65b0a92e44b9ebd90f65f20841fd7b47 Mon Sep 17 00:00:00 2001 From: Vitor Hugo <65777252+vitorhugo-dotnet@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:39:47 -0300 Subject: [PATCH 01/12] test: define repository id update lookup --- internal/updatecheck/checker_test.go | 98 ++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 internal/updatecheck/checker_test.go 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") + } +} From f3eefec735d6a4838f2f9880e10cc103e5a058f7 Mon Sep 17 00:00:00 2001 From: Vitor Hugo <65777252+vitorhugo-dotnet@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:39:57 -0300 Subject: [PATCH 02/12] test: define update availability notice --- frontend/src/components/UpdateNotice.test.tsx | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 frontend/src/components/UpdateNotice.test.tsx diff --git a/frontend/src/components/UpdateNotice.test.tsx b/frontend/src/components/UpdateNotice.test.tsx new file mode 100644 index 0000000..808c1c1 --- /dev/null +++ b/frontend/src/components/UpdateNotice.test.tsx @@ -0,0 +1,48 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' +import UpdateNotice from './UpdateNotice' + +describe('UpdateNotice', () => { + it('opens the executable download when an update is available', async () => { + const user = userEvent.setup() + const onOpen = vi.fn() + + render( + , + ) + + await user.click(screen.getByRole('button', { name: 'Update build-42' })) + + expect(onOpen).toHaveBeenCalledWith( + 'https://github.com/renamed-owner/go-script-sql-runner/releases/download/build-42/go-script-sql-runner.exe', + ) + }) + + it('renders nothing when the installed build is current', () => { + const { container } = render( + , + ) + + expect(container).toBeEmptyDOMElement() + }) +}) From efb234fd8253a7a320527462b95984ebd464cb9f Mon Sep 17 00:00:00 2001 From: Vitor Hugo <65777252+vitorhugo-dotnet@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:40:23 -0300 Subject: [PATCH 03/12] feat: resolve update releases by repository id --- internal/updatecheck/checker.go | 140 ++++++++++++++++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 internal/updatecheck/checker.go 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 +} From 7300b3a55ad2d714790deccd53a284713e25e7be Mon Sep 17 00:00:00 2001 From: Vitor Hugo <65777252+vitorhugo-dotnet@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:40:31 -0300 Subject: [PATCH 04/12] feat: add update availability notice --- frontend/src/components/UpdateNotice.tsx | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 frontend/src/components/UpdateNotice.tsx diff --git a/frontend/src/components/UpdateNotice.tsx b/frontend/src/components/UpdateNotice.tsx new file mode 100644 index 0000000..e08baee --- /dev/null +++ b/frontend/src/components/UpdateNotice.tsx @@ -0,0 +1,23 @@ +import type { UpdateInfo } from '../api/types' + +interface UpdateNoticeProps { + info: UpdateInfo | null + onOpen(url: string): void +} + +export default function UpdateNotice({ info, onOpen }: UpdateNoticeProps) { + if (!info?.available) return null + + const targetUrl = info.downloadUrl || info.releaseUrl + if (!targetUrl) return null + + return ( + + ) +} From 45f31c3c29fe608b53c4ec7f66d1c68d75469775 Mon Sep 17 00:00:00 2001 From: Vitor Hugo <65777252+vitorhugo-dotnet@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:40:43 -0300 Subject: [PATCH 05/12] feat: expose update checker frontend types --- frontend/src/api/types.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 5fa598e..8511b60 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -77,6 +77,14 @@ export interface RunOptions { transactionMode?: TransactionMode | '' } +export interface UpdateInfo { + currentTag: string + latestTag: string + available: boolean + releaseUrl: string + downloadUrl: string +} + export interface RunnerApi { listProfiles(): Promise getProfile(profileID: string): Promise @@ -93,5 +101,7 @@ export interface RunnerApi { stopRun(): Promise importProfileFromDialog(): Promise exportProfileToDialog(profileID: string): Promise + checkForUpdates(): Promise + openExternalURL(url: string): void onExecutionEvent(handler: (event: ExecutionEvent) => void): () => void } From d2dd010fee013a99b04fdaec6b5a18b2d752f7a0 Mon Sep 17 00:00:00 2001 From: Vitor Hugo <65777252+vitorhugo-dotnet@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:40:58 -0300 Subject: [PATCH 06/12] feat: bridge update checks to desktop UI --- frontend/src/api/runner.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/frontend/src/api/runner.ts b/frontend/src/api/runner.ts index 365e7cf..a7e7a82 100644 --- a/frontend/src/api/runner.ts +++ b/frontend/src/api/runner.ts @@ -7,6 +7,7 @@ import type { RunnerApi, Script, TransactionMode, + UpdateInfo, } from './types' interface DesktopBinding { @@ -25,10 +26,12 @@ interface DesktopBinding { StopRun(): Promise ImportProfileFromDialog(): Promise ExportProfileToDialog(profileID: string): Promise + CheckForUpdates(): Promise } interface WailsRuntime { EventsOn(eventName: string, callback: (payload?: unknown) => void): () => void + BrowserOpenURL(url: string): void } declare global { @@ -67,6 +70,14 @@ export const wailsRunnerApi: RunnerApi = { stopRun: () => desktop().StopRun(), importProfileFromDialog: () => desktop().ImportProfileFromDialog(), exportProfileToDialog: (profileID) => desktop().ExportProfileToDialog(profileID), + checkForUpdates: () => desktop().CheckForUpdates(), + openExternalURL: (url) => { + if (window.runtime?.BrowserOpenURL) { + window.runtime.BrowserOpenURL(url) + return + } + window.open(url, '_blank', 'noopener,noreferrer') + }, onExecutionEvent: (handler) => { if (!window.runtime?.EventsOn) { return () => undefined From 2dfcf04332d7b4e563edddc614046329deb71288 Mon Sep 17 00:00:00 2001 From: Vitor Hugo <65777252+vitorhugo-dotnet@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:41:19 -0300 Subject: [PATCH 07/12] feat: expose repository id update check to Wails --- internal/ui/wails/desktop_app.go | 33 +++++++++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) 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) +} From 3e9408f8779bf0ec37f3f53e3e0b05ccd1b11d20 Mon Sep 17 00:00:00 2001 From: Vitor Hugo <65777252+vitorhugo-dotnet@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:41:31 -0300 Subject: [PATCH 08/12] feat: pass embedded build tag to desktop updater --- main.go | 3 +++ 1 file changed, 3 insertions(+) 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", From 3de69893a35b417d770faac623c13931b17d944a Mon Sep 17 00:00:00 2001 From: Vitor Hugo <65777252+vitorhugo-dotnet@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:42:03 -0300 Subject: [PATCH 09/12] feat: check releases when the desktop app opens --- frontend/src/App.tsx | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) 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
+