Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 22 additions & 2 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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 =
Expand Down Expand Up @@ -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<UpdateInfo | null>(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 (
<>
<main className="grid h-full min-h-0 grid-rows-[auto_auto_minmax(0,1fr)_132px] bg-slate-950 text-slate-100">
Expand Down Expand Up @@ -74,6 +93,7 @@ export default function App({ api = wailsRunnerApi }: AppProps) {
Edit
</button>
<div className="flex-1" />
<UpdateNotice info={updateInfo} onOpen={api.openExternalURL} />
<button
className={buttonClass}
type="button"
Expand Down
11 changes: 11 additions & 0 deletions frontend/src/api/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
RunnerApi,
Script,
TransactionMode,
UpdateInfo,
} from './types'

interface DesktopBinding {
Expand All @@ -25,10 +26,12 @@ interface DesktopBinding {
StopRun(): Promise<boolean>
ImportProfileFromDialog(): Promise<Profile | null>
ExportProfileToDialog(profileID: string): Promise<string>
CheckForUpdates(): Promise<UpdateInfo>
}

interface WailsRuntime {
EventsOn(eventName: string, callback: (payload?: unknown) => void): () => void
BrowserOpenURL(url: string): void
}

declare global {
Expand Down Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions frontend/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Profile[]>
getProfile(profileID: string): Promise<Profile>
Expand All @@ -93,5 +101,7 @@ export interface RunnerApi {
stopRun(): Promise<boolean>
importProfileFromDialog(): Promise<Profile | null>
exportProfileToDialog(profileID: string): Promise<string>
checkForUpdates(): Promise<UpdateInfo>
openExternalURL(url: string): void
onExecutionEvent(handler: (event: ExecutionEvent) => void): () => void
}
48 changes: 48 additions & 0 deletions frontend/src/components/UpdateNotice.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<UpdateNotice
info={{
currentTag: 'build-41',
latestTag: 'build-42',
available: true,
releaseUrl: 'https://github.com/renamed-owner/go-script-sql-runner/releases/tag/build-42',
downloadUrl:
'https://github.com/renamed-owner/go-script-sql-runner/releases/download/build-42/go-script-sql-runner.exe',
}}
onOpen={onOpen}
/>,
)

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(
<UpdateNotice
info={{
currentTag: 'build-42',
latestTag: 'build-42',
available: false,
releaseUrl: 'https://example.invalid/build-42',
downloadUrl: '',
}}
onOpen={vi.fn()}
/>,
)

expect(container).toBeEmptyDOMElement()
})
})
23 changes: 23 additions & 0 deletions frontend/src/components/UpdateNotice.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<button
type="button"
className="rounded-md border border-emerald-700 bg-emerald-950/70 px-2.5 py-1.5 text-xs font-medium text-emerald-200 transition hover:bg-emerald-900 focus:outline-none focus:ring-2 focus:ring-emerald-500"
onClick={() => onOpen(targetUrl)}
>
Update {info.latestTag}
</button>
)
}
8 changes: 8 additions & 0 deletions frontend/src/state/useRunnerController.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,14 @@ function fakeApi(overrides: Partial<RunnerApi> = {}) {
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 () => {
Expand Down
33 changes: 30 additions & 3 deletions internal/ui/wails/desktop_app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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()
Expand Down Expand Up @@ -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)
}
Loading
Loading