From 45075b3328434b55b770da13eab8d747da19050b Mon Sep 17 00:00:00 2001 From: Elijah Duffy Date: Mon, 29 Jun 2026 22:23:35 -0700 Subject: [PATCH] feat(frontend): use Wails bindings for file I/O Replace hidden file inputs and anchor downloads with native open/save dialogs via a thin platform layer. WAV export sends base64 audio data to the Go backend for writing to the user-chosen path. Co-authored-by: Cursor --- fileservice.go | 8 +++- frontend/bindings/sfxkeeb/fileservice.ts | 4 +- frontend/package-lock.json | 48 ++++++++++++++++++++++-- frontend/package.json | 1 + frontend/src/App.svelte | 38 +++++++++---------- frontend/src/lib/platform/files.ts | 38 +++++++++++++++++++ frontend/src/lib/project.ts | 19 +++------- frontend/vite.config.ts | 6 ++- 8 files changed, 120 insertions(+), 42 deletions(-) create mode 100644 frontend/src/lib/platform/files.ts diff --git a/fileservice.go b/fileservice.go index 61f14d8..6583166 100644 --- a/fileservice.go +++ b/fileservice.go @@ -1,6 +1,7 @@ package main import ( + "encoding/base64" "net/http" "os" "path/filepath" @@ -102,11 +103,16 @@ func (s *FileService) SaveProject(content string, defaultName string) error { return os.WriteFile(path, []byte(content), 0o644) } -func (s *FileService) SaveAudio(data []byte, defaultName string) error { +func (s *FileService) SaveAudio(dataBase64 string, defaultName string) error { if defaultName == "" { defaultName = "keyboard_track.wav" } + data, err := base64.StdEncoding.DecodeString(dataBase64) + if err != nil { + return err + } + path, err := s.app.Dialog.SaveFile(). SetMessage("Export Audio"). SetFilename(defaultName). diff --git a/frontend/bindings/sfxkeeb/fileservice.ts b/frontend/bindings/sfxkeeb/fileservice.ts index 3a02264..09c339b 100644 --- a/frontend/bindings/sfxkeeb/fileservice.ts +++ b/frontend/bindings/sfxkeeb/fileservice.ts @@ -24,8 +24,8 @@ export function OpenVideo(): $CancellablePromise<$models.VideoResult> { }); } -export function SaveAudio(data: string, defaultName: string): $CancellablePromise { - return $Call.ByID(1785178400, data, defaultName); +export function SaveAudio(dataBase64: string, defaultName: string): $CancellablePromise { + return $Call.ByID(1785178400, dataBase64, defaultName); } export function SaveProject(content: string, defaultName: string): $CancellablePromise { diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 41a1f81..b8c3609 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,6 +11,7 @@ "@sveltejs/vite-plugin-svelte": "^7.1.2", "@tsconfig/svelte": "^5.0.8", "@types/node": "^24.12.3", + "@wailsio/runtime": "^3.0.0-alpha.95", "svelte": "^5.55.5", "svelte-check": "^4.4.8", "typescript": "~6.0.2", @@ -18,9 +19,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", "dev": true, "license": "MIT", "optional": true, @@ -330,6 +331,40 @@ "node": "^20.19.0 || >=22.12.0" } }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@rolldown/binding-win32-arm64-msvc": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", @@ -454,6 +489,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@wailsio/runtime": { + "version": "3.0.0-alpha.95", + "resolved": "https://registry.npmjs.org/@wailsio/runtime/-/runtime-3.0.0-alpha.95.tgz", + "integrity": "sha512-ODhaZYRrtRvwi6cfGEj4L+Ut9od9zTd7GH1BrAaONxlBYM4d31d3YG982HSZiktmIE2SqvyhTciY4sV5FbIfqA==", + "dev": true, + "license": "MIT" + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index b186136..d8c6884 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,6 +13,7 @@ "@sveltejs/vite-plugin-svelte": "^7.1.2", "@tsconfig/svelte": "^5.0.8", "@types/node": "^24.12.3", + "@wailsio/runtime": "^3.0.0-alpha.95", "svelte": "^5.55.5", "svelte-check": "^4.4.8", "typescript": "~6.0.2", diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index a4c4e04..8e34d9e 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -10,6 +10,7 @@ shouldRecordMarker, } from './lib/keyboard'; import { downloadProject, exportAudio, loadProject } from './lib/project'; + import { openProject, openVideo } from './lib/platform/files'; import { addMarker, app, @@ -28,8 +29,6 @@ import { attachVideoPlayer, type VideoController } from './lib/video/player'; let videoEl: HTMLVideoElement | undefined = $state(); - let videoInput: HTMLInputElement | undefined = $state(); - let projectInput: HTMLInputElement | undefined = $state(); let timeline: Timeline | undefined = $state(); let controller: VideoController | null = null; let audioEngine = createAudioEngine(); @@ -160,28 +159,27 @@ audioEngine.destroy(); }); - async function handleVideoFile(event: Event) { - const input = event.target as HTMLInputElement; - const file = input.files?.[0]; - if (!file) return; - setVideoUrl(URL.createObjectURL(file), file.name); - statusMessage = `Loaded video: ${file.name}`; - input.value = ''; + async function handleOpenVideo() { + try { + const result = await openVideo(); + if (!result) return; + setVideoUrl(result.url, result.name); + statusMessage = `Loaded video: ${result.name}`; + } catch (error) { + statusMessage = error instanceof Error ? error.message : 'Failed to open video'; + } } - async function handleProjectFile(event: Event) { - const input = event.target as HTMLInputElement; - const file = input.files?.[0]; - if (!file) return; + async function handleOpenProject() { try { - const text = await file.text(); + const text = await openProject(); + if (!text) return; loadProject(JSON.parse(text)); - statusMessage = `Loaded project: ${file.name}`; + statusMessage = 'Loaded project'; rescheduleAudio(); } catch (error) { statusMessage = error instanceof Error ? error.message : 'Failed to load project'; } - input.value = ''; } async function handleExportAudio() { @@ -202,9 +200,9 @@
- - - + + +
@@ -230,8 +228,6 @@ {statusMessage} {/if}
- -
diff --git a/frontend/src/lib/platform/files.ts b/frontend/src/lib/platform/files.ts new file mode 100644 index 0000000..6cbe7d4 --- /dev/null +++ b/frontend/src/lib/platform/files.ts @@ -0,0 +1,38 @@ +import * as FileService from '../../../bindings/sfxkeeb/fileservice'; + +export type VideoOpenResult = { + url: string; + name: string; +}; + +export async function openVideo(): Promise { + const result = await FileService.OpenVideo(); + if (!result.url) return null; + return { url: result.url, name: result.name }; +} + +export async function openProject(): Promise { + const text = await FileService.OpenProject(); + return text || null; +} + +export async function saveProject(content: string, defaultName = 'project.json'): Promise { + await FileService.SaveProject(content, defaultName); + return true; +} + +async function blobToBase64(blob: Blob): Promise { + const buffer = await blob.arrayBuffer(); + const bytes = new Uint8Array(buffer); + let binary = ''; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]!); + } + return btoa(binary); +} + +export async function saveAudio(blob: Blob, defaultName = 'keyboard_track.wav'): Promise { + const dataBase64 = await blobToBase64(blob); + await FileService.SaveAudio(dataBase64, defaultName); + return true; +} diff --git a/frontend/src/lib/project.ts b/frontend/src/lib/project.ts index 0f5bb8d..35e9dea 100644 --- a/frontend/src/lib/project.ts +++ b/frontend/src/lib/project.ts @@ -1,5 +1,6 @@ import { renderKeyboardTrack } from './audio/export'; import type { SampleCache } from './audio/sampleCache'; +import { saveAudio, saveProject } from './platform/files'; import type { LegacySwitchType, Marker, Project, SwitchType } from './types'; import { app, setActiveSwitch, setMarkers, setSelectedIds } from './store.svelte'; @@ -32,15 +33,10 @@ export function buildProject(): Project { }; } -export function downloadProject(filename = 'project.json') { +export async function downloadProject(filename = 'project.json') { const project = buildProject(); - const blob = new Blob([JSON.stringify(project, null, 2)], { type: 'application/json' }); - const url = URL.createObjectURL(blob); - const anchor = document.createElement('a'); - anchor.href = url; - anchor.download = filename; - anchor.click(); - URL.revokeObjectURL(url); + const content = JSON.stringify(project, null, 2); + await saveProject(content, filename); } export function loadProject(data: Project & { version?: number; switch?: string }) { @@ -65,10 +61,5 @@ export async function exportAudio(duration: number, cache?: SampleCache) { cache, }); - const url = URL.createObjectURL(blob); - const anchor = document.createElement('a'); - anchor.href = url; - anchor.download = 'keyboard_track.wav'; - anchor.click(); - URL.revokeObjectURL(url); + await saveAudio(blob, 'keyboard_track.wav'); } diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index d867977..7231e97 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url' import type { IncomingMessage, ServerResponse } from 'node:http' import { defineConfig, type Connect, type Plugin } from 'vite' import { svelte } from '@sveltejs/vite-plugin-svelte' +import wails from '@wailsio/runtime/plugins/vite' const repoRoot = resolve(fileURLToPath(import.meta.url), '../..') const assetsDir = resolve(repoRoot, 'assets') @@ -49,8 +50,11 @@ function serveRepoAssets(): Plugin { } export default defineConfig({ - plugins: [svelte(), serveRepoAssets()], + plugins: [svelte(), wails('./bindings'), serveRepoAssets()], server: { + host: '127.0.0.1', + port: Number(process.env.WAILS_VITE_PORT) || 5173, + strictPort: true, fs: { allow: [repoRoot], },