Skip to content

Commit 78afb56

Browse files
alicodingclaude
andauthored
fix: desktop Export downloads -- native save dialog for the Wails webview (#220)
Every Export button's Blob + synthetic-anchor-click download was silently inert on the installed desktop build: the Wails webview supplies no download handler for a synthetic anchor click, despite downloadJSON.ts's own header comment claiming webview parity. Server mode (a real browser tab) was never affected. downloadJSON now routes by runtime: desktop calls the new SettingsService.SaveTextFile binding (a native OS save dialog, backed by Wails3's own Dialog manager) and writes the chosen path directly; server mode keeps the existing Blob + anchor path unchanged. CompositionView's separate inline copy of the same broken mechanism is retrofitted onto the shared helper instead of carrying its own fix. Also fixes lefthook.yml's golangci-lint job to pass --build-tags=server, matching ci.yml's own args -- without it the local hook lints a wider (desktop-tag) file set than CI's job ever compiles, so it can fail on pre-existing findings CI's real gate never sees. Claude-Session: https://claude.ai/code/session_01FW5GkkAG8du7tNdYLk2zSd Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 17d0618 commit 78afb56

6 files changed

Lines changed: 121 additions & 24 deletions

File tree

frontend/bindings/github.com/alicoding/mill/internal/services/settingssvc/settingsservice.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,26 @@ export function RestoreSummonHotkey(): $CancellablePromise<void> {
356356
return $Call.ByID(442997791);
357357
}
358358

359+
/**
360+
* SaveTextFile prompts with the OS-native save dialog (suggestedName
361+
* pre-filled) and writes content to whatever path the user picks --
362+
* the desktop-mode counterpart to a browser's own download prompt,
363+
* which the Wails webview does not provide (an anchor-element download
364+
* click is silently inert there). Returns the chosen path, or "" with
365+
* a nil error when the user cancels. Server mode and any other run
366+
* without a live windowed application (a headless `go test`, in
367+
* particular) return a nil app from application.Get() -- the same
368+
* no-op guard atlasservice_share.go's revealPath uses -- and this
369+
* method reports that as an error rather than a silent no-op, since
370+
* unlike an OS-reveal action a caller genuinely needs to know the
371+
* save never happened. A real `-tags server` build additionally
372+
* degrades through Wails3's own server-mode dialog stub, which
373+
* already returns an equivalent "not available" error.
374+
*/
375+
export function SaveTextFile(suggestedName: string, content: string): $CancellablePromise<string> {
376+
return $Call.ByID(891957520, suggestedName, content);
377+
}
378+
359379
/**
360380
* SetAttentionIdleThreshold persists seconds (a non-positive value
361381
* resets to the default, mirroring a cleared Settings field rather than

frontend/src/composition/CompositionView.tsx

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { InventoryList, type InventoryItem } from '../shared/InventoryList'
1212
import { ENTITY_ICON } from '../shared/entityIcons'
1313
import { formatUpdated, sortByUpdatedDesc } from '../shared/inventorySort'
1414
import { describeSeedReset } from '../shared/seedLifecycle'
15+
import { downloadJSON } from '../shared/downloadJSON'
1516
import { RestoreExamplesButton } from '../shared/RestoreExamplesButton'
1617
import { useImportConfirm } from '../shared/useImportConfirm'
1718
import type { Workflow } from '../../bindings/github.com/alicoding/mill/internal/domain/composition/models'
@@ -216,19 +217,11 @@ function CompositionView() {
216217

217218
// Downloads id's current definition as a portable .json file --
218219
// ExportWorkflow's own doc comment covers why the output is
219-
// deterministic. A Blob + synthetic anchor click is the standard
220-
// browser download mechanism, identical inside the Wails webview.
220+
// deterministic; downloadJSON routes the actual save through the
221+
// right mechanism for the current runtime.
221222
const exportWorkflow = (id: string, label: string) => {
222223
CompositionService.ExportWorkflow(id)
223-
.then((json) => {
224-
const blob = new Blob([json], { type: 'application/json' })
225-
const url = URL.createObjectURL(blob)
226-
const a = document.createElement('a')
227-
a.href = url
228-
a.download = `${label.trim() || 'workflow'}.json`
229-
a.click()
230-
URL.revokeObjectURL(url)
231-
})
224+
.then((json) => downloadJSON(`${label.trim() || 'workflow'}.json`, json))
232225
.catch((err) => setImportError(String(err)))
233226
}
234227

frontend/src/shared/downloadJSON.ts

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,22 @@
1-
// The standard browser Blob + synthetic-anchor-click download mechanism,
2-
// used identically by every Configure entity's Export button
3-
// (ConfigureRequests/Lists/MCPServers.tsx) -- extracted once the same
4-
// ~8 lines were about to be written a third time (CLAUDE.md: three
5-
// similar lines is fine, a fourth repetition is the actual signal to
6-
// share it). Works the same inside Mill's own Wails webview as it does
7-
// in a normal browser tab, no native file-save API needed. Composition's
8-
// own Export button (CompositionView.tsx, built first) keeps its
9-
// original inline version rather than being retrofitted to this --
10-
// already shipped and tested, and churning it for a marginal DRY gain
11-
// isn't worth the re-verification cost.
12-
export function downloadJSON(filename: string, json: string) {
1+
import { SettingsService } from './bindings'
2+
3+
// Every Export button's download mechanism (Atlas toolbar, Configure
4+
// Requests/Lists/MCPServers, and CompositionView's own workflow
5+
// export) routes through this one function. A synthetic anchor-click
6+
// download needs the browser to supply a download handler -- the
7+
// Wails webview doesn't provide one, so the anchor click is silently
8+
// inert there even though nothing about it errors. Desktop mode
9+
// therefore goes through SettingsService.SaveTextFile (the OS-native
10+
// save dialog); server mode (a real browser tab) keeps the standard
11+
// Blob + synthetic-anchor-click path, which works there exactly as it
12+
// does in any other web app.
13+
export async function downloadJSON(filename: string, json: string): Promise<void> {
14+
const buildInfo = await SettingsService.GetBuildInfo().catch(() => null)
15+
const isNativeWebview = buildInfo != null && !buildInfo.Server
16+
if (isNativeWebview) {
17+
await SettingsService.SaveTextFile(filename, json)
18+
return
19+
}
1320
const blob = new Blob([json], { type: 'application/json' })
1421
const url = URL.createObjectURL(blob)
1522
const a = document.createElement('a')
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package settingssvc
2+
3+
import (
4+
"fmt"
5+
"os"
6+
7+
"github.com/wailsapp/wails/v3/pkg/application"
8+
)
9+
10+
// SaveTextFile prompts with the OS-native save dialog (suggestedName
11+
// pre-filled) and writes content to whatever path the user picks --
12+
// the desktop-mode counterpart to a browser's own download prompt,
13+
// which the Wails webview does not provide (an anchor-element download
14+
// click is silently inert there). Returns the chosen path, or "" with
15+
// a nil error when the user cancels. Server mode and any other run
16+
// without a live windowed application (a headless `go test`, in
17+
// particular) return a nil app from application.Get() -- the same
18+
// no-op guard atlasservice_share.go's revealPath uses -- and this
19+
// method reports that as an error rather than a silent no-op, since
20+
// unlike an OS-reveal action a caller genuinely needs to know the
21+
// save never happened. A real `-tags server` build additionally
22+
// degrades through Wails3's own server-mode dialog stub, which
23+
// already returns an equivalent "not available" error.
24+
func (s *SettingsService) SaveTextFile(suggestedName, content string) (string, error) {
25+
app := application.Get()
26+
if app == nil {
27+
return "", fmt.Errorf("native file save is not available in this mode")
28+
}
29+
path, err := app.Dialog.SaveFile().SetFilename(suggestedName).PromptForSingleSelection()
30+
if err != nil {
31+
return "", fmt.Errorf("native file save is not available in this mode: %w", err)
32+
}
33+
if path == "" {
34+
return "", nil
35+
}
36+
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
37+
return "", fmt.Errorf("write file: %w", err)
38+
}
39+
return path, nil
40+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package settingssvc
2+
3+
import (
4+
"log/slog"
5+
"testing"
6+
7+
"github.com/alicoding/mill/internal/services/compositionsvc"
8+
"github.com/alicoding/mill/internal/services/servicetest"
9+
"github.com/alicoding/mill/internal/services/triggersvc"
10+
)
11+
12+
// TestSaveTextFile_NoLiveApplicationErrors: a headless `go test` run
13+
// never calls application.New, so application.Get() is nil -- the same
14+
// condition revealPath (atlasservice_share.go) treats as a silent
15+
// no-op. SaveTextFile must instead report a real error rather than
16+
// panic on the nil app, since a caller cannot tell "cancelled" apart
17+
// from "never ran" without one.
18+
func TestSaveTextFile_NoLiveApplicationErrors(t *testing.T) {
19+
store := servicetest.NewFakeStore()
20+
comp := compositionsvc.NewCompositionService(store)
21+
trig := triggersvc.NewTriggerService(comp, slog.Default(), store)
22+
set := NewSettingsService(store, trig, false)
23+
24+
path, err := set.SaveTextFile("export.json", `{"a":1}`)
25+
if err == nil {
26+
t.Fatal("SaveTextFile() with no live application: want an error, got nil")
27+
}
28+
if path != "" {
29+
t.Errorf("SaveTextFile() path = %q, want empty on error", path)
30+
}
31+
}

lefthook.yml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,13 @@ pre-commit:
4949
run: go vet . ./internal/...
5050
- name: golangci-lint
5151
glob: "*.go"
52-
run: golangci-lint run . ./internal/...
52+
# --build-tags=server matches ci.yml's own golangci-lint-action
53+
# args exactly -- without it this job lints the DEFAULT (desktop)
54+
# tag set, a strictly larger file set than CI's job ever compiles
55+
# (`!server`-tagged files CI's own -tags=server run never sees),
56+
# so a local pass here was checking something CI doesn't and a
57+
# local fail could block a commit CI's real gate would accept.
58+
run: golangci-lint run . ./internal/... --build-tags=server
5359
- name: go-test
5460
glob: "*.go"
5561
# Root package (`.`) included alongside ./internal/... -- the

0 commit comments

Comments
 (0)